dsh-clean-desktop-shell 0.1.6 → 0.1.8

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.
@@ -23,8 +23,17 @@ import {
23
23
  rmSync,
24
24
  symlinkSync,
25
25
  } from 'node:fs'
26
- import { join } from 'node:path'
27
- import { PKG_ROOT, EXE_NAME, ARCH, PLATFORM, runtimeRoot } from './common.js'
26
+ import { basename, join } from 'node:path'
27
+ import {
28
+ PKG_ROOT,
29
+ EXE_RELPATH,
30
+ EXE_TOP,
31
+ ARCH,
32
+ PLATFORM,
33
+ isWin,
34
+ isMac,
35
+ runtimeRoot,
36
+ } from './common.js'
28
37
 
29
38
  function electronVersion() {
30
39
  try {
@@ -49,18 +58,19 @@ export async function ensureRuntime(ctx) {
49
58
  if (!version) throw new Error('desktopShell.electronVersion missing in package.json')
50
59
  const root = runtimeRoot()
51
60
  const dir = versionDir(root, version)
52
- const exe = join(dir, EXE_NAME)
61
+ const exe = join(dir, EXE_RELPATH)
53
62
 
54
63
  // 1) Already provisioned for this version?
55
64
  if (existsSync(exe)) {
56
65
  cleanupOldVersions(root, dir)
66
+ if (!isWin) await ensureExecutable(ctx, exe)
57
67
  return exe
58
68
  }
59
69
 
60
70
  // 2) Local reuse: DSH_SHELL_ELECTRON_DIR → link/copy its dist/ (fast).
61
71
  const localSrc = process.env.DSH_SHELL_ELECTRON_DIR
62
72
  if (localSrc) {
63
- const srcExe = join(localSrc, 'dist', EXE_NAME)
73
+ const srcExe = join(localSrc, 'dist', EXE_RELPATH)
64
74
  if (existsSync(srcExe) && provisionLocalDist(localSrc, dir)) {
65
75
  ctx.logger.info(`[clean-desktop-shell] reused electron runtime from ${localSrc}`)
66
76
  }
@@ -73,43 +83,130 @@ export async function ensureRuntime(ctx) {
73
83
  }
74
84
 
75
85
  if (!existsSync(exe)) {
86
+ // Name the exact path we expected. Without it a user on an untested
87
+ // platform has nothing to report back (this failure used to be silent).
76
88
  throw new Error(
77
- 'electron runtime provisioning failed — check network, or point DSH_SHELL_ELECTRON_DIR at an electron package',
89
+ `electron runtime provisioning failed — expected binary at ${exe} ` +
90
+ `(platform=${PLATFORM} arch=${ARCH}, layout differs per platform); ` +
91
+ 'check network, or point DSH_SHELL_ELECTRON_DIR at an electron package',
78
92
  )
79
93
  }
80
94
  cleanupOldVersions(root, dir)
95
+ if (!isWin) await ensureExecutable(ctx, exe)
81
96
  ctx.logger.info(`[clean-desktop-shell] electron runtime ${version} ready at ${dir}`)
82
97
  return exe
83
98
  }
84
99
 
100
+ /**
101
+ * Zip extraction does not reliably restore the executable bit — bsdtar
102
+ * (Windows tar.exe, macOS /usr/bin/tar) in particular drops it — and a
103
+ * non-executable binary fails later with a bare EACCES on spawn. Guarantee
104
+ * it instead of trusting the extractor.
105
+ */
106
+ function ensureExecutable(ctx, target) {
107
+ return new Promise((resolve) => {
108
+ let child
109
+ try {
110
+ child = spawn('chmod', ['+x', target], { stdio: 'ignore' })
111
+ } catch {
112
+ return resolve(false)
113
+ }
114
+ child.on('error', () => resolve(false))
115
+ child.on('exit', (code) => {
116
+ if (code !== 0) {
117
+ ctx.logger.warn(`[clean-desktop-shell] chmod +x failed (exit ${code}) on ${target}`)
118
+ }
119
+ resolve(code === 0)
120
+ })
121
+ })
122
+ }
123
+
85
124
  async function downloadRuntime(ctx, version, root, dir) {
86
125
  const tmpZip = join(root, `.electron-${version}.zip.tmp`)
87
126
  rmSync(tmpZip, { force: true })
88
127
  const urls = await runtimeUrls(version)
128
+ const innerName = zipName(version).replace(/\.zip$/, '')
129
+
89
130
  for (const url of urls) {
90
131
  ctx.logger.info(`[clean-desktop-shell] downloading electron ${version} from ${url}`)
91
- if (await fetchFile(url, tmpZip)) {
92
- // Zip extracts to an inner dir named like the zip basename.
93
- const inner = join(root, zipName(version).replace(/\.zip$/, ''))
94
- try {
95
- await extractZip(tmpZip, root)
96
- if (existsSync(join(inner, EXE_NAME)) && inner !== dir) {
97
- rmSync(dir, { recursive: true, force: true })
98
- renameSync(inner, dir)
99
- }
100
- rmSync(tmpZip, { force: true })
101
- return
102
- } catch (err) {
103
- ctx.logger.warn(`[clean-desktop-shell] extract failed: ${err?.message ?? err}`)
104
- rmSync(inner, { recursive: true, force: true })
105
- }
132
+ if (!(await fetchFile(url, tmpZip))) {
133
+ // Failed download — drop the partial file so a later run starts clean.
134
+ rmSync(tmpZip, { force: true })
135
+ continue
106
136
  }
107
- // Failed download — drop the partial file so a later run starts clean.
137
+
138
+ // Extract into a scratch dir, never into the shared runtime root: a
139
+ // half-extracted zip there would be indistinguishable from a real
140
+ // runtime, and the sibling electron-v* dirs must not be disturbed.
141
+ const scratch = join(root, `.extract-${version}-${Date.now()}`)
142
+ const ok = await extractZip(ctx, tmpZip, scratch)
108
143
  rmSync(tmpZip, { force: true })
144
+ if (!ok) {
145
+ ctx.logger.warn(`[clean-desktop-shell] no extractor succeeded for ${url}`)
146
+ rmSync(scratch, { recursive: true, force: true })
147
+ continue
148
+ }
149
+
150
+ // Both archive layouts exist in the wild: some Electron zips wrap
151
+ // everything in a dir named after the zip, others (notably darwin, whose
152
+ // payload is Electron.app/) unpack flat into the destination. Detect
153
+ // rather than assume — the old code assumed the wrapper and silently
154
+ // produced nothing on macOS.
155
+ const payload = findPayload(scratch, join(scratch, innerName))
156
+ if (!payload) {
157
+ ctx.logger.warn(
158
+ `[clean-desktop-shell] unexpected archive layout: no ${EXE_TOP} under ${scratch}`,
159
+ )
160
+ rmSync(scratch, { recursive: true, force: true })
161
+ continue
162
+ }
163
+
164
+ movePayloadInto(dir, payload)
165
+ rmSync(scratch, { recursive: true, force: true })
166
+ if (isMac) await clearQuarantine(ctx, join(dir, EXE_TOP))
167
+ return
109
168
  }
110
169
  throw new Error('electron download failed from all sources')
111
170
  }
112
171
 
172
+ /** The dir that directly holds the electron payload right after extraction. */
173
+ function findPayload(scratch, wrapper) {
174
+ if (existsSync(join(wrapper, EXE_TOP))) return wrapper
175
+ if (existsSync(join(scratch, EXE_TOP))) return scratch
176
+ return null
177
+ }
178
+
179
+ function movePayloadInto(dir, payload) {
180
+ rmSync(dir, { recursive: true, force: true })
181
+ renameSync(payload, dir)
182
+ }
183
+
184
+ /**
185
+ * macOS: a bundle carrying com.apple.quarantine is refused by Gatekeeper with
186
+ * the infamous "已损坏,无法打开" dialog. We download with curl (which does
187
+ * not set the attribute), but archive members themselves can carry it, so
188
+ * clear it once right after extraction rather than debugging it per user.
189
+ */
190
+ function clearQuarantine(ctx, target) {
191
+ return new Promise((resolve) => {
192
+ let child
193
+ try {
194
+ child = spawn('xattr', ['-cr', target], { windowsHide: true, stdio: 'ignore' })
195
+ } catch {
196
+ return resolve(false)
197
+ }
198
+ child.on('error', () => resolve(false))
199
+ child.on('exit', (code) => {
200
+ ctx.logger.info(
201
+ code === 0
202
+ ? `[clean-desktop-shell] cleared extended attributes on ${target}`
203
+ : `[clean-desktop-shell] xattr cleanup skipped (exit ${code})`,
204
+ )
205
+ resolve(code === 0)
206
+ })
207
+ })
208
+ }
209
+
113
210
  /**
114
211
  * Pick the download source by racing a HEAD probe against each candidate
115
212
  * (direct connection, 3s each). The fastest reachable source goes first —
@@ -162,21 +259,81 @@ function fetchFile(url, dest) {
162
259
  })
163
260
  }
164
261
 
165
- function extractZip(zipPath, dest) {
166
- // Windows ships bsdtar (tar.exe) which reads zip; fall back to
167
- // PowerShell Expand-Archive if needed.
168
- const child = spawn('tar', ['-xf', zipPath, '-C', dest], { windowsHide: true, stdio: 'ignore' })
169
- return new Promise((resolve, reject) => {
170
- child.on('error', reject)
171
- child.on('exit', (code) => (code === 0 ? resolve() : reject(new Error(`tar exit ${code}`))))
262
+ /**
263
+ * Extract a zip using whatever the platform actually provides.
264
+ *
265
+ * The archives are ordinary zips, but the readers differ:
266
+ * - Windows: bsdtar ships as tar.exe and reads zip; Expand-Archive is the
267
+ * fallback for hardened images where tar is unavailable.
268
+ * - macOS: ditto -xk is Apple's own extractor and preserves the symlinks
269
+ * inside Electron.app; unzip next; bsdtar last.
270
+ * - Linux: unzip, then tar.
271
+ *
272
+ * Success is decided by the caller (findPayload), not by the exit code — a
273
+ * zip can unpack "successfully" into a layout nobody expects.
274
+ */
275
+ function extractZip(ctx, zipPath, dest) {
276
+ const strategies = isWin
277
+ ? [
278
+ { cmd: 'tar', args: ['-xf', zipPath, '-C', dest] },
279
+ {
280
+ cmd: 'powershell',
281
+ args: [
282
+ '-NoProfile',
283
+ '-NonInteractive',
284
+ '-Command',
285
+ `Expand-Archive -LiteralPath '${zipPath.replace(/'/g, "''")}' -DestinationPath '${dest.replace(/'/g, "''")}' -Force`,
286
+ ],
287
+ },
288
+ ]
289
+ : isMac
290
+ ? [
291
+ { cmd: 'ditto', args: ['-xk', zipPath, dest] },
292
+ { cmd: 'unzip', args: ['-q', '-o', zipPath, '-d', dest] },
293
+ { cmd: 'tar', args: ['-xf', zipPath, '-C', dest] },
294
+ ]
295
+ : [
296
+ { cmd: 'unzip', args: ['-q', '-o', zipPath, '-d', dest] },
297
+ { cmd: 'tar', args: ['-xf', zipPath, '-C', dest] },
298
+ ]
299
+
300
+ return (async () => {
301
+ mkdirSync(dest, { recursive: true })
302
+ for (const s of strategies) {
303
+ const ok = await runExtractor(s.cmd, s.args)
304
+ if (ok) {
305
+ ctx.logger.info(`[clean-desktop-shell] extracted with ${s.cmd}`)
306
+ return true
307
+ }
308
+ ctx.logger.warn(`[clean-desktop-shell] extractor unavailable or failed: ${s.cmd}`)
309
+ }
310
+ return false
311
+ })()
312
+ }
313
+
314
+ function runExtractor(cmd, args) {
315
+ return new Promise((resolve) => {
316
+ let child
317
+ try {
318
+ child = spawn(cmd, args, { windowsHide: true, stdio: 'ignore' })
319
+ } catch {
320
+ return resolve(false)
321
+ }
322
+ child.on('error', () => resolve(false))
323
+ child.on('exit', (code) => resolve(code === 0))
172
324
  })
173
325
  }
174
326
 
175
- /** Remove version dirs older than the current one (dead weight). */
327
+ /**
328
+ * Drop version dirs older than the current one (dead weight), plus any
329
+ * scratch dir a crashed extraction left behind.
330
+ */
176
331
  function cleanupOldVersions(root, currentDir) {
177
332
  try {
178
333
  for (const entry of readdirSync(root)) {
179
- if (!entry.startsWith('electron-v')) continue
334
+ const isStaleRuntime = entry.startsWith('electron-v')
335
+ const isScratch = entry.startsWith('.extract-') || entry.startsWith('.electron-')
336
+ if (!isStaleRuntime && !isScratch) continue
180
337
  const full = join(root, entry)
181
338
  if (full === currentDir) continue
182
339
  rmSync(full, { recursive: true, force: true })
@@ -187,11 +344,15 @@ function cleanupOldVersions(root, currentDir) {
187
344
  }
188
345
 
189
346
  /**
190
- * Provision a local electron package's dist/ as the version dir itself,
191
- * so the layout matches a downloaded runtime: <dir>/electron(.exe) at the
192
- * version-dir root. Windows: junction (zero-copy, instant) — a 269MB
193
- * recursive cpSync can be killed by sandbox/AV on large trees, so only
194
- * fall back to a copy.
347
+ * Provision a local electron package's dist/ as the version dir itself, so
348
+ * the layout matches a downloaded runtime: <dir>/<EXE_RELPATH> at the
349
+ * version-dir root (on macOS that is <dir>/Electron.app/Contents/MacOS/Electron).
350
+ *
351
+ * Windows: junction (zero-copy, instant) — a 269MB recursive cpSync can be
352
+ * killed by sandbox/AV on large trees, so only fall back to a copy.
353
+ * macOS/Linux: a symlink to dist/ would work too, but a bare .app reached
354
+ * through a symlink is a common source of Gatekeeper/entitlement surprises,
355
+ * so always copy there.
195
356
  */
196
357
  function provisionLocalDist(srcPkg, destDir) {
197
358
  if (process.platform === 'win32') {
package/version.txt CHANGED
@@ -1 +1 @@
1
- 0.1.6
1
+ 0.1.8