dsh-clean-desktop-shell 0.1.11 → 0.1.13
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/CONTRIBUTORS.md +25 -19
- package/README.en.md +422 -333
- package/README.md +363 -278
- package/electron/aumid.js +17 -17
- package/electron/crashGuard.js +60 -60
- package/electron/error.html +113 -113
- package/electron/main.js +203 -187
- package/electron/outage.js +60 -0
- package/electron/preload.js +216 -49
- package/electron/progress-preload.js +11 -11
- package/electron/progress.html +79 -79
- package/electron/progress.js +56 -56
- package/electron/service.js +559 -458
- package/electron/shortcut.js +122 -122
- package/electron/tray.js +232 -232
- package/electron/window.js +603 -264
- package/lib/client.js +41 -6
- package/lib/common.js +74 -74
- package/lib/icon.js +85 -51
- package/lib/index.js +90 -15
- package/lib/runtime.js +423 -423
- package/package.json +140 -127
- package/scripts/check-syntax.mjs +54 -54
- package/scripts/selftest-recovery.mjs +140 -0
- package/scripts/selftest-runtime.mjs +90 -90
- package/src/client/client.js +41 -6
- package/src/host/common.js +74 -74
- package/src/host/icon.js +85 -51
- package/src/host/index.js +90 -15
- package/src/host/runtime.js +423 -423
- package/version.txt +1 -1
package/src/host/runtime.js
CHANGED
|
@@ -1,423 +1,423 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Electron runtime provisioning for the plugin-market (branch 2) shell.
|
|
3
|
-
*
|
|
4
|
-
* The electron runtime is NOT an npm dependency (electron-builder forbids
|
|
5
|
-
* electron in "dependencies", and pnpm's allowBuilds would block its
|
|
6
|
-
* postinstall anyway). This module provisions it under
|
|
7
|
-
* $DSH_HOME/desktop-shell-runtime/electron-v<ver>/:
|
|
8
|
-
*
|
|
9
|
-
* 1. resolve the version (package.json → desktopShell.electronVersion)
|
|
10
|
-
* 2. if that version dir exists → reuse it
|
|
11
|
-
* 3. otherwise reuse a local electron package (DSH_SHELL_ELECTRON_DIR),
|
|
12
|
-
* else download the official zip from the fastest reachable source
|
|
13
|
-
* 4. drop stale electron-v* dirs (no unbounded disk growth)
|
|
14
|
-
*/
|
|
15
|
-
import { spawn } from 'node:child_process'
|
|
16
|
-
import { createHash } from 'node:crypto'
|
|
17
|
-
import { createReadStream } from 'node:fs'
|
|
18
|
-
import {
|
|
19
|
-
cpSync,
|
|
20
|
-
existsSync,
|
|
21
|
-
mkdirSync,
|
|
22
|
-
readFileSync,
|
|
23
|
-
readdirSync,
|
|
24
|
-
renameSync,
|
|
25
|
-
rmSync,
|
|
26
|
-
symlinkSync,
|
|
27
|
-
} from 'node:fs'
|
|
28
|
-
import { pipeline } from 'node:stream/promises'
|
|
29
|
-
import { basename, join } from 'node:path'
|
|
30
|
-
import {
|
|
31
|
-
PKG_ROOT,
|
|
32
|
-
EXE_RELPATH,
|
|
33
|
-
EXE_TOP,
|
|
34
|
-
ARCH,
|
|
35
|
-
PLATFORM,
|
|
36
|
-
isWin,
|
|
37
|
-
isMac,
|
|
38
|
-
runtimeRoot,
|
|
39
|
-
fetchFile,
|
|
40
|
-
} from './common.js'
|
|
41
|
-
|
|
42
|
-
function electronVersion() {
|
|
43
|
-
try {
|
|
44
|
-
const meta = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'))
|
|
45
|
-
return meta.desktopShell?.electronVersion || null
|
|
46
|
-
} catch {
|
|
47
|
-
return null
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function versionDir(root, version) {
|
|
52
|
-
return join(root, `electron-v${version}`)
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function zipName(version) {
|
|
56
|
-
return `electron-v${version}-${PLATFORM}-${ARCH}.zip`
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
/** Resolve (or provision) the runtime and return the electron exe path. */
|
|
60
|
-
export async function ensureRuntime(ctx) {
|
|
61
|
-
const version = electronVersion()
|
|
62
|
-
if (!version) throw new Error('desktopShell.electronVersion missing in package.json')
|
|
63
|
-
const root = runtimeRoot()
|
|
64
|
-
const dir = versionDir(root, version)
|
|
65
|
-
const exe = join(dir, EXE_RELPATH)
|
|
66
|
-
|
|
67
|
-
// 1) Already provisioned for this version?
|
|
68
|
-
if (existsSync(exe)) {
|
|
69
|
-
cleanupOldVersions(root, dir)
|
|
70
|
-
if (!isWin) await ensureExecutable(ctx, exe)
|
|
71
|
-
return exe
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
// 2) Local reuse: DSH_SHELL_ELECTRON_DIR → link/copy its dist/ (fast).
|
|
75
|
-
const localSrc = process.env.DSH_SHELL_ELECTRON_DIR
|
|
76
|
-
if (localSrc) {
|
|
77
|
-
const srcExe = join(localSrc, 'dist', EXE_RELPATH)
|
|
78
|
-
if (existsSync(srcExe) && provisionLocalDist(localSrc, dir)) {
|
|
79
|
-
ctx.logger.info(`[clean-desktop-shell] reused electron runtime from ${localSrc}`)
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
// 3) Download + extract the official zip from a network-appropriate source.
|
|
84
|
-
if (!existsSync(exe)) {
|
|
85
|
-
mkdirSync(root, { recursive: true })
|
|
86
|
-
await downloadRuntime(ctx, version, root, dir)
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
if (!existsSync(exe)) {
|
|
90
|
-
// Name the exact path we expected. Without it a user on an untested
|
|
91
|
-
// platform has nothing to report back (this failure used to be silent).
|
|
92
|
-
throw new Error(
|
|
93
|
-
`electron runtime provisioning failed — expected binary at ${exe} ` +
|
|
94
|
-
`(platform=${PLATFORM} arch=${ARCH}, layout differs per platform); ` +
|
|
95
|
-
'check network, or point DSH_SHELL_ELECTRON_DIR at an electron package',
|
|
96
|
-
)
|
|
97
|
-
}
|
|
98
|
-
cleanupOldVersions(root, dir)
|
|
99
|
-
if (!isWin) await ensureExecutable(ctx, exe)
|
|
100
|
-
ctx.logger.info(`[clean-desktop-shell] electron runtime ${version} ready at ${dir}`)
|
|
101
|
-
return exe
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Zip extraction does not reliably restore the executable bit — bsdtar
|
|
106
|
-
* (Windows tar.exe, macOS /usr/bin/tar) in particular drops it — and a
|
|
107
|
-
* non-executable binary fails later with a bare EACCES on spawn. Guarantee
|
|
108
|
-
* it instead of trusting the extractor.
|
|
109
|
-
*/
|
|
110
|
-
function ensureExecutable(ctx, target) {
|
|
111
|
-
return new Promise((resolve) => {
|
|
112
|
-
let child
|
|
113
|
-
try {
|
|
114
|
-
child = spawn('chmod', ['+x', target], { stdio: 'ignore' })
|
|
115
|
-
} catch {
|
|
116
|
-
return resolve(false)
|
|
117
|
-
}
|
|
118
|
-
child.on('error', () => resolve(false))
|
|
119
|
-
child.on('exit', (code) => {
|
|
120
|
-
if (code !== 0) {
|
|
121
|
-
ctx.logger.warn(`[clean-desktop-shell] chmod +x failed (exit ${code}) on ${target}`)
|
|
122
|
-
}
|
|
123
|
-
resolve(code === 0)
|
|
124
|
-
})
|
|
125
|
-
})
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
async function downloadRuntime(ctx, version, root, dir) {
|
|
129
|
-
const tmpZip = join(root, `.electron-${version}.zip.tmp`)
|
|
130
|
-
rmSync(tmpZip, { force: true })
|
|
131
|
-
const candidates = await runtimeCandidates(version)
|
|
132
|
-
const zip = zipName(version)
|
|
133
|
-
const innerName = zip.replace(/\.zip$/, '')
|
|
134
|
-
|
|
135
|
-
for (const c of candidates) {
|
|
136
|
-
ctx.logger.info(`[clean-desktop-shell] downloading electron ${version} from ${c.url}`)
|
|
137
|
-
if (!(await fetchFile(c.url, tmpZip))) {
|
|
138
|
-
// Failed download — drop the partial file so a later run starts clean.
|
|
139
|
-
rmSync(tmpZip, { force: true })
|
|
140
|
-
continue
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Integrity check against the source's own SHASUMS256.txt (same check
|
|
144
|
-
// the `electron` package's installer performs). A mismatch means the
|
|
145
|
-
// bytes are corrupt or tampered — the source is skipped entirely.
|
|
146
|
-
if (!(await verifySha256(ctx, tmpZip, c.shasums, zip))) {
|
|
147
|
-
rmSync(tmpZip, { force: true })
|
|
148
|
-
continue
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Extract into a scratch dir, never into the shared runtime root: a
|
|
152
|
-
// half-extracted zip there would be indistinguishable from a real
|
|
153
|
-
// runtime, and the sibling electron-v* dirs must not be disturbed.
|
|
154
|
-
const scratch = join(root, `.extract-${version}-${Date.now()}`)
|
|
155
|
-
const ok = await extractZip(ctx, tmpZip, scratch)
|
|
156
|
-
rmSync(tmpZip, { force: true })
|
|
157
|
-
if (!ok) {
|
|
158
|
-
ctx.logger.warn(`[clean-desktop-shell] no extractor succeeded for ${c.url}`)
|
|
159
|
-
rmSync(scratch, { recursive: true, force: true })
|
|
160
|
-
continue
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
// Both archive layouts exist in the wild: some Electron zips wrap
|
|
164
|
-
// everything in a dir named after the zip, others (notably darwin, whose
|
|
165
|
-
// payload is Electron.app/) unpack flat into the destination. Detect
|
|
166
|
-
// rather than assume — the old code assumed the wrapper and silently
|
|
167
|
-
// produced nothing on macOS.
|
|
168
|
-
const payload = findPayload(scratch, join(scratch, innerName))
|
|
169
|
-
if (!payload) {
|
|
170
|
-
ctx.logger.warn(
|
|
171
|
-
`[clean-desktop-shell] unexpected archive layout: no ${EXE_TOP} under ${scratch}`,
|
|
172
|
-
)
|
|
173
|
-
rmSync(scratch, { recursive: true, force: true })
|
|
174
|
-
continue
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
movePayloadInto(dir, payload)
|
|
178
|
-
rmSync(scratch, { recursive: true, force: true })
|
|
179
|
-
if (isMac) await clearQuarantine(ctx, join(dir, EXE_TOP))
|
|
180
|
-
return
|
|
181
|
-
}
|
|
182
|
-
throw new Error('electron download failed from all sources')
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
/**
|
|
186
|
-
* Verify a downloaded zip against the source's SHASUMS256.txt — the same
|
|
187
|
-
* integrity check the `electron` package's own installer performs. Returns
|
|
188
|
-
* false on a checksum mismatch (fatal for that source); a temporarily
|
|
189
|
-
* unreachable SHASUMS file only warns, so a reachable zip is not wasted
|
|
190
|
-
* over an unrelated blocker.
|
|
191
|
-
*/
|
|
192
|
-
async function verifySha256(ctx, file, shasumsUrl, name) {
|
|
193
|
-
let expected = null
|
|
194
|
-
let reason = ''
|
|
195
|
-
try {
|
|
196
|
-
const res = await fetch(shasumsUrl, { signal: AbortSignal.timeout(10000) })
|
|
197
|
-
if (!res.ok) {
|
|
198
|
-
reason = `HTTP ${res.status}`
|
|
199
|
-
} else {
|
|
200
|
-
const text = await res.text()
|
|
201
|
-
for (const line of text.split('\n')) {
|
|
202
|
-
const m = line.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/i)
|
|
203
|
-
if (m && m[2].trim() === name) {
|
|
204
|
-
expected = m[1].toLowerCase()
|
|
205
|
-
break
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
if (!expected) reason = 'entry not found'
|
|
209
|
-
}
|
|
210
|
-
} catch (err) {
|
|
211
|
-
reason = err?.message || 'fetch failed'
|
|
212
|
-
}
|
|
213
|
-
if (!expected) {
|
|
214
|
-
ctx.logger.warn(
|
|
215
|
-
`[clean-desktop-shell] SHASUMS256.txt unavailable from ${shasumsUrl} (${reason}) — skipping integrity check`,
|
|
216
|
-
)
|
|
217
|
-
return true
|
|
218
|
-
}
|
|
219
|
-
// Stream the hash — the zip can be ~270 MB and must not be read whole
|
|
220
|
-
// into memory.
|
|
221
|
-
const hash = createHash('sha256')
|
|
222
|
-
await pipeline(createReadStream(file), hash)
|
|
223
|
-
const actual = hash.digest('hex')
|
|
224
|
-
if (actual !== expected) {
|
|
225
|
-
ctx.logger.warn(
|
|
226
|
-
`[clean-desktop-shell] checksum mismatch for ${name}: expected ${expected}, got ${actual}`,
|
|
227
|
-
)
|
|
228
|
-
return false
|
|
229
|
-
}
|
|
230
|
-
return true
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
/** The dir that directly holds the electron payload right after extraction. */
|
|
234
|
-
function findPayload(scratch, wrapper) {
|
|
235
|
-
if (existsSync(join(wrapper, EXE_TOP))) return wrapper
|
|
236
|
-
if (existsSync(join(scratch, EXE_TOP))) return scratch
|
|
237
|
-
return null
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function movePayloadInto(dir, payload) {
|
|
241
|
-
rmSync(dir, { recursive: true, force: true })
|
|
242
|
-
renameSync(payload, dir)
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
/**
|
|
246
|
-
* macOS: a bundle carrying com.apple.quarantine is refused by Gatekeeper with
|
|
247
|
-
* the infamous "已损坏,无法打开" dialog. We download with curl (which does
|
|
248
|
-
* not set the attribute), but archive members themselves can carry it, so
|
|
249
|
-
* clear it once right after extraction rather than debugging it per user.
|
|
250
|
-
*/
|
|
251
|
-
function clearQuarantine(ctx, target) {
|
|
252
|
-
return new Promise((resolve) => {
|
|
253
|
-
let child
|
|
254
|
-
try {
|
|
255
|
-
child = spawn('xattr', ['-cr', target], { windowsHide: true, stdio: 'ignore' })
|
|
256
|
-
} catch {
|
|
257
|
-
return resolve(false)
|
|
258
|
-
}
|
|
259
|
-
child.on('error', () => resolve(false))
|
|
260
|
-
child.on('exit', (code) => {
|
|
261
|
-
ctx.logger.info(
|
|
262
|
-
code === 0
|
|
263
|
-
? `[clean-desktop-shell] cleared extended attributes on ${target}`
|
|
264
|
-
: `[clean-desktop-shell] xattr cleanup skipped (exit ${code})`,
|
|
265
|
-
)
|
|
266
|
-
resolve(code === 0)
|
|
267
|
-
})
|
|
268
|
-
})
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
/**
|
|
272
|
-
* Pick the download source by racing a HEAD probe against each candidate
|
|
273
|
-
* (direct connection, 3s each). The fastest reachable source goes first —
|
|
274
|
-
* this naturally prefers the domestic npmmirror mirror on CN networks,
|
|
275
|
-
* the official GitHub source on international/well-proxied networks, and
|
|
276
|
-
* never wastes a full download on a dead source. Each candidate carries
|
|
277
|
-
* its own SHASUMS256.txt location for the post-download integrity check.
|
|
278
|
-
*/
|
|
279
|
-
async function runtimeCandidates(version) {
|
|
280
|
-
const base = (name, urlBase) => ({
|
|
281
|
-
name,
|
|
282
|
-
url: `${urlBase}/${zipName(version)}`,
|
|
283
|
-
shasums: `${urlBase}/SHASUMS256.txt`,
|
|
284
|
-
})
|
|
285
|
-
const candidates = [
|
|
286
|
-
base('github', `https://github.com/electron/electron/releases/download/v${version}`),
|
|
287
|
-
base('npmmirror', `https://npmmirror.com/mirrors/electron/${version}`),
|
|
288
|
-
]
|
|
289
|
-
const results = await Promise.all(
|
|
290
|
-
candidates.map(async (c) => {
|
|
291
|
-
const t0 = Date.now()
|
|
292
|
-
try {
|
|
293
|
-
// AbortSignal.timeout — the standard self-cleaning probe timeout.
|
|
294
|
-
const res = await fetch(c.url, { signal: AbortSignal.timeout(3000), method: 'HEAD' })
|
|
295
|
-
if (res.status < 500) return { ...c, ms: Date.now() - t0 }
|
|
296
|
-
} catch {
|
|
297
|
-
// unreachable — drop
|
|
298
|
-
}
|
|
299
|
-
return null
|
|
300
|
-
}),
|
|
301
|
-
)
|
|
302
|
-
const ok = results.filter(Boolean).sort((a, b) => a.ms - b.ms)
|
|
303
|
-
if (ok.length === 0) {
|
|
304
|
-
// Probes all failed (offline?) — still try both, mirror first (cheap).
|
|
305
|
-
return [candidates[1], candidates[0]]
|
|
306
|
-
}
|
|
307
|
-
const rest = candidates.filter((c) => c.url !== ok[0].url)
|
|
308
|
-
return [ok[0], ...rest]
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/**
|
|
312
|
-
* Extract a zip using whatever the platform actually provides.
|
|
313
|
-
*
|
|
314
|
-
* The archives are ordinary zips, but the readers differ:
|
|
315
|
-
* - Windows: bsdtar ships as tar.exe and reads zip; Expand-Archive is the
|
|
316
|
-
* fallback for hardened images where tar is unavailable.
|
|
317
|
-
* - macOS: ditto -xk is Apple's own extractor and preserves the symlinks
|
|
318
|
-
* inside Electron.app; unzip next; bsdtar last.
|
|
319
|
-
* - Linux: unzip, then tar.
|
|
320
|
-
*
|
|
321
|
-
* Success is decided by the caller (findPayload), not by the exit code — a
|
|
322
|
-
* zip can unpack "successfully" into a layout nobody expects.
|
|
323
|
-
*/
|
|
324
|
-
function extractZip(ctx, zipPath, dest) {
|
|
325
|
-
const strategies = isWin
|
|
326
|
-
? [
|
|
327
|
-
{ cmd: 'tar', args: ['-xf', zipPath, '-C', dest] },
|
|
328
|
-
{
|
|
329
|
-
cmd: 'powershell',
|
|
330
|
-
args: [
|
|
331
|
-
'-NoProfile',
|
|
332
|
-
'-NonInteractive',
|
|
333
|
-
'-Command',
|
|
334
|
-
`Expand-Archive -LiteralPath '${zipPath.replace(/'/g, "''")}' -DestinationPath '${dest.replace(/'/g, "''")}' -Force`,
|
|
335
|
-
],
|
|
336
|
-
},
|
|
337
|
-
]
|
|
338
|
-
: isMac
|
|
339
|
-
? [
|
|
340
|
-
{ cmd: 'ditto', args: ['-xk', zipPath, dest] },
|
|
341
|
-
{ cmd: 'unzip', args: ['-q', '-o', zipPath, '-d', dest] },
|
|
342
|
-
{ cmd: 'tar', args: ['-xf', zipPath, '-C', dest] },
|
|
343
|
-
]
|
|
344
|
-
: [
|
|
345
|
-
{ cmd: 'unzip', args: ['-q', '-o', zipPath, '-d', dest] },
|
|
346
|
-
{ cmd: 'tar', args: ['-xf', zipPath, '-C', dest] },
|
|
347
|
-
]
|
|
348
|
-
|
|
349
|
-
return (async () => {
|
|
350
|
-
mkdirSync(dest, { recursive: true })
|
|
351
|
-
for (const s of strategies) {
|
|
352
|
-
const ok = await runExtractor(s.cmd, s.args)
|
|
353
|
-
if (ok) {
|
|
354
|
-
ctx.logger.info(`[clean-desktop-shell] extracted with ${s.cmd}`)
|
|
355
|
-
return true
|
|
356
|
-
}
|
|
357
|
-
ctx.logger.warn(`[clean-desktop-shell] extractor unavailable or failed: ${s.cmd}`)
|
|
358
|
-
}
|
|
359
|
-
return false
|
|
360
|
-
})()
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
function runExtractor(cmd, args) {
|
|
364
|
-
return new Promise((resolve) => {
|
|
365
|
-
let child
|
|
366
|
-
try {
|
|
367
|
-
child = spawn(cmd, args, { windowsHide: true, stdio: 'ignore' })
|
|
368
|
-
} catch {
|
|
369
|
-
return resolve(false)
|
|
370
|
-
}
|
|
371
|
-
child.on('error', () => resolve(false))
|
|
372
|
-
child.on('exit', (code) => resolve(code === 0))
|
|
373
|
-
})
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/**
|
|
377
|
-
* Drop version dirs older than the current one (dead weight), plus any
|
|
378
|
-
* scratch dir a crashed extraction left behind.
|
|
379
|
-
*/
|
|
380
|
-
function cleanupOldVersions(root, currentDir) {
|
|
381
|
-
try {
|
|
382
|
-
for (const entry of readdirSync(root)) {
|
|
383
|
-
const isStaleRuntime = entry.startsWith('electron-v')
|
|
384
|
-
const isScratch = entry.startsWith('.extract-') || entry.startsWith('.electron-')
|
|
385
|
-
if (!isStaleRuntime && !isScratch) continue
|
|
386
|
-
const full = join(root, entry)
|
|
387
|
-
if (full === currentDir) continue
|
|
388
|
-
rmSync(full, { recursive: true, force: true })
|
|
389
|
-
}
|
|
390
|
-
} catch {
|
|
391
|
-
// best-effort
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
/**
|
|
396
|
-
* Provision a local electron package's dist/ as the version dir itself, so
|
|
397
|
-
* the layout matches a downloaded runtime: <dir>/<EXE_RELPATH> at the
|
|
398
|
-
* version-dir root (on macOS that is <dir>/Electron.app/Contents/MacOS/Electron).
|
|
399
|
-
*
|
|
400
|
-
* Windows: junction (zero-copy, instant) — a 269MB recursive cpSync can be
|
|
401
|
-
* killed by sandbox/AV on large trees, so only fall back to a copy.
|
|
402
|
-
* macOS/Linux: a symlink to dist/ would work too, but a bare .app reached
|
|
403
|
-
* through a symlink is a common source of Gatekeeper/entitlement surprises,
|
|
404
|
-
* so always copy there.
|
|
405
|
-
*/
|
|
406
|
-
function provisionLocalDist(srcPkg, destDir) {
|
|
407
|
-
if (process.platform === 'win32') {
|
|
408
|
-
try {
|
|
409
|
-
rmSync(destDir, { recursive: true, force: true })
|
|
410
|
-
symlinkSync(join(srcPkg, 'dist'), destDir, 'junction')
|
|
411
|
-
return true
|
|
412
|
-
} catch {
|
|
413
|
-
// fall through to a real copy
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
try {
|
|
417
|
-
rmSync(destDir, { recursive: true, force: true })
|
|
418
|
-
cpSync(join(srcPkg, 'dist'), destDir, { recursive: true })
|
|
419
|
-
return true
|
|
420
|
-
} catch {
|
|
421
|
-
return false
|
|
422
|
-
}
|
|
423
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Electron runtime provisioning for the plugin-market (branch 2) shell.
|
|
3
|
+
*
|
|
4
|
+
* The electron runtime is NOT an npm dependency (electron-builder forbids
|
|
5
|
+
* electron in "dependencies", and pnpm's allowBuilds would block its
|
|
6
|
+
* postinstall anyway). This module provisions it under
|
|
7
|
+
* $DSH_HOME/desktop-shell-runtime/electron-v<ver>/:
|
|
8
|
+
*
|
|
9
|
+
* 1. resolve the version (package.json → desktopShell.electronVersion)
|
|
10
|
+
* 2. if that version dir exists → reuse it
|
|
11
|
+
* 3. otherwise reuse a local electron package (DSH_SHELL_ELECTRON_DIR),
|
|
12
|
+
* else download the official zip from the fastest reachable source
|
|
13
|
+
* 4. drop stale electron-v* dirs (no unbounded disk growth)
|
|
14
|
+
*/
|
|
15
|
+
import { spawn } from 'node:child_process'
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
17
|
+
import { createReadStream } from 'node:fs'
|
|
18
|
+
import {
|
|
19
|
+
cpSync,
|
|
20
|
+
existsSync,
|
|
21
|
+
mkdirSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
readdirSync,
|
|
24
|
+
renameSync,
|
|
25
|
+
rmSync,
|
|
26
|
+
symlinkSync,
|
|
27
|
+
} from 'node:fs'
|
|
28
|
+
import { pipeline } from 'node:stream/promises'
|
|
29
|
+
import { basename, join } from 'node:path'
|
|
30
|
+
import {
|
|
31
|
+
PKG_ROOT,
|
|
32
|
+
EXE_RELPATH,
|
|
33
|
+
EXE_TOP,
|
|
34
|
+
ARCH,
|
|
35
|
+
PLATFORM,
|
|
36
|
+
isWin,
|
|
37
|
+
isMac,
|
|
38
|
+
runtimeRoot,
|
|
39
|
+
fetchFile,
|
|
40
|
+
} from './common.js'
|
|
41
|
+
|
|
42
|
+
function electronVersion() {
|
|
43
|
+
try {
|
|
44
|
+
const meta = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'))
|
|
45
|
+
return meta.desktopShell?.electronVersion || null
|
|
46
|
+
} catch {
|
|
47
|
+
return null
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function versionDir(root, version) {
|
|
52
|
+
return join(root, `electron-v${version}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function zipName(version) {
|
|
56
|
+
return `electron-v${version}-${PLATFORM}-${ARCH}.zip`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Resolve (or provision) the runtime and return the electron exe path. */
|
|
60
|
+
export async function ensureRuntime(ctx) {
|
|
61
|
+
const version = electronVersion()
|
|
62
|
+
if (!version) throw new Error('desktopShell.electronVersion missing in package.json')
|
|
63
|
+
const root = runtimeRoot()
|
|
64
|
+
const dir = versionDir(root, version)
|
|
65
|
+
const exe = join(dir, EXE_RELPATH)
|
|
66
|
+
|
|
67
|
+
// 1) Already provisioned for this version?
|
|
68
|
+
if (existsSync(exe)) {
|
|
69
|
+
cleanupOldVersions(root, dir)
|
|
70
|
+
if (!isWin) await ensureExecutable(ctx, exe)
|
|
71
|
+
return exe
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 2) Local reuse: DSH_SHELL_ELECTRON_DIR → link/copy its dist/ (fast).
|
|
75
|
+
const localSrc = process.env.DSH_SHELL_ELECTRON_DIR
|
|
76
|
+
if (localSrc) {
|
|
77
|
+
const srcExe = join(localSrc, 'dist', EXE_RELPATH)
|
|
78
|
+
if (existsSync(srcExe) && provisionLocalDist(localSrc, dir)) {
|
|
79
|
+
ctx.logger.info(`[clean-desktop-shell] reused electron runtime from ${localSrc}`)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// 3) Download + extract the official zip from a network-appropriate source.
|
|
84
|
+
if (!existsSync(exe)) {
|
|
85
|
+
mkdirSync(root, { recursive: true })
|
|
86
|
+
await downloadRuntime(ctx, version, root, dir)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!existsSync(exe)) {
|
|
90
|
+
// Name the exact path we expected. Without it a user on an untested
|
|
91
|
+
// platform has nothing to report back (this failure used to be silent).
|
|
92
|
+
throw new Error(
|
|
93
|
+
`electron runtime provisioning failed — expected binary at ${exe} ` +
|
|
94
|
+
`(platform=${PLATFORM} arch=${ARCH}, layout differs per platform); ` +
|
|
95
|
+
'check network, or point DSH_SHELL_ELECTRON_DIR at an electron package',
|
|
96
|
+
)
|
|
97
|
+
}
|
|
98
|
+
cleanupOldVersions(root, dir)
|
|
99
|
+
if (!isWin) await ensureExecutable(ctx, exe)
|
|
100
|
+
ctx.logger.info(`[clean-desktop-shell] electron runtime ${version} ready at ${dir}`)
|
|
101
|
+
return exe
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Zip extraction does not reliably restore the executable bit — bsdtar
|
|
106
|
+
* (Windows tar.exe, macOS /usr/bin/tar) in particular drops it — and a
|
|
107
|
+
* non-executable binary fails later with a bare EACCES on spawn. Guarantee
|
|
108
|
+
* it instead of trusting the extractor.
|
|
109
|
+
*/
|
|
110
|
+
function ensureExecutable(ctx, target) {
|
|
111
|
+
return new Promise((resolve) => {
|
|
112
|
+
let child
|
|
113
|
+
try {
|
|
114
|
+
child = spawn('chmod', ['+x', target], { stdio: 'ignore' })
|
|
115
|
+
} catch {
|
|
116
|
+
return resolve(false)
|
|
117
|
+
}
|
|
118
|
+
child.on('error', () => resolve(false))
|
|
119
|
+
child.on('exit', (code) => {
|
|
120
|
+
if (code !== 0) {
|
|
121
|
+
ctx.logger.warn(`[clean-desktop-shell] chmod +x failed (exit ${code}) on ${target}`)
|
|
122
|
+
}
|
|
123
|
+
resolve(code === 0)
|
|
124
|
+
})
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function downloadRuntime(ctx, version, root, dir) {
|
|
129
|
+
const tmpZip = join(root, `.electron-${version}.zip.tmp`)
|
|
130
|
+
rmSync(tmpZip, { force: true })
|
|
131
|
+
const candidates = await runtimeCandidates(version)
|
|
132
|
+
const zip = zipName(version)
|
|
133
|
+
const innerName = zip.replace(/\.zip$/, '')
|
|
134
|
+
|
|
135
|
+
for (const c of candidates) {
|
|
136
|
+
ctx.logger.info(`[clean-desktop-shell] downloading electron ${version} from ${c.url}`)
|
|
137
|
+
if (!(await fetchFile(c.url, tmpZip))) {
|
|
138
|
+
// Failed download — drop the partial file so a later run starts clean.
|
|
139
|
+
rmSync(tmpZip, { force: true })
|
|
140
|
+
continue
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Integrity check against the source's own SHASUMS256.txt (same check
|
|
144
|
+
// the `electron` package's installer performs). A mismatch means the
|
|
145
|
+
// bytes are corrupt or tampered — the source is skipped entirely.
|
|
146
|
+
if (!(await verifySha256(ctx, tmpZip, c.shasums, zip))) {
|
|
147
|
+
rmSync(tmpZip, { force: true })
|
|
148
|
+
continue
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Extract into a scratch dir, never into the shared runtime root: a
|
|
152
|
+
// half-extracted zip there would be indistinguishable from a real
|
|
153
|
+
// runtime, and the sibling electron-v* dirs must not be disturbed.
|
|
154
|
+
const scratch = join(root, `.extract-${version}-${Date.now()}`)
|
|
155
|
+
const ok = await extractZip(ctx, tmpZip, scratch)
|
|
156
|
+
rmSync(tmpZip, { force: true })
|
|
157
|
+
if (!ok) {
|
|
158
|
+
ctx.logger.warn(`[clean-desktop-shell] no extractor succeeded for ${c.url}`)
|
|
159
|
+
rmSync(scratch, { recursive: true, force: true })
|
|
160
|
+
continue
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Both archive layouts exist in the wild: some Electron zips wrap
|
|
164
|
+
// everything in a dir named after the zip, others (notably darwin, whose
|
|
165
|
+
// payload is Electron.app/) unpack flat into the destination. Detect
|
|
166
|
+
// rather than assume — the old code assumed the wrapper and silently
|
|
167
|
+
// produced nothing on macOS.
|
|
168
|
+
const payload = findPayload(scratch, join(scratch, innerName))
|
|
169
|
+
if (!payload) {
|
|
170
|
+
ctx.logger.warn(
|
|
171
|
+
`[clean-desktop-shell] unexpected archive layout: no ${EXE_TOP} under ${scratch}`,
|
|
172
|
+
)
|
|
173
|
+
rmSync(scratch, { recursive: true, force: true })
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
movePayloadInto(dir, payload)
|
|
178
|
+
rmSync(scratch, { recursive: true, force: true })
|
|
179
|
+
if (isMac) await clearQuarantine(ctx, join(dir, EXE_TOP))
|
|
180
|
+
return
|
|
181
|
+
}
|
|
182
|
+
throw new Error('electron download failed from all sources')
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Verify a downloaded zip against the source's SHASUMS256.txt — the same
|
|
187
|
+
* integrity check the `electron` package's own installer performs. Returns
|
|
188
|
+
* false on a checksum mismatch (fatal for that source); a temporarily
|
|
189
|
+
* unreachable SHASUMS file only warns, so a reachable zip is not wasted
|
|
190
|
+
* over an unrelated blocker.
|
|
191
|
+
*/
|
|
192
|
+
async function verifySha256(ctx, file, shasumsUrl, name) {
|
|
193
|
+
let expected = null
|
|
194
|
+
let reason = ''
|
|
195
|
+
try {
|
|
196
|
+
const res = await fetch(shasumsUrl, { signal: AbortSignal.timeout(10000) })
|
|
197
|
+
if (!res.ok) {
|
|
198
|
+
reason = `HTTP ${res.status}`
|
|
199
|
+
} else {
|
|
200
|
+
const text = await res.text()
|
|
201
|
+
for (const line of text.split('\n')) {
|
|
202
|
+
const m = line.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/i)
|
|
203
|
+
if (m && m[2].trim() === name) {
|
|
204
|
+
expected = m[1].toLowerCase()
|
|
205
|
+
break
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
if (!expected) reason = 'entry not found'
|
|
209
|
+
}
|
|
210
|
+
} catch (err) {
|
|
211
|
+
reason = err?.message || 'fetch failed'
|
|
212
|
+
}
|
|
213
|
+
if (!expected) {
|
|
214
|
+
ctx.logger.warn(
|
|
215
|
+
`[clean-desktop-shell] SHASUMS256.txt unavailable from ${shasumsUrl} (${reason}) — skipping integrity check`,
|
|
216
|
+
)
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
219
|
+
// Stream the hash — the zip can be ~270 MB and must not be read whole
|
|
220
|
+
// into memory.
|
|
221
|
+
const hash = createHash('sha256')
|
|
222
|
+
await pipeline(createReadStream(file), hash)
|
|
223
|
+
const actual = hash.digest('hex')
|
|
224
|
+
if (actual !== expected) {
|
|
225
|
+
ctx.logger.warn(
|
|
226
|
+
`[clean-desktop-shell] checksum mismatch for ${name}: expected ${expected}, got ${actual}`,
|
|
227
|
+
)
|
|
228
|
+
return false
|
|
229
|
+
}
|
|
230
|
+
return true
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The dir that directly holds the electron payload right after extraction. */
|
|
234
|
+
function findPayload(scratch, wrapper) {
|
|
235
|
+
if (existsSync(join(wrapper, EXE_TOP))) return wrapper
|
|
236
|
+
if (existsSync(join(scratch, EXE_TOP))) return scratch
|
|
237
|
+
return null
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function movePayloadInto(dir, payload) {
|
|
241
|
+
rmSync(dir, { recursive: true, force: true })
|
|
242
|
+
renameSync(payload, dir)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* macOS: a bundle carrying com.apple.quarantine is refused by Gatekeeper with
|
|
247
|
+
* the infamous "已损坏,无法打开" dialog. We download with curl (which does
|
|
248
|
+
* not set the attribute), but archive members themselves can carry it, so
|
|
249
|
+
* clear it once right after extraction rather than debugging it per user.
|
|
250
|
+
*/
|
|
251
|
+
function clearQuarantine(ctx, target) {
|
|
252
|
+
return new Promise((resolve) => {
|
|
253
|
+
let child
|
|
254
|
+
try {
|
|
255
|
+
child = spawn('xattr', ['-cr', target], { windowsHide: true, stdio: 'ignore' })
|
|
256
|
+
} catch {
|
|
257
|
+
return resolve(false)
|
|
258
|
+
}
|
|
259
|
+
child.on('error', () => resolve(false))
|
|
260
|
+
child.on('exit', (code) => {
|
|
261
|
+
ctx.logger.info(
|
|
262
|
+
code === 0
|
|
263
|
+
? `[clean-desktop-shell] cleared extended attributes on ${target}`
|
|
264
|
+
: `[clean-desktop-shell] xattr cleanup skipped (exit ${code})`,
|
|
265
|
+
)
|
|
266
|
+
resolve(code === 0)
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Pick the download source by racing a HEAD probe against each candidate
|
|
273
|
+
* (direct connection, 3s each). The fastest reachable source goes first —
|
|
274
|
+
* this naturally prefers the domestic npmmirror mirror on CN networks,
|
|
275
|
+
* the official GitHub source on international/well-proxied networks, and
|
|
276
|
+
* never wastes a full download on a dead source. Each candidate carries
|
|
277
|
+
* its own SHASUMS256.txt location for the post-download integrity check.
|
|
278
|
+
*/
|
|
279
|
+
async function runtimeCandidates(version) {
|
|
280
|
+
const base = (name, urlBase) => ({
|
|
281
|
+
name,
|
|
282
|
+
url: `${urlBase}/${zipName(version)}`,
|
|
283
|
+
shasums: `${urlBase}/SHASUMS256.txt`,
|
|
284
|
+
})
|
|
285
|
+
const candidates = [
|
|
286
|
+
base('github', `https://github.com/electron/electron/releases/download/v${version}`),
|
|
287
|
+
base('npmmirror', `https://npmmirror.com/mirrors/electron/${version}`),
|
|
288
|
+
]
|
|
289
|
+
const results = await Promise.all(
|
|
290
|
+
candidates.map(async (c) => {
|
|
291
|
+
const t0 = Date.now()
|
|
292
|
+
try {
|
|
293
|
+
// AbortSignal.timeout — the standard self-cleaning probe timeout.
|
|
294
|
+
const res = await fetch(c.url, { signal: AbortSignal.timeout(3000), method: 'HEAD' })
|
|
295
|
+
if (res.status < 500) return { ...c, ms: Date.now() - t0 }
|
|
296
|
+
} catch {
|
|
297
|
+
// unreachable — drop
|
|
298
|
+
}
|
|
299
|
+
return null
|
|
300
|
+
}),
|
|
301
|
+
)
|
|
302
|
+
const ok = results.filter(Boolean).sort((a, b) => a.ms - b.ms)
|
|
303
|
+
if (ok.length === 0) {
|
|
304
|
+
// Probes all failed (offline?) — still try both, mirror first (cheap).
|
|
305
|
+
return [candidates[1], candidates[0]]
|
|
306
|
+
}
|
|
307
|
+
const rest = candidates.filter((c) => c.url !== ok[0].url)
|
|
308
|
+
return [ok[0], ...rest]
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Extract a zip using whatever the platform actually provides.
|
|
313
|
+
*
|
|
314
|
+
* The archives are ordinary zips, but the readers differ:
|
|
315
|
+
* - Windows: bsdtar ships as tar.exe and reads zip; Expand-Archive is the
|
|
316
|
+
* fallback for hardened images where tar is unavailable.
|
|
317
|
+
* - macOS: ditto -xk is Apple's own extractor and preserves the symlinks
|
|
318
|
+
* inside Electron.app; unzip next; bsdtar last.
|
|
319
|
+
* - Linux: unzip, then tar.
|
|
320
|
+
*
|
|
321
|
+
* Success is decided by the caller (findPayload), not by the exit code — a
|
|
322
|
+
* zip can unpack "successfully" into a layout nobody expects.
|
|
323
|
+
*/
|
|
324
|
+
function extractZip(ctx, zipPath, dest) {
|
|
325
|
+
const strategies = isWin
|
|
326
|
+
? [
|
|
327
|
+
{ cmd: 'tar', args: ['-xf', zipPath, '-C', dest] },
|
|
328
|
+
{
|
|
329
|
+
cmd: 'powershell',
|
|
330
|
+
args: [
|
|
331
|
+
'-NoProfile',
|
|
332
|
+
'-NonInteractive',
|
|
333
|
+
'-Command',
|
|
334
|
+
`Expand-Archive -LiteralPath '${zipPath.replace(/'/g, "''")}' -DestinationPath '${dest.replace(/'/g, "''")}' -Force`,
|
|
335
|
+
],
|
|
336
|
+
},
|
|
337
|
+
]
|
|
338
|
+
: isMac
|
|
339
|
+
? [
|
|
340
|
+
{ cmd: 'ditto', args: ['-xk', zipPath, dest] },
|
|
341
|
+
{ cmd: 'unzip', args: ['-q', '-o', zipPath, '-d', dest] },
|
|
342
|
+
{ cmd: 'tar', args: ['-xf', zipPath, '-C', dest] },
|
|
343
|
+
]
|
|
344
|
+
: [
|
|
345
|
+
{ cmd: 'unzip', args: ['-q', '-o', zipPath, '-d', dest] },
|
|
346
|
+
{ cmd: 'tar', args: ['-xf', zipPath, '-C', dest] },
|
|
347
|
+
]
|
|
348
|
+
|
|
349
|
+
return (async () => {
|
|
350
|
+
mkdirSync(dest, { recursive: true })
|
|
351
|
+
for (const s of strategies) {
|
|
352
|
+
const ok = await runExtractor(s.cmd, s.args)
|
|
353
|
+
if (ok) {
|
|
354
|
+
ctx.logger.info(`[clean-desktop-shell] extracted with ${s.cmd}`)
|
|
355
|
+
return true
|
|
356
|
+
}
|
|
357
|
+
ctx.logger.warn(`[clean-desktop-shell] extractor unavailable or failed: ${s.cmd}`)
|
|
358
|
+
}
|
|
359
|
+
return false
|
|
360
|
+
})()
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function runExtractor(cmd, args) {
|
|
364
|
+
return new Promise((resolve) => {
|
|
365
|
+
let child
|
|
366
|
+
try {
|
|
367
|
+
child = spawn(cmd, args, { windowsHide: true, stdio: 'ignore' })
|
|
368
|
+
} catch {
|
|
369
|
+
return resolve(false)
|
|
370
|
+
}
|
|
371
|
+
child.on('error', () => resolve(false))
|
|
372
|
+
child.on('exit', (code) => resolve(code === 0))
|
|
373
|
+
})
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Drop version dirs older than the current one (dead weight), plus any
|
|
378
|
+
* scratch dir a crashed extraction left behind.
|
|
379
|
+
*/
|
|
380
|
+
function cleanupOldVersions(root, currentDir) {
|
|
381
|
+
try {
|
|
382
|
+
for (const entry of readdirSync(root)) {
|
|
383
|
+
const isStaleRuntime = entry.startsWith('electron-v')
|
|
384
|
+
const isScratch = entry.startsWith('.extract-') || entry.startsWith('.electron-')
|
|
385
|
+
if (!isStaleRuntime && !isScratch) continue
|
|
386
|
+
const full = join(root, entry)
|
|
387
|
+
if (full === currentDir) continue
|
|
388
|
+
rmSync(full, { recursive: true, force: true })
|
|
389
|
+
}
|
|
390
|
+
} catch {
|
|
391
|
+
// best-effort
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Provision a local electron package's dist/ as the version dir itself, so
|
|
397
|
+
* the layout matches a downloaded runtime: <dir>/<EXE_RELPATH> at the
|
|
398
|
+
* version-dir root (on macOS that is <dir>/Electron.app/Contents/MacOS/Electron).
|
|
399
|
+
*
|
|
400
|
+
* Windows: junction (zero-copy, instant) — a 269MB recursive cpSync can be
|
|
401
|
+
* killed by sandbox/AV on large trees, so only fall back to a copy.
|
|
402
|
+
* macOS/Linux: a symlink to dist/ would work too, but a bare .app reached
|
|
403
|
+
* through a symlink is a common source of Gatekeeper/entitlement surprises,
|
|
404
|
+
* so always copy there.
|
|
405
|
+
*/
|
|
406
|
+
function provisionLocalDist(srcPkg, destDir) {
|
|
407
|
+
if (process.platform === 'win32') {
|
|
408
|
+
try {
|
|
409
|
+
rmSync(destDir, { recursive: true, force: true })
|
|
410
|
+
symlinkSync(join(srcPkg, 'dist'), destDir, 'junction')
|
|
411
|
+
return true
|
|
412
|
+
} catch {
|
|
413
|
+
// fall through to a real copy
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
try {
|
|
417
|
+
rmSync(destDir, { recursive: true, force: true })
|
|
418
|
+
cpSync(join(srcPkg, 'dist'), destDir, { recursive: true })
|
|
419
|
+
return true
|
|
420
|
+
} catch {
|
|
421
|
+
return false
|
|
422
|
+
}
|
|
423
|
+
}
|