@webprodigies/morphite 0.2.2 → 0.2.3

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/app-update.mjs ADDED
@@ -0,0 +1,182 @@
1
+ /** SOURCE OF TRUTH KEYWORDS: appUpdateSteps, APP_SWAP, SWAP_HELPER_SCRIPT, verifyApp, STAGE_PREFIX
2
+ * WHAT: The steps that replace an installed Morphite with a release, for the terminal installer and
3
+ * the app's own Update alike: read the release manifest, download the zip for this CPU while
4
+ * measuring its SHA-256, unpack it with ditto, check the unpacked app (bundle id, version,
5
+ * signature), clear the download mark, and swap it in by renames: the installed app is set aside,
6
+ * the new one renamed into place, and on any failure the installed app is put back.
7
+ * WHY: Both paths must trust and replace an app the same way. After the app quits nothing of it may
8
+ * run, and packaged Electron cannot run as Node (hardenPackagedElectron's RunAsNode fuse), so the
9
+ * app's swap runs as SWAP_HELPER_SCRIPT under /bin/sh; it repeats swapApps's steps and is tested
10
+ * against the same cases. Its inputs are positional arguments; no path is ever put into its text.
11
+ * WHERE: bin/morphite.mjs (installer) and src/desktop/updates/release-install.ts (STAGED_UPDATE)
12
+ * import these; manifest.mjs owns the release format. Plain JavaScript: the installer has no
13
+ * build step or dependencies.
14
+ */
15
+ import { createHash } from 'node:crypto'
16
+ import { createReadStream, createWriteStream } from 'node:fs'
17
+ import { execFile } from 'node:child_process'
18
+ import { rename, stat, readFile } from 'node:fs/promises'
19
+ import { join } from 'node:path'
20
+ import { once } from 'node:events'
21
+ import { promisify } from 'node:util'
22
+ import { INSTALL_MANIFEST_FILE, latestManifestUrl, parseInstallManifest, releaseFileUrl } from './manifest.mjs'
23
+
24
+ const run = promisify(execFile)
25
+ /** Folders Morphite creates beside an installed app while updating it; only these are cleaned up. */
26
+ export const STAGE_PREFIX = '.morphite-update-'
27
+ /** The file inside such a folder that proves Morphite made it. */
28
+ export const STAGE_MARK = 'morphite-update.json'
29
+
30
+ export async function fetchWithRetry(url, { attempts = 3, userAgent = '@webprodigies/morphite installer', signal } = {}) {
31
+ let failure
32
+ for (let attempt = 1; attempt <= attempts; attempt++) {
33
+ try {
34
+ const response = await fetch(url, { headers: { 'user-agent': userAgent }, redirect: 'follow', signal })
35
+ if (response.status === 404) return response
36
+ if (!response.ok) throw Error(`HTTP ${response.status}`)
37
+ return response
38
+ } catch (error) {
39
+ if (signal?.aborted) throw error
40
+ failure = error
41
+ await new Promise((resolve) => setTimeout(resolve, 600 * attempt))
42
+ }
43
+ }
44
+ throw Error(`Could not reach GitHub (${failure?.message ?? 'network error'}). Check your connection and run the command again.`)
45
+ }
46
+
47
+ /** The checked manifest of the newest release, or of the release in the local folder `from`. */
48
+ export async function readManifest(from, options) {
49
+ if (from) return parseInstallManifest(JSON.parse(await readFile(join(from, INSTALL_MANIFEST_FILE), 'utf8')))
50
+ const response = await fetchWithRetry(latestManifestUrl(), options)
51
+ if (response.status === 404) throw Error('No Morphite release is available yet. Try again soon.')
52
+ return parseInstallManifest(await response.json())
53
+ }
54
+
55
+ /** Downloads (or copies from `from`) one archive to `destination`, measuring it as it arrives. */
56
+ export async function downloadArchive(manifest, archive, destination, { from, progress, signal, ...options } = {}) {
57
+ const hash = createHash('sha256')
58
+ let received = 0
59
+ const report = () => progress?.(received / archive.size, received)
60
+ const output = createWriteStream(destination, { mode: 0o600 })
61
+ const write = async (chunk) => {
62
+ hash.update(chunk)
63
+ received += chunk.length
64
+ if (received > archive.size) throw Error('The download is larger than the release says. Nothing was installed.')
65
+ if (!output.write(chunk)) await once(output, 'drain')
66
+ }
67
+ const timer = progress ? setInterval(report, 120) : undefined
68
+ try {
69
+ if (from) {
70
+ for await (const chunk of createReadStream(join(from, archive.file), { signal })) await write(chunk)
71
+ } else {
72
+ const response = await fetchWithRetry(releaseFileUrl(manifest.tag, archive.file), { ...options, signal })
73
+ if (!response.ok || !response.body) throw Error(`The release file is unavailable (HTTP ${response.status}).`)
74
+ for await (const chunk of response.body) await write(chunk)
75
+ }
76
+ output.end()
77
+ await once(output, 'finish')
78
+ } finally {
79
+ clearInterval(timer)
80
+ output.destroy()
81
+ }
82
+ report()
83
+ return { path: destination, sha256: hash.digest('hex'), size: received }
84
+ }
85
+ export const archiveMatches = (file, archive) => file.size === archive.size && file.sha256 === archive.sha256
86
+
87
+ /** Unpacks a release zip (made with ditto --keepParent) into `folder`. */
88
+ export async function unpackApp(zip, folder) {
89
+ await run('/usr/bin/ditto', ['-x', '-k', zip, folder])
90
+ }
91
+ const infoValue = (app, key) =>
92
+ run('/usr/bin/plutil', ['-extract', key, 'raw', '-o', '-', join(app, 'Contents/Info.plist')]).then(
93
+ (result) => result.stdout.trim(),
94
+ () => null
95
+ )
96
+ export class AppCheckError extends Error {
97
+ constructor(reason, message) {
98
+ super(message)
99
+ this.reason = reason
100
+ }
101
+ }
102
+ /** Throws an AppCheckError unless `app` is the expected Morphite build with an intact signature. */
103
+ export async function verifyApp(app, { bundleId, version }) {
104
+ if ((await infoValue(app, 'CFBundleIdentifier')) !== bundleId)
105
+ throw new AppCheckError('bundle-id', 'The downloaded app isn’t the expected Morphite build.')
106
+ if ((await infoValue(app, 'CFBundleShortVersionString')) !== version)
107
+ throw new AppCheckError('version', 'The downloaded app isn’t the version the release names.')
108
+ await run('/usr/bin/codesign', ['--verify', '--deep', '--strict', app]).catch(() => {
109
+ throw new AppCheckError('signature', 'The downloaded app failed its signature check.')
110
+ })
111
+ }
112
+ /** A file fetched by Morphite carries no download mark; one is cleared defensively all the same. */
113
+ export async function clearQuarantine(app) {
114
+ await run('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', app]).catch(() => undefined)
115
+ }
116
+
117
+ /** APP_SWAP: sets every installed copy aside (aside(index)), renames `candidate` to `destination`
118
+ * and returns what was set aside; on any failure puts every copy back and throws. */
119
+ export async function swapApps(installed, candidate, destination, aside) {
120
+ const replaced = []
121
+ try {
122
+ for (const previous of installed) {
123
+ const moved = aside(replaced.length)
124
+ await rename(previous, moved)
125
+ replaced.push([previous, moved])
126
+ }
127
+ if (await stat(destination).catch(() => null)) throw Error(`${destination} already exists and isn’t Morphite.`)
128
+ await rename(candidate, destination)
129
+ return replaced
130
+ } catch (error) {
131
+ for (const [original, moved] of replaced.reverse()) await rename(moved, original).catch(() => undefined)
132
+ throw error
133
+ }
134
+ }
135
+
136
+ /** APP_SWAP for the app's own Update, run detached by /bin/sh after Morphite starts quitting:
137
+ * sh -c SWAP_HELPER_SCRIPT morphite-update PID CURRENT STAGED ASIDE DESTINATION REOPEN ELEVATE
138
+ * Waits (up to 10 minutes) for PID to exit, sets CURRENT aside to ASIDE, renames STAGED to
139
+ * DESTINATION, puts CURRENT back on any failure, clears the download mark and, with REOPEN=1,
140
+ * opens whichever app is in place. With ELEVATE=1 only the swap runs through macOS's administrator
141
+ * prompt: the fixed AppleScript passes this same script (MORPHITE_SWAP_SCRIPT) and the paths as
142
+ * arguments, quoted by AppleScript's `quoted form of`. Exit codes: 0 updated, 3 Morphite did not
143
+ * quit, 4 nothing staged, 5 aside taken, 6 could not set aside, 7 rolled back, 8 not authorized. */
144
+ export const SWAP_HELPER_SCRIPT = String.raw`
145
+ set -u
146
+ say() { printf '%s %s\n' "$(/bin/date '+%Y-%m-%dT%H:%M:%S')" "$1"; }
147
+ swap() {
148
+ current=$1 staged=$2 aside=$3 destination=$4
149
+ if [ ! -d "$staged" ]; then say "The prepared update is missing. Nothing was changed."; return 4; fi
150
+ if [ -e "$aside" ]; then say "The previous app's place is taken. Nothing was changed."; return 5; fi
151
+ if ! /bin/mv "$current" "$aside"; then say "The installed app couldn't be set aside. Nothing was changed."; return 6; fi
152
+ if [ -e "$destination" ] || ! /bin/mv "$staged" "$destination"; then
153
+ /bin/mv "$aside" "$current" || say "The previous app is at $aside."
154
+ say "The update couldn't be moved into place; the previous app was put back."
155
+ return 7
156
+ fi
157
+ /usr/bin/xattr -dr com.apple.quarantine "$destination" 2>/dev/null
158
+ return 0
159
+ }
160
+ if [ "$1" = swap ]; then shift; swap "$@"; exit $?; fi
161
+ pid=$1 current=$2 staged=$3 aside=$4 destination=$5 reopen=$6 elevate=$7
162
+ waited=0
163
+ while /bin/kill -0 "$pid" 2>/dev/null; do
164
+ if [ "$waited" -ge 1200 ]; then say "Morphite didn't quit. Nothing was changed."; exit 3; fi
165
+ /bin/sleep 0.5
166
+ waited=$((waited + 1))
167
+ done
168
+ if [ "$elevate" = 1 ]; then
169
+ /usr/bin/osascript \
170
+ -e 'on run argv' \
171
+ -e 'do shell script "/bin/sh -c " & quoted form of (item 1 of argv) & " morphite-update swap " & quoted form of (item 2 of argv) & " " & quoted form of (item 3 of argv) & " " & quoted form of (item 4 of argv) & " " & quoted form of (item 5 of argv) with prompt "Morphite needs your permission to finish updating." with administrator privileges' \
172
+ -e 'end run' "$MORPHITE_SWAP_SCRIPT" "$current" "$staged" "$aside" "$destination"
173
+ result=$?
174
+ [ "$result" -eq 0 ] || { say "The update wasn't authorized or couldn't be moved into place. The previous app is in place."; result=8; }
175
+ else
176
+ swap "$current" "$staged" "$aside" "$destination"
177
+ result=$?
178
+ fi
179
+ if [ "$result" -eq 0 ]; then say "Updated $destination."; opened=$destination; else opened=$current; fi
180
+ if [ "$reopen" = 1 ]; then /usr/bin/open "$opened" || say "Morphite couldn't be reopened."; fi
181
+ exit "$result"
182
+ `
package/bin/morphite.mjs CHANGED
@@ -6,20 +6,18 @@
6
6
  * WHY: Until Morphite has a Developer ID, a browser download is stopped by Gatekeeper and people must
7
7
  * allow it in Privacy & Security. A file fetched by this program is not marked as downloaded, so the
8
8
  * verified app opens directly. Running the same command again is the update.
9
- * WHERE: ../manifest.mjs owns the release file format; scripts/release-publish.ts publishes releases;
10
- * the desktop app's Update action opens this command in Terminal. User data lives outside the app
11
- * bundle and is never touched here.
9
+ * WHERE: ../manifest.mjs owns the release file format and ../app-update.mjs the download, checks
10
+ * and swap (appUpdateSteps), shared with the desktop app's own Update, which offers this command
11
+ * only when it cannot prepare an update itself. scripts/release-publish.ts publishes releases.
12
+ * User data lives outside the app bundle and is never touched here.
12
13
  */
13
- import { createHash } from 'node:crypto'
14
- import { createWriteStream } from 'node:fs'
15
- import { access, constants, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, rmdir, stat } from 'node:fs/promises'
14
+ import { access, constants, lstat, mkdir, mkdtemp, readdir, rm } from 'node:fs/promises'
16
15
  import { execFile } from 'node:child_process'
17
16
  import { homedir, platform, tmpdir, arch as nodeArch } from 'node:os'
18
17
  import { join, basename, dirname } from 'node:path'
19
- import { once } from 'node:events'
20
18
  import { createInterface } from 'node:readline'
21
19
  import { promisify } from 'node:util'
22
- import { INSTALL_MANIFEST_FILE, latestManifestUrl, parseInstallManifest, releaseFileUrl } from '../manifest.mjs'
20
+ import { archiveMatches, clearQuarantine, downloadArchive, readManifest, swapApps, unpackApp, verifyApp } from '../app-update.mjs'
23
21
 
24
22
  const run = promisify(execFile)
25
23
  const args = process.argv.slice(2)
@@ -135,59 +133,6 @@ async function moveToTrash(path) {
135
133
  )
136
134
  }
137
135
 
138
- // ── Release ─────────────────────────────────────────────────────────────────
139
- async function fetchWithRetry(url, attempts = 3) {
140
- let failure
141
- for (let attempt = 1; attempt <= attempts; attempt++) {
142
- try {
143
- const response = await fetch(url, { headers: { 'user-agent': `${PACKAGE} installer` }, redirect: 'follow' })
144
- if (response.status === 404) return response
145
- if (!response.ok) throw Error(`HTTP ${response.status}`)
146
- return response
147
- } catch (error) {
148
- failure = error
149
- await new Promise((resolve) => setTimeout(resolve, 600 * attempt))
150
- }
151
- }
152
- throw Error(`Could not reach GitHub (${failure?.message ?? 'network error'}). Check your connection and run the command again.`)
153
- }
154
- async function readManifest(from) {
155
- if (from) return parseInstallManifest(JSON.parse(await readFile(join(from, INSTALL_MANIFEST_FILE), 'utf8')))
156
- const response = await fetchWithRetry(latestManifestUrl())
157
- if (response.status === 404) throw Error('No Morphite release is available yet. Try again soon.')
158
- return parseInstallManifest(await response.json())
159
- }
160
- async function download(manifest, archive, folder, from, progress) {
161
- const destination = join(folder, archive.file)
162
- const hash = createHash('sha256')
163
- let received = 0
164
- const report = () => progress(received / archive.size, `${megabytes(received)} of ${megabytes(archive.size)} MB`)
165
- const output = createWriteStream(destination, { mode: 0o600 })
166
- const write = async (chunk) => {
167
- hash.update(chunk)
168
- received += chunk.length
169
- if (received > archive.size) throw Error('The download is larger than the release says. Nothing was installed.')
170
- if (!output.write(chunk)) await once(output, 'drain')
171
- }
172
- const timer = setInterval(report, 120)
173
- try {
174
- if (from) {
175
- const { createReadStream } = await import('node:fs')
176
- for await (const chunk of createReadStream(join(from, archive.file))) await write(chunk)
177
- } else {
178
- const response = await fetchWithRetry(releaseFileUrl(manifest.tag, archive.file))
179
- if (!response.ok || !response.body) throw Error(`The release file is unavailable (HTTP ${response.status}).`)
180
- for await (const chunk of response.body) await write(chunk)
181
- }
182
- output.end()
183
- await once(output, 'finish')
184
- } finally {
185
- clearInterval(timer)
186
- output.destroy()
187
- }
188
- return { path: destination, sha256: hash.digest('hex'), size: received }
189
- }
190
-
191
136
  // ── Install ─────────────────────────────────────────────────────────────────
192
137
  async function writable(folder) {
193
138
  return access(folder, constants.W_OK).then(
@@ -298,11 +243,15 @@ async function main() {
298
243
  try {
299
244
  let file
300
245
  try {
301
- file = await download(manifest, archive, work, from, (ratio, detail) => live.set(progressLine(ratio, detail)))
246
+ file = await downloadArchive(manifest, archive, join(work, archive.file), {
247
+ from,
248
+ userAgent: `${PACKAGE} installer`,
249
+ progress: (ratio, received) => live.set(progressLine(ratio, `${megabytes(received)} of ${megabytes(archive.size)} MB`))
250
+ })
302
251
  } catch (error) {
303
252
  throw stop('The download stopped.', `${error.message} Nothing was changed.`)
304
253
  }
305
- if (file.size !== archive.size || file.sha256 !== archive.sha256)
254
+ if (!archiveMatches(file, archive))
306
255
  throw stop('The download doesn’t match the release.', 'Nothing was changed. Run the command again.')
307
256
  live.done(progressLine(1, `${megabytes(file.size)} MB · verified`))
308
257
 
@@ -311,14 +260,12 @@ async function main() {
311
260
  // Stage beside the destination so the final move is a rename on the same disk.
312
261
  const stage = await mkdtemp(join(target, '.morphite-install-'))
313
262
  try {
314
- await run('/usr/bin/ditto', ['-x', '-k', file.path, stage])
263
+ await unpackApp(file.path, stage)
315
264
  const candidate = join(stage, `${manifest.name}.app`)
316
- if ((await bundleIdOf(candidate)) !== manifest.bundleId) throw stop('The downloaded app isn’t the expected Morphite build.', 'Nothing was changed.')
317
- await run('/usr/bin/codesign', ['--verify', '--deep', '--strict', candidate]).catch(() => {
318
- throw stop('The downloaded app failed its signature check.', 'Nothing was changed.')
265
+ await verifyApp(candidate, { bundleId: manifest.bundleId, version: manifest.version }).catch((error) => {
266
+ throw stop(error.message, 'Nothing was changed.')
319
267
  })
320
- // Fetched by this program, the app carries no download mark; clear one defensively all the same.
321
- await run('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', candidate]).catch(() => undefined)
268
+ await clearQuarantine(candidate)
322
269
 
323
270
  // Morphite must be closed before its app is replaced. The person quits it from its menu, or
324
271
  // presses Enter and the installer asks it to quit the normal way (never a forced stop, so its
@@ -366,23 +313,13 @@ async function main() {
366
313
  live.set(grey(`${current ? 'Updating' : 'Installing'} in ${place}…`))
367
314
  }
368
315
 
369
- const replaced = []
370
316
  try {
371
317
  // Set the old app aside in the staging folder; it is removed with that folder only once the
372
- // new app is in place, as any Mac updater does. The bundle holds no data: projects, settings
373
- // and sign-in live outside it.
374
- for (const previous of installed) {
375
- const aside = join(stage, `previous-${replaced.length}.app`)
376
- await rename(previous, aside)
377
- replaced.push([previous, aside])
378
- }
379
- if (await stat(destination).catch(() => null)) throw Error(`${tilde(destination)} already exists and isn’t ${manifest.name}.`)
380
- await rename(candidate, destination)
318
+ // new app is in place, as any Mac updater does. On failure every earlier copy is put back
319
+ // (APP_SWAP). The bundle holds no data: projects, settings and sign-in live outside it.
320
+ await swapApps(installed, candidate, destination, (index) => join(stage, `previous-${index}.app`))
381
321
  } catch (error) {
382
- // Put every earlier copy back where it was; the install did not happen.
383
- for (const [original, moved] of replaced.reverse()) await rename(moved, original).catch(() => undefined)
384
- if (error.reported) throw error
385
- throw stop(`${manifest.name} couldn’t be installed.`, `${error.message} Nothing was changed.`)
322
+ throw stop(`${manifest.name} couldn’t be installed.`, `${error.message.replace(destination, tilde(destination))} Nothing was changed.`)
386
323
  }
387
324
  live.done(grey(`${current ? 'Updated' : 'Installed'} in ${place}`))
388
325
  } finally {
package/manifest.mjs CHANGED
@@ -4,10 +4,16 @@
4
4
  * WHY: The installer trusts nothing it has not checked: it installs only releases that carry this
5
5
  * file (earlier releases without it are never picked up), checks every field before a path or URL
6
6
  * is built from it, and refuses an archive whose size or hash differs.
7
- * WHERE: scripts/release-publish.ts writes it beside the zips; bin/morphite.mjs reads it from the
8
- * releases repository (or a local folder with --from). Plain JavaScript so the published installer
9
- * has no dependencies.
7
+ * WHERE: writeReleaseFiles writes it beside the zips (scripts/release-publish.ts, and the local
8
+ * update test kit scripts/update-try.ts); bin/morphite.mjs and the app's own Update
9
+ * (app-update.mjs readManifest → src/desktop/updates/release-install.ts) read it from the releases
10
+ * repository or a local folder. Plain JavaScript so the published installer has no dependencies.
10
11
  */
12
+ import { createHash } from 'node:crypto'
13
+ import { createReadStream } from 'node:fs'
14
+ import { stat, writeFile } from 'node:fs/promises'
15
+ import { join } from 'node:path'
16
+
11
17
  export const INSTALL_MANIFEST_FILE = 'morphite-install.json'
12
18
  export const RELEASES_REPOSITORY = 'webprodigies-org/morphite-releases'
13
19
  export const MAC_ARCHES = ['arm64', 'x64']
@@ -47,3 +53,24 @@ export const releaseFileUrl = (tag, file) =>
47
53
  `https://github.com/${RELEASES_REPOSITORY}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(file)}`
48
54
  export const latestManifestUrl = () =>
49
55
  `https://github.com/${RELEASES_REPOSITORY}/releases/latest/download/${INSTALL_MANIFEST_FILE}`
56
+
57
+ /** Describes the zips already in `folder` for a release: measures each, writes morphite-install.json
58
+ * and SHA256SUMS beside them and returns the checked manifest. Used by release:publish and the
59
+ * local update test kit (update:try), so a local release has the published format. */
60
+ export async function writeReleaseFiles(folder, { files, ...release }) {
61
+ const archives = {}
62
+ for (const arch of MAC_ARCHES) {
63
+ const file = files[arch]
64
+ if (!file) continue
65
+ const hash = createHash('sha256')
66
+ for await (const chunk of createReadStream(join(folder, file))) hash.update(chunk)
67
+ archives[arch] = { file, sha256: hash.digest('hex'), size: (await stat(join(folder, file))).size }
68
+ }
69
+ const manifest = parseInstallManifest({ format: 1, ...release, archives })
70
+ await writeFile(join(folder, INSTALL_MANIFEST_FILE), JSON.stringify(manifest, null, 2) + '\n')
71
+ await writeFile(
72
+ join(folder, 'SHA256SUMS'),
73
+ Object.values(manifest.archives).map((entry) => `${entry.sha256} ${entry.file}`).join('\n') + '\n'
74
+ )
75
+ return manifest
76
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webprodigies/morphite",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Install or update Morphite on your Mac: npx @webprodigies/morphite",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,6 +9,7 @@
9
9
  "files": [
10
10
  "bin/morphite.mjs",
11
11
  "manifest.mjs",
12
+ "app-update.mjs",
12
13
  "README.md"
13
14
  ],
14
15
  "engines": {