dsh-tabbit 0.2.2 → 0.3.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/CHANGELOG.md +100 -0
- package/LICENSE +21 -0
- package/README.en.md +116 -0
- package/README.md +55 -81
- package/client/client.js +389 -0
- package/cordis.patch.yml +77 -2
- package/lib/core/index.js +742 -0
- package/lib/installer/detect.js +374 -0
- package/lib/installer/download.js +247 -0
- package/lib/installer/index.js +254 -0
- package/lib/mentions/index.js +595 -0
- package/lib/permissions/index.js +136 -0
- package/lib/runtime/cli.js +229 -0
- package/lib/runtime/client.js +431 -0
- package/lib/runtime/codec.js +126 -0
- package/lib/runtime/endpoint.js +248 -0
- package/lib/runtime/errors.js +126 -0
- package/lib/runtime/instances.js +287 -0
- package/lib/runtime/net.js +143 -0
- package/lib/runtime/peer.js +132 -0
- package/lib/tool-browser/index.js +425 -0
- package/lib/update-check.js +343 -0
- package/lib/web-fetch/index.js +219 -0
- package/package.json +53 -16
- package/skills/tabbit/SKILL.md +66 -0
- package/skills/tabbit/references/interaction-helpers.md +150 -0
- package/skills/tabbit/references/platform-invocation.md +174 -0
- package/skills/{tabbit-browser → tabbit}/references/playwright-recipes.md +11 -3
- package/skills/tabbit/references/runtime-recovery.md +104 -0
- package/README.zh-CN.md +0 -114
- package/index.js +0 -352
- package/installer.js +0 -568
- package/skills/tabbit-browser/SKILL.md +0 -274
- package/skills/tabbit-browser/agents/openai.yaml +0 -4
- package/skills/tabbit-browser/references/interaction-helpers.md +0 -103
- package/skills/tabbit-browser/references/platform-invocation.md +0 -45
- package/skills/tabbit-browser/references/runtime-recovery.md +0 -95
- package/update-check.js +0 -177
- /package/skills/{tabbit-browser → tabbit}/references/information-extraction.md +0 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* 文件职责:稳定版 Tabbit 的安装检测 + 地区/平台/下载目标推导
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 回答四个问题(全是"读系统信息",无副作用):
|
|
7
|
+
* 1. 机器上装了哪些稳定版 Tabbit?什么版本?
|
|
8
|
+
* macOS:读 .app 包里的 Info.plist(plutil 命令);
|
|
9
|
+
* Windows:读卸载注册表(reg.exe query)。
|
|
10
|
+
* 2. 版本达标吗?(最低 1.9.0,isVersionAtLeast 做数字段比较)
|
|
11
|
+
* 3. 用户在哪个地区?(决定下国际版 tabbit.ai 还是国内版 tabbit.com)
|
|
12
|
+
* 4. 该下哪个安装包?(平台/CPU 架构 → .dmg/.exe 的下载 URL)
|
|
13
|
+
*
|
|
14
|
+
* 出处:自 github:Tabbit-Browser/dsh-tabbit(本包 npm 0.2.x 世代)的
|
|
15
|
+
* installer.js 移植。Runtime Service 可达性有两路信号,强弱有别:
|
|
16
|
+
* - 【主信号】launcher 实例注册表的 endpoint 文件——只在服务真正运行期间
|
|
17
|
+
* 存在(见 ../runtime/instances.ts 的 listInstances()),精确且便宜;
|
|
18
|
+
* - 【降级信号】进程列表匹配(ps / Get-CimInstance 找
|
|
19
|
+
* browser-runtime-service.mjs / nodejs-playwright-runtime.mjs)——又慢
|
|
20
|
+
* 又粗,但不依赖注册表目录。保留它是为 Windows:注册表目录在 Windows 上
|
|
21
|
+
* 的位置未经真机确认,注册表读不到时进程探测是唯一可用的在线判据。
|
|
22
|
+
* 组合逻辑在调用方(installer/index.ts):注册表有在线实例即 ready;
|
|
23
|
+
* 注册表空时才落到进程探测。本模块自身只提供"读系统信息"的原料。
|
|
24
|
+
*
|
|
25
|
+
* 可测性设计:所有会碰系统的函数都接受可注入的 run/platform/env 参数
|
|
26
|
+
* (默认用真系统),单元测试喂假数据就能全覆盖,不需要真装 Tabbit。
|
|
27
|
+
*/
|
|
28
|
+
import { spawnSync } from 'node:child_process';
|
|
29
|
+
import { constants } from 'node:fs';
|
|
30
|
+
import { access } from 'node:fs/promises';
|
|
31
|
+
import { homedir } from 'node:os';
|
|
32
|
+
import { join } from 'node:path';
|
|
33
|
+
/* 支持的最低稳定版版本。 */
|
|
34
|
+
export const MINIMUM_TABBIT_VERSION = '1.9.0';
|
|
35
|
+
/* 默认实现:spawnSync 同步执行(检测场景低频短命令,同步最简单)。 */
|
|
36
|
+
const defaultRun = (command, args) => {
|
|
37
|
+
const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true, maxBuffer: 16 * 1024 * 1024 });
|
|
38
|
+
return { status: result.status, stdout: result.stdout ?? '' };
|
|
39
|
+
};
|
|
40
|
+
/* 两条发行线的官网源。 */
|
|
41
|
+
const INSTALLER_ORIGINS = {
|
|
42
|
+
domestic: 'https://www.tabbit.com',
|
|
43
|
+
international: 'https://www.tabbit.ai',
|
|
44
|
+
};
|
|
45
|
+
/* "平台:架构" → 安装包规格。不在表里的组合(如 Linux)不支持。 */
|
|
46
|
+
const DOWNLOADS = {
|
|
47
|
+
'win32:x64': { platform: 'windows', arch: 'x86_64', extension: '.exe', fallbackName: 'Tabbit Browser Installer.exe' },
|
|
48
|
+
'darwin:arm64': { platform: 'mac', arch: 'ARM_64', extension: '.dmg', fallbackName: 'Tabbit Browser Installer ARM64.dmg' },
|
|
49
|
+
'darwin:x64': { platform: 'mac', arch: 'x86_64', extension: '.dmg', fallbackName: 'Tabbit Browser Installer Intel.dmg' },
|
|
50
|
+
};
|
|
51
|
+
/* macOS 上要找的两个应用(按 bundleId 精确认证,不能光看目录名——重命名的假目录不算)。 */
|
|
52
|
+
const MAC_APPLICATIONS = [
|
|
53
|
+
{ name: 'Tabbit', bundleId: 'com.tabbit-ai.Tabbit', edition: 'international', channel: 'stable' },
|
|
54
|
+
{ name: 'Tabbit Browser', bundleId: 'com.tab-browser.Tabbit', edition: 'domestic', channel: 'stable' },
|
|
55
|
+
];
|
|
56
|
+
/* Windows 上按卸载表 DisplayName 认的两个名字。 */
|
|
57
|
+
const WINDOWS_DISPLAY_NAMES = new Map([
|
|
58
|
+
['Tabbit', { edition: 'international', channel: 'stable' }],
|
|
59
|
+
['Tabbit Browser', { edition: 'domestic', channel: 'stable' }],
|
|
60
|
+
]);
|
|
61
|
+
/*
|
|
62
|
+
* 把各种乱七八糟的 locale 写法归一成两位国家码(大写):
|
|
63
|
+
* "zh_CN" / "zh-CN" / "'en_US'" / "zh_CN@calendar=..." → CN / US
|
|
64
|
+
* 已经是纯两位码("CN")就直接大写返回。
|
|
65
|
+
* 解析不出返回 undefined。
|
|
66
|
+
*/
|
|
67
|
+
export function normalizeRegionCode(value) {
|
|
68
|
+
const locale = String(value ?? '')
|
|
69
|
+
.trim()
|
|
70
|
+
.replace(/^['"]|['"]$/g, '') // 去掉包裹引号
|
|
71
|
+
.split('@', 1)[0] ?? ''; // 去掉 @ 后面的修饰(日历等)
|
|
72
|
+
if (/^[a-z]{2}$/i.test(locale))
|
|
73
|
+
return locale.toUpperCase();
|
|
74
|
+
return locale.match(/(?:_|-)([a-z]{2})$/i)?.[1]?.toUpperCase();
|
|
75
|
+
}
|
|
76
|
+
/*
|
|
77
|
+
* 探测系统地区:
|
|
78
|
+
* macOS → `defaults read -g AppleLocale`(如 zh_CN);
|
|
79
|
+
* Windows → PowerShell 读 Get-WinHomeLocation 的 GeoId 转两位国家码。
|
|
80
|
+
* 探测不到 → undefined(下游按国际版处理)。
|
|
81
|
+
*/
|
|
82
|
+
export function detectSystemRegion({ platform = process.platform, run = defaultRun, } = {}) {
|
|
83
|
+
if (platform === 'darwin') {
|
|
84
|
+
const result = run('/usr/bin/defaults', ['read', '-g', 'AppleLocale']);
|
|
85
|
+
return result.status === 0 ? normalizeRegionCode(result.stdout) : undefined;
|
|
86
|
+
}
|
|
87
|
+
if (platform === 'win32') {
|
|
88
|
+
const script = '([System.Globalization.RegionInfo]::new((Get-WinHomeLocation).GeoId)).TwoLetterISORegionName';
|
|
89
|
+
const result = run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]);
|
|
90
|
+
return result.status === 0 ? normalizeRegionCode(result.stdout) : undefined;
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
/* 地区 → 发行线:只有 CN 走国内版,其余(含未知)都走国际版。 */
|
|
95
|
+
export function installerDistributionForRegion(regionCode) {
|
|
96
|
+
return normalizeRegionCode(regionCode) === 'CN' ? 'domestic' : 'international';
|
|
97
|
+
}
|
|
98
|
+
/* 拼安装器下载 URL(官方 upgrade API + 平台/架构参数 + 来源标记)。 */
|
|
99
|
+
export function installerUrl(spec, distribution = 'international') {
|
|
100
|
+
const origin = INSTALLER_ORIGINS[distribution];
|
|
101
|
+
const query = new URLSearchParams({
|
|
102
|
+
platform: spec.platform,
|
|
103
|
+
arch: spec.arch,
|
|
104
|
+
// tab_brand 是下载统计侧的归因标识,沿用 0.2.x 世代已在线上使用的
|
|
105
|
+
// 'dshr'(换值会把同一渠道的统计切成两段)。
|
|
106
|
+
tab_brand: 'dshr',
|
|
107
|
+
utm_source: 'dsh',
|
|
108
|
+
});
|
|
109
|
+
return `${origin}/api/v0/upgrade/installer?${query}`;
|
|
110
|
+
}
|
|
111
|
+
/*
|
|
112
|
+
* 推导本机应下载的安装包规格。两个"报的架构不等于真架构"的坑要修正:
|
|
113
|
+
* - macOS:x64 版 Node 跑在 Apple Silicon 的 Rosetta 转译层下时,
|
|
114
|
+
* process.arch 谎报 x64。用 `sysctl hw.optional.arm64` 问硬件真话
|
|
115
|
+
* (返回 1 = 实为 ARM 芯片,应下 ARM64 包)。
|
|
116
|
+
* - Windows:32 位进程跑在 64 位系统上时看 PROCESSOR_ARCHITEW6432
|
|
117
|
+
* 环境变量拿到真实架构。
|
|
118
|
+
* 不支持的组合(Linux 等)直接抛错。
|
|
119
|
+
*/
|
|
120
|
+
export function detectPlatformSpec({ platform = process.platform, arch = process.arch, env = process.env, run = defaultRun, } = {}) {
|
|
121
|
+
let nativeArch = arch;
|
|
122
|
+
if (platform === 'darwin' && arch === 'x64') {
|
|
123
|
+
const result = run('/usr/sbin/sysctl', ['-n', 'hw.optional.arm64']);
|
|
124
|
+
if (result.status === 0 && result.stdout.trim() === '1')
|
|
125
|
+
nativeArch = 'arm64';
|
|
126
|
+
}
|
|
127
|
+
if (platform === 'win32') {
|
|
128
|
+
const reported = String(env.PROCESSOR_ARCHITEW6432 ?? env.PROCESSOR_ARCHITECTURE ?? arch).toLowerCase();
|
|
129
|
+
nativeArch = reported === 'amd64' || reported === 'x86_64' ? 'x64' : arch;
|
|
130
|
+
}
|
|
131
|
+
const spec = DOWNLOADS[`${platform}:${nativeArch}`];
|
|
132
|
+
if (!spec)
|
|
133
|
+
throw new Error(`Tabbit Browser installer is unavailable for ${platform}/${nativeArch}.`);
|
|
134
|
+
return { ...spec };
|
|
135
|
+
}
|
|
136
|
+
/* access() 的布尔化封装(存在返回 true,不存在不抛错返回 false)。 */
|
|
137
|
+
async function exists(path, mode = constants.F_OK) {
|
|
138
|
+
try {
|
|
139
|
+
await access(path, mode);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
/* 用 macOS 自带的 plutil 从 Info.plist 里抽一个键的值(-o - 输出到 stdout)。 */
|
|
147
|
+
function readPlistValue(plistPath, key, run) {
|
|
148
|
+
const result = run('/usr/bin/plutil', ['-extract', key, 'raw', '-o', '-', plistPath]);
|
|
149
|
+
return result.status === 0 ? result.stdout.trim() : undefined;
|
|
150
|
+
}
|
|
151
|
+
/*
|
|
152
|
+
* macOS 安装检测:在 /Applications 和 ~/Applications 两个根下找目标 .app。
|
|
153
|
+
* 认证三步:Info.plist 存在 → CFBundleIdentifier 与预期完全一致(防重名假
|
|
154
|
+
* 目录冒充)→ 读 CFBundleShortVersionString 拿版本。seenBundleIds 去重
|
|
155
|
+
* (两个根都装了同一应用时只记第一处)。
|
|
156
|
+
*/
|
|
157
|
+
export async function detectMacInstallations({ userHome = homedir(), run = defaultRun, } = {}) {
|
|
158
|
+
const roots = ['/Applications', join(userHome, 'Applications')];
|
|
159
|
+
const installations = [];
|
|
160
|
+
const seenBundleIds = new Set();
|
|
161
|
+
for (const app of MAC_APPLICATIONS) {
|
|
162
|
+
for (const root of roots) {
|
|
163
|
+
const path = join(root, `${app.name}.app`);
|
|
164
|
+
const plistPath = join(path, 'Contents', 'Info.plist');
|
|
165
|
+
if (!(await exists(plistPath)))
|
|
166
|
+
continue;
|
|
167
|
+
const actualBundleId = readPlistValue(plistPath, 'CFBundleIdentifier', run);
|
|
168
|
+
if (actualBundleId !== app.bundleId || seenBundleIds.has(app.bundleId))
|
|
169
|
+
continue;
|
|
170
|
+
const version = readPlistValue(plistPath, 'CFBundleShortVersionString', run);
|
|
171
|
+
installations.push({ ...app, path, ...(version ? { version } : {}) });
|
|
172
|
+
seenBundleIds.add(app.bundleId);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return installations;
|
|
176
|
+
}
|
|
177
|
+
/*
|
|
178
|
+
* 解析 `reg.exe query` 的文本输出(Windows 卸载注册表)。输出形如:
|
|
179
|
+
* HKEY_LOCAL_MACHINE\...\Uninstall\Tabbit
|
|
180
|
+
* DisplayName REG_SZ Tabbit
|
|
181
|
+
* DisplayVersion REG_SZ 1.9.2
|
|
182
|
+
* ...
|
|
183
|
+
* 状态机式逐行扫:见到 HKEY_ 开头行 = 新记录开始(先提交上一条);
|
|
184
|
+
* 缩进行按 "键 REG_类型 值" 抽字段。commit 时只认 DisplayName 在白名单里的;
|
|
185
|
+
* DisplayIcon 常带 ",索引" 后缀和包裹引号,清理后当可执行文件路径用。
|
|
186
|
+
* 导出仅为可单测(纯文本进、结构出)。
|
|
187
|
+
*/
|
|
188
|
+
export function parseWindowsUninstallRegistry(output) {
|
|
189
|
+
const installations = [];
|
|
190
|
+
let record;
|
|
191
|
+
const commit = () => {
|
|
192
|
+
if (!record)
|
|
193
|
+
return;
|
|
194
|
+
const identity = WINDOWS_DISPLAY_NAMES.get(record.DisplayName ?? '');
|
|
195
|
+
if (!identity)
|
|
196
|
+
return;
|
|
197
|
+
const icon = record.DisplayIcon?.replace(/,\s*-?\d+$/, '').replace(/^"(.*)"$/, '$1');
|
|
198
|
+
installations.push({
|
|
199
|
+
name: record.DisplayName ?? '',
|
|
200
|
+
...identity,
|
|
201
|
+
...(record.InstallLocation || icon ? { path: record.InstallLocation || icon } : {}),
|
|
202
|
+
...(icon ? { executable: icon } : {}),
|
|
203
|
+
...(record.DisplayVersion ? { version: record.DisplayVersion } : {}),
|
|
204
|
+
...(record.registryKey ? { registryKey: record.registryKey } : {}),
|
|
205
|
+
});
|
|
206
|
+
};
|
|
207
|
+
for (const line of output.split(/\r?\n/)) {
|
|
208
|
+
if (/^HKEY_/i.test(line.trim())) {
|
|
209
|
+
commit();
|
|
210
|
+
record = { registryKey: line.trim() };
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
if (!record)
|
|
214
|
+
continue;
|
|
215
|
+
const match = line.match(/^\s+(DisplayName|DisplayVersion|InstallLocation|DisplayIcon)\s+REG_\w+\s+(.*)$/i);
|
|
216
|
+
if (match?.[1] !== undefined && match[2] !== undefined)
|
|
217
|
+
record[match[1]] = match[2].trim();
|
|
218
|
+
}
|
|
219
|
+
commit();
|
|
220
|
+
return installations;
|
|
221
|
+
}
|
|
222
|
+
/*
|
|
223
|
+
* Windows 安装检测:查 HKCU(当前用户)和 HKLM(本机)两个卸载表根,
|
|
224
|
+
* 每个都查 64 位和 32 位注册表视图(/reg:64、/reg:32——32 位安装器写的键
|
|
225
|
+
* 在 64 位视图里看不见,反之亦然)。
|
|
226
|
+
* 两轮策略:先精确查两个已知子键名(快);一无所获时降级为整根递归扫
|
|
227
|
+
* (/s,慢但兜得住"安装技术生成随机卸载子键名"的情况)。
|
|
228
|
+
*/
|
|
229
|
+
export function detectWindowsInstallations({ run = defaultRun } = {}) {
|
|
230
|
+
const roots = [
|
|
231
|
+
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
|
232
|
+
'HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
|
233
|
+
];
|
|
234
|
+
const targetedKeys = roots.flatMap((root) => ['Tabbit', 'Tabbit Browser'].map((name) => `${root}\\${name}`));
|
|
235
|
+
const installations = [];
|
|
236
|
+
const seen = new Set();
|
|
237
|
+
const collect = (output) => {
|
|
238
|
+
for (const item of parseWindowsUninstallRegistry(output)) {
|
|
239
|
+
const key = `${item.name}\0${item.path ?? ''}`;
|
|
240
|
+
if (seen.has(key))
|
|
241
|
+
continue;
|
|
242
|
+
seen.add(key);
|
|
243
|
+
installations.push(item);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
for (const key of targetedKeys) {
|
|
247
|
+
for (const view of ['64', '32']) {
|
|
248
|
+
const result = run('reg.exe', ['query', key, `/reg:${view}`]);
|
|
249
|
+
if (result.status === 0)
|
|
250
|
+
collect(result.stdout);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (installations.length > 0)
|
|
254
|
+
return installations;
|
|
255
|
+
// 兼容兜底:某些安装技术用生成式卸载子键名,well-known 键名查不到时
|
|
256
|
+
// 保留整根广扫。
|
|
257
|
+
for (const root of roots) {
|
|
258
|
+
for (const view of ['64', '32']) {
|
|
259
|
+
const result = run('reg.exe', ['query', root, '/s', `/reg:${view}`]);
|
|
260
|
+
if (result.status === 0)
|
|
261
|
+
collect(result.stdout);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return installations;
|
|
265
|
+
}
|
|
266
|
+
/* "1.9.2" / "v1.9" → [1,9,2];解析不出(空/乱格式)→ undefined。 */
|
|
267
|
+
function numericVersion(version) {
|
|
268
|
+
const match = String(version ?? '').trim().match(/^v?(\d+(?:\.\d+)*)/i);
|
|
269
|
+
return match?.[1] !== undefined ? match[1].split('.').map(Number) : undefined;
|
|
270
|
+
}
|
|
271
|
+
/*
|
|
272
|
+
* 版本达标判断:按数字段逐段比较(长度不齐的段补 0),
|
|
273
|
+
* 任一方解析不出版本号一律算不达标(宁严勿松)。
|
|
274
|
+
*/
|
|
275
|
+
export function isVersionAtLeast(version, minimum = MINIMUM_TABBIT_VERSION) {
|
|
276
|
+
const actual = numericVersion(version);
|
|
277
|
+
const required = numericVersion(minimum);
|
|
278
|
+
if (!actual || !required)
|
|
279
|
+
return false;
|
|
280
|
+
const length = Math.max(actual.length, required.length);
|
|
281
|
+
for (let index = 0; index < length; index += 1) {
|
|
282
|
+
const left = actual[index] ?? 0;
|
|
283
|
+
const right = required[index] ?? 0;
|
|
284
|
+
if (left !== right)
|
|
285
|
+
return left > right;
|
|
286
|
+
}
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
/*
|
|
290
|
+
* 检测总入口:按平台分派(其它平台返回空),并把结果分成
|
|
291
|
+
* installations(所有找到的)和 supportedInstallations(版本达标的)两份。
|
|
292
|
+
* 注意:这里只管"装没装/版本够不够";"Runtime Service 通不通"由调用方
|
|
293
|
+
* 拼 ctx.tabbit.launcherPath()/instances() 判断(理由见文件头)。
|
|
294
|
+
*/
|
|
295
|
+
export async function detectTabbitInstallations({ platform = process.platform, userHome = homedir(), run = defaultRun, minimumVersion = MINIMUM_TABBIT_VERSION, } = {}) {
|
|
296
|
+
const installations = platform === 'darwin'
|
|
297
|
+
? await detectMacInstallations({ userHome, run })
|
|
298
|
+
: platform === 'win32'
|
|
299
|
+
? detectWindowsInstallations({ run })
|
|
300
|
+
: [];
|
|
301
|
+
const supportedInstallations = installations.filter((item) => isVersionAtLeast(item.version, minimumVersion));
|
|
302
|
+
return { installations, supportedInstallations };
|
|
303
|
+
}
|
|
304
|
+
/*
|
|
305
|
+
* 判断一条进程记录是不是 Tabbit 的 Runtime Service 常驻进程。
|
|
306
|
+
* 认两个入口脚本名(浏览器两代 Runtime 的常驻进程各叫一个):
|
|
307
|
+
* browser-runtime-service.mjs / nodejs-playwright-runtime.mjs。
|
|
308
|
+
* 匹配要求脚本名以路径分隔符/引号/空白开头、以结尾或引号/空白收尾——
|
|
309
|
+
* 防止 "not-browser-runtime-service.mjs.bak" 之类的伪匹配;同时刻意
|
|
310
|
+
* 【不】匹配 tabbit-cli 等短命 CLI 进程(那是每次调用起一个的客户端,
|
|
311
|
+
* 不代表服务在跑)。
|
|
312
|
+
*/
|
|
313
|
+
function isTabbitRuntimeProcess(name, command) {
|
|
314
|
+
const value = `${String(name ?? '')} ${String(command ?? '')}`;
|
|
315
|
+
return (/(?:^|[\\/"'\s])browser-runtime-service\.mjs(?=$|["'\s])/i.test(value) ||
|
|
316
|
+
/(?:^|[\\/"'\s])nodejs-playwright-runtime\.mjs(?=$|["'\s])/i.test(value));
|
|
317
|
+
}
|
|
318
|
+
/*
|
|
319
|
+
* 解析 `ps -axo pid=,comm=,args=` 的输出(每行:pid 命令名 完整命令行)。
|
|
320
|
+
* 导出仅为可单测(纯文本进、结构出)。
|
|
321
|
+
*/
|
|
322
|
+
export function parseUnixProcessList(output) {
|
|
323
|
+
const processes = [];
|
|
324
|
+
for (const line of String(output).split(/\r?\n/)) {
|
|
325
|
+
const match = line.match(/^\s*(\d+)\s+(\S+)\s+(.*)$/);
|
|
326
|
+
if (!match || !isTabbitRuntimeProcess(match[2], match[3]))
|
|
327
|
+
continue;
|
|
328
|
+
processes.push({ pid: Number(match[1]), name: match[2] ?? '' });
|
|
329
|
+
}
|
|
330
|
+
return processes;
|
|
331
|
+
}
|
|
332
|
+
/*
|
|
333
|
+
* 解析 PowerShell `Get-CimInstance ... | ConvertTo-Json` 的输出。
|
|
334
|
+
* ConvertTo-Json 的坑:结果只有一条时输出的是【单个对象】而非数组,要归一。
|
|
335
|
+
*/
|
|
336
|
+
export function parseWindowsProcessList(output) {
|
|
337
|
+
if (!String(output).trim())
|
|
338
|
+
return [];
|
|
339
|
+
let records;
|
|
340
|
+
try {
|
|
341
|
+
records = JSON.parse(output);
|
|
342
|
+
}
|
|
343
|
+
catch {
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
const list = Array.isArray(records) ? records : [records];
|
|
347
|
+
return list
|
|
348
|
+
.filter((record) => record !== null && typeof record === 'object' && isTabbitRuntimeProcess(record.Name, record.CommandLine))
|
|
349
|
+
.map((record) => ({ pid: Number(record.ProcessId), name: String(record.Name ?? '') }));
|
|
350
|
+
}
|
|
351
|
+
/*
|
|
352
|
+
* 列出当前机器上的 Runtime Service 进程:
|
|
353
|
+
* Windows → PowerShell 查 Win32_Process(CommandLine LIKE 两个脚本名);
|
|
354
|
+
* 其它平台 → `ps -axo pid=,comm=,args=` 全量列表后正则筛。
|
|
355
|
+
* 命令失败(status 非 0)一律返回空列表——降级信号探测不到就当没有。
|
|
356
|
+
*/
|
|
357
|
+
export function detectTabbitRuntimeProcesses({ platform = process.platform, run = defaultRun, } = {}) {
|
|
358
|
+
if (platform === 'win32') {
|
|
359
|
+
const script = `Get-CimInstance Win32_Process -Filter "CommandLine LIKE '%browser-runtime-service.mjs%' OR CommandLine LIKE '%nodejs-playwright-runtime.mjs%'" | Select-Object ProcessId,Name,CommandLine | ConvertTo-Json -Compress`;
|
|
360
|
+
const result = run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]);
|
|
361
|
+
return result.status === 0 ? parseWindowsProcessList(result.stdout) : [];
|
|
362
|
+
}
|
|
363
|
+
const result = run('ps', ['-axo', 'pid=,comm=,args=']);
|
|
364
|
+
return result.status === 0 ? parseUnixProcessList(result.stdout) : [];
|
|
365
|
+
}
|
|
366
|
+
/*
|
|
367
|
+
* 把进程列表归纳成"在不在跑/有没有歧义":>1 个 Runtime 进程 = 多个 Tabbit
|
|
368
|
+
* 实例同时在跑(每个实例一个常驻 Runtime),选实例可能有歧义——调用方
|
|
369
|
+
* 据此提示用户设置 tabbit.instance。
|
|
370
|
+
*/
|
|
371
|
+
export function summarizeTabbitRuntime(processes) {
|
|
372
|
+
const instanceCount = Array.isArray(processes) ? processes.length : 0;
|
|
373
|
+
return { instanceCount, running: instanceCount > 0, ambiguous: instanceCount > 1 };
|
|
374
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* ============================================================================
|
|
3
|
+
* 文件职责:安装器的后台下载(安全加固版的"下载一个文件")
|
|
4
|
+
* ============================================================================
|
|
5
|
+
*
|
|
6
|
+
* 自 github:Tabbit-Browser/dsh-plugin 的 installer.js 移植。看似只是下载,
|
|
7
|
+
* 但落到用户磁盘上的是一个【将要被双击运行的浏览器安装包】,所以做了一整套
|
|
8
|
+
* 防御(每条都对应一种真实风险):
|
|
9
|
+
*
|
|
10
|
+
* - 域名白名单:重定向链的【最终 URL】必须是 https 且落在 tabbit.com/
|
|
11
|
+
* tabbit.ai 系主机上——防 CDN/短链被劫持后把"安装包"重定向到恶意主机;
|
|
12
|
+
* - 体积上限 1 GiB:Content-Length 和实际累计字节双重检查——防被灌爆磁盘;
|
|
13
|
+
* - 原子写入:先写 `目标名.<pid>.<时间戳>.part` 临时文件('wx' 独占创建 +
|
|
14
|
+
* 0o600 仅本人可读写),全部校验通过后才 rename 成正式名——保证
|
|
15
|
+
* Downloads 里出现的正式文件名【要么不存在、要么就是完整校验过的】,
|
|
16
|
+
* 绝无半截文件;任何失败路径都删 .part 不留垃圾;
|
|
17
|
+
* - 完整性校验:收到的字节数必须等于 Content-Length(防截断);
|
|
18
|
+
* - 魔数(magic bytes)签名校验:.exe 开头必须是 'MZ'(Windows PE 格式),
|
|
19
|
+
* .dmg 结尾 512 字节内必须有 'koly'(DMG 结尾块签名)——防服务器返回
|
|
20
|
+
* 错误页/HTML 却顶着安装包文件名落盘;
|
|
21
|
+
* - 文件名清洗:Content-Disposition 给的文件名先 basename 掐掉路径部分
|
|
22
|
+
* (防 ../../ 路径穿越),再滤掉控制字符和 Windows 保留字符,扩展名
|
|
23
|
+
* 对不上就直接用兜底名;
|
|
24
|
+
* - 重名避让:已存在同名文件时用 "名字 (1).ext" 递增,绝不覆盖用户已有文件。
|
|
25
|
+
*/
|
|
26
|
+
import { constants } from 'node:fs';
|
|
27
|
+
import { access, mkdir, open, rename, rm } from 'node:fs/promises';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
import { basename, join } from 'node:path';
|
|
30
|
+
import { detectPlatformSpec, detectSystemRegion, installerDistributionForRegion, installerUrl, } from './detect.js';
|
|
31
|
+
/* 安装包体积安全上限:1 GiB。 */
|
|
32
|
+
const MAX_INSTALLER_BYTES = 1024 * 1024 * 1024;
|
|
33
|
+
/* 最终下载 URL 允许落在的主机白名单(两条发行线的官网/包/发布域)。 */
|
|
34
|
+
const ALLOWED_DOWNLOAD_HOSTS = new Set([
|
|
35
|
+
'www.tabbit.com',
|
|
36
|
+
'pkg.tabbit.com',
|
|
37
|
+
'releases.tabbit.com',
|
|
38
|
+
'www.tabbit.ai',
|
|
39
|
+
'pkg.tabbit.ai',
|
|
40
|
+
'releases.tabbit.ai',
|
|
41
|
+
]);
|
|
42
|
+
async function exists(path) {
|
|
43
|
+
try {
|
|
44
|
+
await access(path, constants.F_OK);
|
|
45
|
+
return true;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/* 文件名里要清洗掉的字符:Windows 保留字符(<>:"/\|?*)+ 控制字符(0x00-0x1f)。 */
|
|
52
|
+
const CONTROL_OR_RESERVED_CHARS = new RegExp('[<>:"/\\\\|?*]|[\\x00-\\x1f]', 'g');
|
|
53
|
+
/*
|
|
54
|
+
* 从 HTTP 响应决定保存文件名。优先级:
|
|
55
|
+
* 1. Content-Disposition 的 filename*=UTF-8''(RFC 5987 编码格式,可带中文);
|
|
56
|
+
* 2. Content-Disposition 的普通 filename=;
|
|
57
|
+
* 3. 最终 URL 路径的最后一段;
|
|
58
|
+
* 4. 平台规格里的兜底名。
|
|
59
|
+
* 拿到候选后:basename 掐路径(防穿越)→ 清洗保留/控制字符 →
|
|
60
|
+
* 扩展名必须匹配平台预期(.dmg/.exe),否则整个放弃用兜底名。
|
|
61
|
+
*/
|
|
62
|
+
function filenameFromResponse(response, spec) {
|
|
63
|
+
const disposition = response.headers.get('content-disposition') ?? '';
|
|
64
|
+
const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1];
|
|
65
|
+
const plain = disposition.match(/filename="?([^";]+)"?/i)?.[1];
|
|
66
|
+
let candidate;
|
|
67
|
+
try {
|
|
68
|
+
candidate = encoded ? decodeURIComponent(encoded) : plain;
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
candidate = plain;
|
|
72
|
+
}
|
|
73
|
+
if (!candidate) {
|
|
74
|
+
try {
|
|
75
|
+
candidate = decodeURIComponent(basename(new URL(response.url).pathname));
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
candidate = undefined;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
const safe = basename(candidate || spec.fallbackName)
|
|
82
|
+
.replace(CONTROL_OR_RESERVED_CHARS, '_')
|
|
83
|
+
.trim();
|
|
84
|
+
return safe.toLowerCase().endsWith(spec.extension) ? safe : spec.fallbackName;
|
|
85
|
+
}
|
|
86
|
+
/* 重名避让:目标已存在就试 "名字 (1).ext"、"名字 (2).ext"……最多 1000 次。 */
|
|
87
|
+
async function uniqueDestination(directory, filename) {
|
|
88
|
+
const extensionIndex = filename.toLowerCase().lastIndexOf('.');
|
|
89
|
+
const stem = extensionIndex > 0 ? filename.slice(0, extensionIndex) : filename;
|
|
90
|
+
const extension = extensionIndex > 0 ? filename.slice(extensionIndex) : '';
|
|
91
|
+
for (let index = 0; index < 1000; index += 1) {
|
|
92
|
+
const candidate = join(directory, index === 0 ? filename : `${stem} (${index})${extension}`);
|
|
93
|
+
if (!(await exists(candidate)))
|
|
94
|
+
return candidate;
|
|
95
|
+
}
|
|
96
|
+
throw new Error('Could not allocate a unique installer filename.');
|
|
97
|
+
}
|
|
98
|
+
/*
|
|
99
|
+
* 魔数签名校验(读文件的头/尾几个字节比对格式签名):
|
|
100
|
+
* .exe:头 2 字节必须是 'MZ'(DOS/PE 可执行文件的经典签名);
|
|
101
|
+
* .dmg:文件末尾 512 字节的"koly 块"(DMG 格式的结尾元数据块签名)。
|
|
102
|
+
* 不匹配 = 下到的根本不是安装包(可能是错误页 HTML),直接判失败。
|
|
103
|
+
*/
|
|
104
|
+
async function verifyInstaller(path, spec, bytes) {
|
|
105
|
+
const handle = await open(path, 'r');
|
|
106
|
+
try {
|
|
107
|
+
if (spec.extension === '.exe') {
|
|
108
|
+
const header = Buffer.alloc(2);
|
|
109
|
+
await handle.read(header, 0, 2, 0);
|
|
110
|
+
if (header.toString('ascii') !== 'MZ')
|
|
111
|
+
throw new Error('Downloaded file is not a Windows executable.');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const trailer = Buffer.alloc(Math.min(512, bytes));
|
|
115
|
+
await handle.read(trailer, 0, trailer.length, Math.max(0, bytes - trailer.length));
|
|
116
|
+
if (trailer.subarray(0, 4).toString('ascii') !== 'koly') {
|
|
117
|
+
throw new Error('Downloaded file is not a valid DMG image.');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
finally {
|
|
121
|
+
await handle.close();
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/*
|
|
125
|
+
* 下载主流程。步骤(文件头列的每道防御在这里落地):
|
|
126
|
+
* 1. 推导平台规格 + 地区 → 拼下载 URL,确保输出目录存在;
|
|
127
|
+
* 2. fetch(跟随重定向)→ 校验 HTTP 状态 → 【白名单校验最终 URL】;
|
|
128
|
+
* 3. 决定文件名 + 避让重名 → 独占创建 .part 临时文件(0o600);
|
|
129
|
+
* 4. for-await 流式逐块写盘:每块查取消信号、累计字节数防超限;
|
|
130
|
+
* 进度节流上报(百分比前进了、或距上次超 1 秒才报一次);
|
|
131
|
+
* 5. handle.sync() 强制刷盘 → 字节数对账 → 魔数校验 → rename 转正;
|
|
132
|
+
* 6. 任何失败路径:关句柄、删 .part、原样抛错。
|
|
133
|
+
*/
|
|
134
|
+
export async function downloadInstaller(options = {}) {
|
|
135
|
+
const { signal, onProgress = () => undefined, outputDirectory = join(homedir(), 'Downloads'), fetchImpl = fetch, platformOptions } = options;
|
|
136
|
+
const spec = detectPlatformSpec(platformOptions);
|
|
137
|
+
const regionCode = detectSystemRegion(platformOptions);
|
|
138
|
+
const distribution = installerDistributionForRegion(regionCode);
|
|
139
|
+
const sourceUrl = installerUrl(spec, distribution);
|
|
140
|
+
await mkdir(outputDirectory, { recursive: true });
|
|
141
|
+
const response = await fetchImpl(sourceUrl, { redirect: 'follow', ...(signal ? { signal } : {}) });
|
|
142
|
+
if (!response.ok || !response.body)
|
|
143
|
+
throw new Error(`Installer download failed with HTTP ${response.status}.`);
|
|
144
|
+
// 白名单查的是 response.url——重定向链的【最终】落点,不是我们发起的 URL。
|
|
145
|
+
const finalUrl = new URL(response.url || sourceUrl);
|
|
146
|
+
if (finalUrl.protocol !== 'https:' || !ALLOWED_DOWNLOAD_HOSTS.has(finalUrl.hostname)) {
|
|
147
|
+
throw new Error(`Installer redirected to an untrusted host: ${finalUrl.hostname || finalUrl.href}`);
|
|
148
|
+
}
|
|
149
|
+
const expectedBytes = Number(response.headers.get('content-length')) || undefined;
|
|
150
|
+
if (expectedBytes && expectedBytes > MAX_INSTALLER_BYTES)
|
|
151
|
+
throw new Error('Installer exceeds the 1 GiB safety limit.');
|
|
152
|
+
const filename = filenameFromResponse(response, spec);
|
|
153
|
+
const destination = await uniqueDestination(outputDirectory, filename);
|
|
154
|
+
// .part 名里掺 pid+时间戳:并发下载互不踩;'wx' = 必须新建(已存在就报错)。
|
|
155
|
+
const partial = `${destination}.${process.pid}.${Date.now()}.part`;
|
|
156
|
+
const handle = await open(partial, 'wx', 0o600);
|
|
157
|
+
let receivedBytes = 0;
|
|
158
|
+
let lastPercent = -1;
|
|
159
|
+
let lastReportAt = 0;
|
|
160
|
+
try {
|
|
161
|
+
// response.body 是 Web 流;for-await 逐块消费(Node 里可直接异步迭代)。
|
|
162
|
+
for await (const chunk of response.body) {
|
|
163
|
+
if (signal?.aborted)
|
|
164
|
+
throw signal.reason ?? new Error('Download cancelled.');
|
|
165
|
+
receivedBytes += chunk.byteLength;
|
|
166
|
+
if (receivedBytes > MAX_INSTALLER_BYTES)
|
|
167
|
+
throw new Error('Installer exceeds the 1 GiB safety limit.');
|
|
168
|
+
await handle.write(chunk);
|
|
169
|
+
const now = Date.now();
|
|
170
|
+
const percent = expectedBytes ? Math.floor((receivedBytes / expectedBytes) * 100) : undefined;
|
|
171
|
+
if ((percent !== undefined && percent > lastPercent) || now - lastReportAt >= 1000) {
|
|
172
|
+
lastPercent = percent ?? lastPercent;
|
|
173
|
+
lastReportAt = now;
|
|
174
|
+
onProgress({ receivedBytes, expectedBytes, percent });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
await handle.sync(); // fsync:确保数据真正落盘(掉电也不丢)再进入校验
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
await handle.close().catch(() => undefined);
|
|
181
|
+
await rm(partial, { force: true });
|
|
182
|
+
throw error;
|
|
183
|
+
}
|
|
184
|
+
await handle.close();
|
|
185
|
+
try {
|
|
186
|
+
if (expectedBytes !== undefined && receivedBytes !== expectedBytes) {
|
|
187
|
+
throw new Error(`Installer download was incomplete: received ${receivedBytes} of ${expectedBytes} bytes.`);
|
|
188
|
+
}
|
|
189
|
+
await verifyInstaller(partial, spec, receivedBytes);
|
|
190
|
+
await rename(partial, destination); // 原子转正:从此正式文件名可见
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
await rm(partial, { force: true });
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
return {
|
|
197
|
+
path: destination,
|
|
198
|
+
bytes: receivedBytes,
|
|
199
|
+
platform: spec.platform,
|
|
200
|
+
arch: spec.arch,
|
|
201
|
+
region: regionCode ?? 'unknown',
|
|
202
|
+
distribution,
|
|
203
|
+
sourceUrl,
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/*
|
|
207
|
+
* 把 downloadInstaller 包装成 `ctx.jobs` 形状的任务句柄:
|
|
208
|
+
* - cancel → AbortController.abort(下载循环里检查 signal 会及时停);
|
|
209
|
+
* - 进度/结果以带前缀的行写进输出缓冲(TABBIT_DOWNLOAD_PROGRESS /
|
|
210
|
+
* TABBIT_INSTALLER_READY + JSON——机器可解析,模型也能读懂);
|
|
211
|
+
* - done 归一为三态:completed(含安装包路径)/ killed(用户取消)/
|
|
212
|
+
* failed(其它错误);
|
|
213
|
+
* - onSettled:无论成败都回调一次(installer/index.ts 用它清 activeJobs 账)。
|
|
214
|
+
*/
|
|
215
|
+
export function createDownloadJob(options = {}) {
|
|
216
|
+
const controller = new AbortController();
|
|
217
|
+
let pendingOutput = '';
|
|
218
|
+
const append = (line) => {
|
|
219
|
+
pendingOutput += `${line}\n`;
|
|
220
|
+
};
|
|
221
|
+
const formatProgress = ({ receivedBytes, expectedBytes, percent }) => {
|
|
222
|
+
append(`TABBIT_DOWNLOAD_PROGRESS ${JSON.stringify({ receivedBytes, expectedBytes, percent })}`);
|
|
223
|
+
};
|
|
224
|
+
const done = downloadInstaller({ ...options, signal: controller.signal, onProgress: formatProgress })
|
|
225
|
+
.then((result) => {
|
|
226
|
+
append(`TABBIT_INSTALLER_READY ${JSON.stringify(result)}`);
|
|
227
|
+
return { status: 'completed', detail: `installer saved to ${result.path}` };
|
|
228
|
+
})
|
|
229
|
+
.catch((error) => {
|
|
230
|
+
if (controller.signal.aborted) {
|
|
231
|
+
append('Tabbit Browser installer download was cancelled.');
|
|
232
|
+
return { status: 'killed', detail: 'download cancelled' };
|
|
233
|
+
}
|
|
234
|
+
append(`Tabbit Browser installer download failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
235
|
+
return { status: 'failed', detail: 'download failed' };
|
|
236
|
+
})
|
|
237
|
+
.finally(() => options.onSettled?.());
|
|
238
|
+
return {
|
|
239
|
+
cancel: (reason) => controller.abort(new Error(reason || 'Download cancelled.')),
|
|
240
|
+
done,
|
|
241
|
+
readOutput() {
|
|
242
|
+
const output = pendingOutput;
|
|
243
|
+
pendingOutput = '';
|
|
244
|
+
return output;
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|