dsh-tabbit 0.2.3 → 0.3.2
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 +133 -0
- package/LICENSE +21 -0
- package/README.en.md +141 -0
- package/README.md +70 -76
- package/client/client.js +390 -0
- package/cordis.patch.yml +76 -5
- package/lib/core/index.js +756 -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 +454 -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 +476 -0
- package/lib/update-check.js +343 -0
- package/lib/web-fetch/index.js +219 -0
- package/package.json +55 -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
package/installer.js
DELETED
|
@@ -1,568 +0,0 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process'
|
|
2
|
-
import { constants } from 'node:fs'
|
|
3
|
-
import { access, mkdir, open, rename, rm } from 'node:fs/promises'
|
|
4
|
-
import { homedir } from 'node:os'
|
|
5
|
-
import { basename, join } from 'node:path'
|
|
6
|
-
|
|
7
|
-
const MAX_INSTALLER_BYTES = 1024 * 1024 * 1024
|
|
8
|
-
export const MINIMUM_TABBIT_VERSION = '1.9.0'
|
|
9
|
-
const ALLOWED_DOWNLOAD_HOSTS = new Set([
|
|
10
|
-
'www.tabbit.com',
|
|
11
|
-
'pkg.tabbit.com',
|
|
12
|
-
'releases.tabbit.com',
|
|
13
|
-
'www.tabbit.ai',
|
|
14
|
-
'pkg.tabbit.ai',
|
|
15
|
-
'releases.tabbit.ai',
|
|
16
|
-
])
|
|
17
|
-
|
|
18
|
-
const INSTALLER_ORIGINS = {
|
|
19
|
-
domestic: 'https://www.tabbit.com',
|
|
20
|
-
international: 'https://www.tabbit.ai',
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const DOWNLOADS = {
|
|
24
|
-
'win32:x64': {
|
|
25
|
-
platform: 'windows',
|
|
26
|
-
arch: 'x86_64',
|
|
27
|
-
extension: '.exe',
|
|
28
|
-
fallbackName: 'Tabbit Browser Installer.exe',
|
|
29
|
-
},
|
|
30
|
-
'darwin:arm64': {
|
|
31
|
-
platform: 'mac',
|
|
32
|
-
arch: 'ARM_64',
|
|
33
|
-
extension: '.dmg',
|
|
34
|
-
fallbackName: 'Tabbit Browser Installer ARM64.dmg',
|
|
35
|
-
},
|
|
36
|
-
'darwin:x64': {
|
|
37
|
-
platform: 'mac',
|
|
38
|
-
arch: 'x86_64',
|
|
39
|
-
extension: '.dmg',
|
|
40
|
-
fallbackName: 'Tabbit Browser Installer Intel.dmg',
|
|
41
|
-
},
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const MAC_APPLICATIONS = [
|
|
45
|
-
{
|
|
46
|
-
name: 'Tabbit',
|
|
47
|
-
bundleId: 'com.tabbit-ai.Tabbit',
|
|
48
|
-
edition: 'international',
|
|
49
|
-
channel: 'stable',
|
|
50
|
-
},
|
|
51
|
-
{
|
|
52
|
-
name: 'Tabbit Browser',
|
|
53
|
-
bundleId: 'com.tab-browser.Tabbit',
|
|
54
|
-
edition: 'domestic',
|
|
55
|
-
channel: 'stable',
|
|
56
|
-
},
|
|
57
|
-
]
|
|
58
|
-
|
|
59
|
-
const WINDOWS_DISPLAY_NAMES = new Map([
|
|
60
|
-
['Tabbit', { edition: 'international', channel: 'stable' }],
|
|
61
|
-
['Tabbit Browser', { edition: 'domestic', channel: 'stable' }],
|
|
62
|
-
])
|
|
63
|
-
|
|
64
|
-
export function normalizeRegionCode(value) {
|
|
65
|
-
const locale = String(value ?? '')
|
|
66
|
-
.trim()
|
|
67
|
-
.replace(/^['"]|['"]$/g, '')
|
|
68
|
-
.split('@', 1)[0]
|
|
69
|
-
if (/^[a-z]{2}$/i.test(locale)) return locale.toUpperCase()
|
|
70
|
-
return locale.match(/(?:_|-)([a-z]{2})$/i)?.[1].toUpperCase()
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
export function detectSystemRegion({
|
|
74
|
-
platform = process.platform,
|
|
75
|
-
run = spawnSync,
|
|
76
|
-
} = {}) {
|
|
77
|
-
if (platform === 'darwin') {
|
|
78
|
-
const result = run('/usr/bin/defaults', ['read', '-g', 'AppleLocale'], {
|
|
79
|
-
encoding: 'utf8',
|
|
80
|
-
})
|
|
81
|
-
return result.status === 0 ? normalizeRegionCode(result.stdout) : undefined
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (platform === 'win32') {
|
|
85
|
-
const script = '([System.Globalization.RegionInfo]::new((Get-WinHomeLocation).GeoId)).TwoLetterISORegionName'
|
|
86
|
-
const result = run(
|
|
87
|
-
'powershell.exe',
|
|
88
|
-
['-NoProfile', '-NonInteractive', '-Command', script],
|
|
89
|
-
{ encoding: 'utf8', windowsHide: true },
|
|
90
|
-
)
|
|
91
|
-
return result.status === 0 ? normalizeRegionCode(result.stdout) : undefined
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
return undefined
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
export function installerDistributionForRegion(regionCode) {
|
|
98
|
-
return normalizeRegionCode(regionCode) === 'CN' ? 'domestic' : 'international'
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
export function installerUrl(spec, distribution = 'international') {
|
|
102
|
-
const origin = INSTALLER_ORIGINS[distribution]
|
|
103
|
-
if (!origin) throw new Error(`Unknown Tabbit installer distribution: ${distribution}.`)
|
|
104
|
-
const query = new URLSearchParams({
|
|
105
|
-
platform: spec.platform,
|
|
106
|
-
arch: spec.arch,
|
|
107
|
-
tab_brand: 'dshr',
|
|
108
|
-
utm_source: 'dsh',
|
|
109
|
-
})
|
|
110
|
-
return `${origin}/api/v0/upgrade/installer?${query}`
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
export function detectPlatformSpec({
|
|
114
|
-
platform = process.platform,
|
|
115
|
-
arch = process.arch,
|
|
116
|
-
env = process.env,
|
|
117
|
-
run = spawnSync,
|
|
118
|
-
} = {}) {
|
|
119
|
-
let nativeArch = arch
|
|
120
|
-
|
|
121
|
-
if (platform === 'darwin' && arch === 'x64') {
|
|
122
|
-
const result = run('/usr/sbin/sysctl', ['-n', 'hw.optional.arm64'], {
|
|
123
|
-
encoding: 'utf8',
|
|
124
|
-
})
|
|
125
|
-
if (result.status === 0 && result.stdout.trim() === '1') nativeArch = 'arm64'
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (platform === 'win32') {
|
|
129
|
-
const reported = String(
|
|
130
|
-
env.PROCESSOR_ARCHITEW6432 ?? env.PROCESSOR_ARCHITECTURE ?? arch,
|
|
131
|
-
).toLowerCase()
|
|
132
|
-
nativeArch = reported === 'amd64' || reported === 'x86_64' ? 'x64' : arch
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
const spec = DOWNLOADS[`${platform}:${nativeArch}`]
|
|
136
|
-
if (!spec) {
|
|
137
|
-
throw new Error(`Tabbit Browser installer is unavailable for ${platform}/${nativeArch}.`)
|
|
138
|
-
}
|
|
139
|
-
return { ...spec }
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
async function exists(path, mode = constants.F_OK) {
|
|
143
|
-
try {
|
|
144
|
-
await access(path, mode)
|
|
145
|
-
return true
|
|
146
|
-
} catch {
|
|
147
|
-
return false
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
function readPlistValue(plistPath, key, run = spawnSync) {
|
|
152
|
-
const result = run(
|
|
153
|
-
'/usr/bin/plutil',
|
|
154
|
-
['-extract', key, 'raw', '-o', '-', plistPath],
|
|
155
|
-
{ encoding: 'utf8' },
|
|
156
|
-
)
|
|
157
|
-
return result.status === 0 ? result.stdout.trim() : undefined
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
export async function detectMacInstallations({
|
|
161
|
-
userHome = homedir(),
|
|
162
|
-
run = spawnSync,
|
|
163
|
-
} = {}) {
|
|
164
|
-
const roots = ['/Applications', join(userHome, 'Applications')]
|
|
165
|
-
const installations = []
|
|
166
|
-
const seenBundleIds = new Set()
|
|
167
|
-
|
|
168
|
-
for (const app of MAC_APPLICATIONS) {
|
|
169
|
-
for (const root of roots) {
|
|
170
|
-
const path = join(root, `${app.name}.app`)
|
|
171
|
-
const plistPath = join(path, 'Contents', 'Info.plist')
|
|
172
|
-
if (!(await exists(plistPath))) continue
|
|
173
|
-
const actualBundleId = readPlistValue(plistPath, 'CFBundleIdentifier', run)
|
|
174
|
-
if (actualBundleId !== app.bundleId || seenBundleIds.has(app.bundleId)) continue
|
|
175
|
-
const version = readPlistValue(plistPath, 'CFBundleShortVersionString', run)
|
|
176
|
-
installations.push({
|
|
177
|
-
...app,
|
|
178
|
-
path,
|
|
179
|
-
...(version ? { version } : {}),
|
|
180
|
-
})
|
|
181
|
-
seenBundleIds.add(app.bundleId)
|
|
182
|
-
}
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
return installations
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
export function parseWindowsUninstallRegistry(output) {
|
|
189
|
-
const installations = []
|
|
190
|
-
let record = undefined
|
|
191
|
-
|
|
192
|
-
const commit = () => {
|
|
193
|
-
if (!record) return
|
|
194
|
-
const identity = WINDOWS_DISPLAY_NAMES.get(record.DisplayName)
|
|
195
|
-
if (!identity) return
|
|
196
|
-
const icon = record.DisplayIcon
|
|
197
|
-
?.replace(/,\s*-?\d+$/, '')
|
|
198
|
-
.replace(/^"(.*)"$/, '$1')
|
|
199
|
-
installations.push({
|
|
200
|
-
name: record.DisplayName,
|
|
201
|
-
...identity,
|
|
202
|
-
...(record.InstallLocation || icon ? { path: record.InstallLocation || icon } : {}),
|
|
203
|
-
...(icon ? { executable: icon } : {}),
|
|
204
|
-
...(record.DisplayVersion ? { version: record.DisplayVersion } : {}),
|
|
205
|
-
registryKey: record.registryKey,
|
|
206
|
-
})
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
for (const line of String(output).split(/\r?\n/)) {
|
|
210
|
-
if (/^HKEY_/i.test(line.trim())) {
|
|
211
|
-
commit()
|
|
212
|
-
record = { registryKey: line.trim() }
|
|
213
|
-
continue
|
|
214
|
-
}
|
|
215
|
-
if (!record) continue
|
|
216
|
-
const match = line.match(/^\s+(DisplayName|DisplayVersion|InstallLocation|DisplayIcon)\s+REG_\w+\s+(.*)$/i)
|
|
217
|
-
if (match) record[match[1]] = match[2].trim()
|
|
218
|
-
}
|
|
219
|
-
commit()
|
|
220
|
-
return installations
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
export function detectWindowsInstallations({ run = spawnSync } = {}) {
|
|
224
|
-
const roots = [
|
|
225
|
-
'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
|
226
|
-
'HKLM\\Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
|
227
|
-
]
|
|
228
|
-
const targetedKeys = roots.flatMap(root => (
|
|
229
|
-
['Tabbit', 'Tabbit Browser'].map(name => `${root}\\${name}`)
|
|
230
|
-
))
|
|
231
|
-
const installations = []
|
|
232
|
-
const seen = new Set()
|
|
233
|
-
|
|
234
|
-
const collect = (output) => {
|
|
235
|
-
for (const item of parseWindowsUninstallRegistry(output)) {
|
|
236
|
-
const key = `${item.name}\0${item.path ?? ''}`
|
|
237
|
-
if (seen.has(key)) continue
|
|
238
|
-
seen.add(key)
|
|
239
|
-
installations.push(item)
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
for (const key of targetedKeys) {
|
|
244
|
-
for (const view of ['64', '32']) {
|
|
245
|
-
const result = run('reg.exe', ['query', key, `/reg:${view}`], {
|
|
246
|
-
encoding: 'utf8',
|
|
247
|
-
windowsHide: true,
|
|
248
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
249
|
-
})
|
|
250
|
-
if (result.status === 0) collect(result.stdout)
|
|
251
|
-
}
|
|
252
|
-
}
|
|
253
|
-
if (installations.length > 0) return installations
|
|
254
|
-
|
|
255
|
-
// Installer technologies may use generated uninstall subkeys. Preserve the
|
|
256
|
-
// broad scan as a compatibility fallback when the stable well-known keys are
|
|
257
|
-
// absent.
|
|
258
|
-
for (const root of roots) {
|
|
259
|
-
for (const view of ['64', '32']) {
|
|
260
|
-
const result = run('reg.exe', ['query', root, '/s', `/reg:${view}`], {
|
|
261
|
-
encoding: 'utf8',
|
|
262
|
-
windowsHide: true,
|
|
263
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
264
|
-
})
|
|
265
|
-
if (result.status === 0) collect(result.stdout)
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
return installations
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
async function detectCli({
|
|
272
|
-
userHome = homedir(),
|
|
273
|
-
platform = process.platform,
|
|
274
|
-
env = process.env,
|
|
275
|
-
} = {}) {
|
|
276
|
-
const path = platform === 'win32'
|
|
277
|
-
? join(env.LOCALAPPDATA || join(userHome, 'AppData', 'Local'), 'Tabbit', 'LocalAgent', 'bin', 'tabbit-cli.exe')
|
|
278
|
-
: join(userHome, '.local', 'bin', 'tabbit-cli')
|
|
279
|
-
return await exists(path, platform === 'win32' ? constants.F_OK : constants.X_OK)
|
|
280
|
-
? { ready: true, path }
|
|
281
|
-
: { ready: false }
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function numericVersion(version) {
|
|
285
|
-
const match = String(version ?? '').trim().match(/^v?(\d+(?:\.\d+)*)/i)
|
|
286
|
-
return match ? match[1].split('.').map(Number) : undefined
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
export function isVersionAtLeast(version, minimum = MINIMUM_TABBIT_VERSION) {
|
|
290
|
-
const actual = numericVersion(version)
|
|
291
|
-
const required = numericVersion(minimum)
|
|
292
|
-
if (!actual || !required) return false
|
|
293
|
-
const length = Math.max(actual.length, required.length)
|
|
294
|
-
for (let index = 0; index < length; index += 1) {
|
|
295
|
-
const left = actual[index] ?? 0
|
|
296
|
-
const right = required[index] ?? 0
|
|
297
|
-
if (left !== right) return left > right
|
|
298
|
-
}
|
|
299
|
-
return true
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
function isTabbitRuntimeProcess(name, command) {
|
|
303
|
-
const value = `${name ?? ''} ${command ?? ''}`
|
|
304
|
-
return /(?:^|[\\/"'\s])browser-runtime-service\.mjs(?=$|["'\s])/i.test(value)
|
|
305
|
-
|| /(?:^|[\\/"'\s])nodejs-playwright-runtime\.mjs(?=$|["'\s])/i.test(value)
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
export function parseUnixProcessList(output) {
|
|
309
|
-
const processes = []
|
|
310
|
-
for (const line of String(output).split(/\r?\n/)) {
|
|
311
|
-
const match = line.match(/^\s*(\d+)\s+(\S+)\s+(.*)$/)
|
|
312
|
-
if (!match || !isTabbitRuntimeProcess(match[2], match[3])) continue
|
|
313
|
-
processes.push({ pid: Number(match[1]), name: match[2] })
|
|
314
|
-
}
|
|
315
|
-
return processes
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
export function parseWindowsProcessList(output) {
|
|
319
|
-
if (!String(output).trim()) return []
|
|
320
|
-
let records
|
|
321
|
-
try {
|
|
322
|
-
records = JSON.parse(output)
|
|
323
|
-
} catch {
|
|
324
|
-
return []
|
|
325
|
-
}
|
|
326
|
-
if (!Array.isArray(records)) records = [records]
|
|
327
|
-
return records
|
|
328
|
-
.filter(record => isTabbitRuntimeProcess(record.Name, record.CommandLine))
|
|
329
|
-
.map(record => ({
|
|
330
|
-
pid: Number(record.ProcessId),
|
|
331
|
-
name: String(record.Name ?? ''),
|
|
332
|
-
}))
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
export function detectTabbitPlaywrightProcesses({
|
|
336
|
-
platform = process.platform,
|
|
337
|
-
run = spawnSync,
|
|
338
|
-
} = {}) {
|
|
339
|
-
if (platform === 'win32') {
|
|
340
|
-
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`
|
|
341
|
-
const result = run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script], {
|
|
342
|
-
encoding: 'utf8',
|
|
343
|
-
windowsHide: true,
|
|
344
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
345
|
-
})
|
|
346
|
-
return result.status === 0 ? parseWindowsProcessList(result.stdout) : []
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
const result = run('ps', ['-axo', 'pid=,comm=,args='], {
|
|
350
|
-
encoding: 'utf8',
|
|
351
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
352
|
-
})
|
|
353
|
-
return result.status === 0 ? parseUnixProcessList(result.stdout) : []
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
export function summarizeTabbitRuntime(playwrightProcesses) {
|
|
357
|
-
const instanceCount = Array.isArray(playwrightProcesses) ? playwrightProcesses.length : 0
|
|
358
|
-
return {
|
|
359
|
-
instanceCount,
|
|
360
|
-
running: instanceCount > 0,
|
|
361
|
-
ambiguous: instanceCount > 1,
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
export async function detectTabbit({
|
|
366
|
-
platform = process.platform,
|
|
367
|
-
userHome = homedir(),
|
|
368
|
-
env = process.env,
|
|
369
|
-
run = spawnSync,
|
|
370
|
-
minimumVersion = MINIMUM_TABBIT_VERSION,
|
|
371
|
-
} = {}) {
|
|
372
|
-
const installations = platform === 'darwin'
|
|
373
|
-
? await detectMacInstallations({ userHome, run })
|
|
374
|
-
: platform === 'win32'
|
|
375
|
-
? detectWindowsInstallations({ run })
|
|
376
|
-
: []
|
|
377
|
-
const cli = await detectCli({ userHome, platform, env })
|
|
378
|
-
const supportedInstallations = installations.filter(item => (
|
|
379
|
-
isVersionAtLeast(item.version, minimumVersion)
|
|
380
|
-
))
|
|
381
|
-
const playwrightProcesses = supportedInstallations.length > 0
|
|
382
|
-
? detectTabbitPlaywrightProcesses({ platform, run })
|
|
383
|
-
: []
|
|
384
|
-
const runtime = summarizeTabbitRuntime(playwrightProcesses)
|
|
385
|
-
const recommendation = supportedInstallations.length === 0
|
|
386
|
-
? 'download'
|
|
387
|
-
: runtime.running && cli.ready
|
|
388
|
-
? 'ready'
|
|
389
|
-
: 'restart-required'
|
|
390
|
-
return {
|
|
391
|
-
platform,
|
|
392
|
-
minimumVersion,
|
|
393
|
-
cliReady: cli.ready,
|
|
394
|
-
cliPath: cli.path,
|
|
395
|
-
installations,
|
|
396
|
-
supportedInstallations,
|
|
397
|
-
playwrightProcessRunning: runtime.running,
|
|
398
|
-
playwrightInstanceCount: runtime.instanceCount,
|
|
399
|
-
playwrightRuntimeAmbiguous: runtime.ambiguous,
|
|
400
|
-
playwrightProcesses,
|
|
401
|
-
recommendation,
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
function filenameFromResponse(response, spec) {
|
|
406
|
-
const disposition = response.headers.get('content-disposition') ?? ''
|
|
407
|
-
const encoded = disposition.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
|
|
408
|
-
const plain = disposition.match(/filename="?([^";]+)"?/i)?.[1]
|
|
409
|
-
let candidate
|
|
410
|
-
try {
|
|
411
|
-
candidate = encoded ? decodeURIComponent(encoded) : plain
|
|
412
|
-
} catch {
|
|
413
|
-
candidate = plain
|
|
414
|
-
}
|
|
415
|
-
if (!candidate) {
|
|
416
|
-
try {
|
|
417
|
-
candidate = decodeURIComponent(basename(new URL(response.url).pathname))
|
|
418
|
-
} catch {
|
|
419
|
-
candidate = undefined
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
const safe = basename(candidate || spec.fallbackName)
|
|
423
|
-
.replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_')
|
|
424
|
-
.trim()
|
|
425
|
-
return safe.toLowerCase().endsWith(spec.extension) ? safe : spec.fallbackName
|
|
426
|
-
}
|
|
427
|
-
|
|
428
|
-
async function uniqueDestination(directory, filename) {
|
|
429
|
-
const extensionIndex = filename.toLowerCase().lastIndexOf('.')
|
|
430
|
-
const stem = extensionIndex > 0 ? filename.slice(0, extensionIndex) : filename
|
|
431
|
-
const extension = extensionIndex > 0 ? filename.slice(extensionIndex) : ''
|
|
432
|
-
for (let index = 0; index < 1000; index += 1) {
|
|
433
|
-
const candidate = join(directory, index === 0 ? filename : `${stem} (${index})${extension}`)
|
|
434
|
-
if (!(await exists(candidate))) return candidate
|
|
435
|
-
}
|
|
436
|
-
throw new Error('Could not allocate a unique installer filename.')
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
async function verifyInstaller(path, spec, bytes) {
|
|
440
|
-
const handle = await open(path, 'r')
|
|
441
|
-
try {
|
|
442
|
-
if (spec.extension === '.exe') {
|
|
443
|
-
const header = Buffer.alloc(2)
|
|
444
|
-
await handle.read(header, 0, 2, 0)
|
|
445
|
-
if (header.toString('ascii') !== 'MZ') throw new Error('Downloaded file is not a Windows executable.')
|
|
446
|
-
return
|
|
447
|
-
}
|
|
448
|
-
const trailer = Buffer.alloc(Math.min(512, bytes))
|
|
449
|
-
await handle.read(trailer, 0, trailer.length, Math.max(0, bytes - trailer.length))
|
|
450
|
-
if (trailer.subarray(0, 4).toString('ascii') !== 'koly') {
|
|
451
|
-
throw new Error('Downloaded file is not a valid DMG image.')
|
|
452
|
-
}
|
|
453
|
-
} finally {
|
|
454
|
-
await handle.close()
|
|
455
|
-
}
|
|
456
|
-
}
|
|
457
|
-
|
|
458
|
-
export async function downloadInstaller({
|
|
459
|
-
signal,
|
|
460
|
-
onProgress = () => {},
|
|
461
|
-
outputDirectory = join(homedir(), 'Downloads'),
|
|
462
|
-
fetchImpl = fetch,
|
|
463
|
-
platformOptions,
|
|
464
|
-
} = {}) {
|
|
465
|
-
const spec = detectPlatformSpec(platformOptions)
|
|
466
|
-
const regionCode = detectSystemRegion(platformOptions)
|
|
467
|
-
const distribution = installerDistributionForRegion(regionCode)
|
|
468
|
-
const sourceUrl = installerUrl(spec, distribution)
|
|
469
|
-
await mkdir(outputDirectory, { recursive: true })
|
|
470
|
-
|
|
471
|
-
const response = await fetchImpl(sourceUrl, { redirect: 'follow', signal })
|
|
472
|
-
if (!response.ok || !response.body) throw new Error(`Installer download failed with HTTP ${response.status}.`)
|
|
473
|
-
const finalUrl = new URL(response.url || sourceUrl)
|
|
474
|
-
if (finalUrl.protocol !== 'https:' || !ALLOWED_DOWNLOAD_HOSTS.has(finalUrl.hostname)) {
|
|
475
|
-
throw new Error(`Installer redirected to an untrusted host: ${finalUrl.hostname || finalUrl.href}`)
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
const expectedBytes = Number(response.headers.get('content-length')) || undefined
|
|
479
|
-
if (expectedBytes && expectedBytes > MAX_INSTALLER_BYTES) throw new Error('Installer exceeds the 1 GiB safety limit.')
|
|
480
|
-
const filename = filenameFromResponse(response, spec)
|
|
481
|
-
const destination = await uniqueDestination(outputDirectory, filename)
|
|
482
|
-
const partial = `${destination}.${process.pid}.${Date.now()}.part`
|
|
483
|
-
const handle = await open(partial, 'wx', 0o600)
|
|
484
|
-
let receivedBytes = 0
|
|
485
|
-
let lastPercent = -1
|
|
486
|
-
let lastReportAt = 0
|
|
487
|
-
|
|
488
|
-
try {
|
|
489
|
-
for await (const chunk of response.body) {
|
|
490
|
-
if (signal?.aborted) throw signal.reason ?? new Error('Download cancelled.')
|
|
491
|
-
receivedBytes += chunk.byteLength
|
|
492
|
-
if (receivedBytes > MAX_INSTALLER_BYTES) throw new Error('Installer exceeds the 1 GiB safety limit.')
|
|
493
|
-
await handle.write(chunk)
|
|
494
|
-
|
|
495
|
-
const now = Date.now()
|
|
496
|
-
const percent = expectedBytes ? Math.floor((receivedBytes / expectedBytes) * 100) : undefined
|
|
497
|
-
if ((percent !== undefined && percent > lastPercent) || now - lastReportAt >= 1000) {
|
|
498
|
-
lastPercent = percent ?? lastPercent
|
|
499
|
-
lastReportAt = now
|
|
500
|
-
onProgress({ receivedBytes, expectedBytes, percent })
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
await handle.sync()
|
|
504
|
-
} catch (error) {
|
|
505
|
-
await handle.close().catch(() => {})
|
|
506
|
-
await rm(partial, { force: true })
|
|
507
|
-
throw error
|
|
508
|
-
}
|
|
509
|
-
await handle.close()
|
|
510
|
-
|
|
511
|
-
try {
|
|
512
|
-
if (expectedBytes !== undefined && receivedBytes !== expectedBytes) {
|
|
513
|
-
throw new Error(`Installer download was incomplete: received ${receivedBytes} of ${expectedBytes} bytes.`)
|
|
514
|
-
}
|
|
515
|
-
await verifyInstaller(partial, spec, receivedBytes)
|
|
516
|
-
await rename(partial, destination)
|
|
517
|
-
} catch (error) {
|
|
518
|
-
await rm(partial, { force: true })
|
|
519
|
-
throw error
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
return {
|
|
523
|
-
path: destination,
|
|
524
|
-
bytes: receivedBytes,
|
|
525
|
-
platform: spec.platform,
|
|
526
|
-
arch: spec.arch,
|
|
527
|
-
region: regionCode ?? 'unknown',
|
|
528
|
-
distribution,
|
|
529
|
-
sourceUrl,
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
export function createDownloadJob(options = {}) {
|
|
534
|
-
const controller = new AbortController()
|
|
535
|
-
let pendingOutput = ''
|
|
536
|
-
const append = (line) => {
|
|
537
|
-
pendingOutput += `${line}\n`
|
|
538
|
-
}
|
|
539
|
-
const formatProgress = ({ receivedBytes, expectedBytes, percent }) => {
|
|
540
|
-
append(`TABBIT_DOWNLOAD_PROGRESS ${JSON.stringify({ receivedBytes, expectedBytes, percent })}`)
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
const done = downloadInstaller({
|
|
544
|
-
...options,
|
|
545
|
-
signal: controller.signal,
|
|
546
|
-
onProgress: formatProgress,
|
|
547
|
-
}).then(result => {
|
|
548
|
-
append(`TABBIT_INSTALLER_READY ${JSON.stringify(result)}`)
|
|
549
|
-
return { status: 'completed', detail: `installer saved to ${result.path}` }
|
|
550
|
-
}).catch(error => {
|
|
551
|
-
if (controller.signal.aborted) {
|
|
552
|
-
append('Tabbit Browser installer download was cancelled.')
|
|
553
|
-
return { status: 'killed', detail: 'download cancelled' }
|
|
554
|
-
}
|
|
555
|
-
append(`Tabbit Browser installer download failed: ${error instanceof Error ? error.message : String(error)}`)
|
|
556
|
-
return { status: 'failed', detail: 'download failed' }
|
|
557
|
-
}).finally(() => options.onSettled?.())
|
|
558
|
-
|
|
559
|
-
return {
|
|
560
|
-
cancel: reason => controller.abort(new Error(reason || 'Download cancelled.')),
|
|
561
|
-
done,
|
|
562
|
-
readOutput() {
|
|
563
|
-
const output = pendingOutput
|
|
564
|
-
pendingOutput = ''
|
|
565
|
-
return output
|
|
566
|
-
},
|
|
567
|
-
}
|
|
568
|
-
}
|