@webprodigies/morphite 0.2.1 → 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 +182 -0
- package/bin/morphite.mjs +64 -90
- package/manifest.mjs +30 -3
- package/package.json +2 -1
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
|
|
10
|
-
* the desktop app's Update
|
|
11
|
-
*
|
|
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 {
|
|
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 {
|
|
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(
|
|
@@ -282,10 +227,13 @@ async function main() {
|
|
|
282
227
|
const destination = join(target, `${manifest.name}.app`)
|
|
283
228
|
const work = await mkdtemp(join(tmpdir(), 'morphite-installer-'))
|
|
284
229
|
const cleanup = () => rm(work, { recursive: true, force: true }).catch(() => undefined)
|
|
285
|
-
|
|
230
|
+
const stopped = () => {
|
|
286
231
|
live.done()
|
|
232
|
+
note('Stopped. Nothing was changed.', `Run npx ${PACKAGE}@latest again when you’re ready.`)
|
|
233
|
+
say('')
|
|
287
234
|
void cleanup().then(() => process.exit(130))
|
|
288
|
-
}
|
|
235
|
+
}
|
|
236
|
+
process.once('SIGINT', stopped)
|
|
289
237
|
// A problem after the download started: keep the last line where it stopped, then say what happened.
|
|
290
238
|
const stop = (title, detail) => {
|
|
291
239
|
live.done()
|
|
@@ -295,11 +243,15 @@ async function main() {
|
|
|
295
243
|
try {
|
|
296
244
|
let file
|
|
297
245
|
try {
|
|
298
|
-
file = await
|
|
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
|
+
})
|
|
299
251
|
} catch (error) {
|
|
300
252
|
throw stop('The download stopped.', `${error.message} Nothing was changed.`)
|
|
301
253
|
}
|
|
302
|
-
if (file
|
|
254
|
+
if (!archiveMatches(file, archive))
|
|
303
255
|
throw stop('The download doesn’t match the release.', 'Nothing was changed. Run the command again.')
|
|
304
256
|
live.done(progressLine(1, `${megabytes(file.size)} MB · verified`))
|
|
305
257
|
|
|
@@ -308,44 +260,66 @@ async function main() {
|
|
|
308
260
|
// Stage beside the destination so the final move is a rename on the same disk.
|
|
309
261
|
const stage = await mkdtemp(join(target, '.morphite-install-'))
|
|
310
262
|
try {
|
|
311
|
-
await
|
|
263
|
+
await unpackApp(file.path, stage)
|
|
312
264
|
const candidate = join(stage, `${manifest.name}.app`)
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
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.')
|
|
316
267
|
})
|
|
317
|
-
|
|
318
|
-
await run('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', candidate]).catch(() => undefined)
|
|
268
|
+
await clearQuarantine(candidate)
|
|
319
269
|
|
|
320
|
-
// Morphite must be closed before its app is replaced
|
|
270
|
+
// Morphite must be closed before its app is replaced. The person quits it from its menu, or
|
|
271
|
+
// presses Enter and the installer asks it to quit the normal way (never a forced stop, so its
|
|
272
|
+
// work ends cleanly). No keyboard shortcut is suggested: ⌘Q here would quit Terminal.
|
|
321
273
|
let open = await runningFrom([...installed, ...earlier])
|
|
322
274
|
if (open.length) {
|
|
323
275
|
const names = open.map((app) => basename(app, '.app')).join(' and ')
|
|
324
|
-
live.
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
276
|
+
live.clear()
|
|
277
|
+
note(
|
|
278
|
+
`${names} is open.`,
|
|
279
|
+
`Quit it from its menu bar (${names} → Quit), and this ${current ? 'update' : 'install'} continues on its own.`,
|
|
280
|
+
tty ? 'Or press Return to quit it now. Control-C stops without changing anything.' : 'Control-C stops without changing anything.'
|
|
281
|
+
)
|
|
282
|
+
say('')
|
|
283
|
+
const input = tty && process.stdin.isTTY ? process.stdin : null
|
|
284
|
+
const key = (data) => {
|
|
285
|
+
const text = String(data)
|
|
286
|
+
if (text.includes('\u0003')) return stopped()
|
|
287
|
+
if (!/[\r\n]/.test(text)) return
|
|
288
|
+
live.set(grey(`Quitting ${names}…`))
|
|
289
|
+
for (const app of open) {
|
|
290
|
+
const id = earlier.includes(app) ? EARLIER_APP_ID : manifest.bundleId
|
|
291
|
+
void run('/usr/bin/osascript', ['-e', `tell application id ${JSON.stringify(id)} to quit`]).catch(() => undefined)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (input) {
|
|
295
|
+
input.setRawMode(true)
|
|
296
|
+
input.resume()
|
|
297
|
+
input.on('data', key)
|
|
328
298
|
}
|
|
299
|
+
live.set(grey(`Waiting for ${names} to quit…`))
|
|
300
|
+
try {
|
|
301
|
+
while (open.length) {
|
|
302
|
+
await new Promise((resolve) => setTimeout(resolve, 700))
|
|
303
|
+
open = await runningFrom([...installed, ...earlier])
|
|
304
|
+
}
|
|
305
|
+
} finally {
|
|
306
|
+
if (input) {
|
|
307
|
+
input.off('data', key)
|
|
308
|
+
input.setRawMode(false)
|
|
309
|
+
input.pause()
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
live.done(grey(`${names} quit`))
|
|
329
313
|
live.set(grey(`${current ? 'Updating' : 'Installing'} in ${place}…`))
|
|
330
314
|
}
|
|
331
315
|
|
|
332
|
-
const replaced = []
|
|
333
316
|
try {
|
|
334
317
|
// Set the old app aside in the staging folder; it is removed with that folder only once the
|
|
335
|
-
// new app is in place, as any Mac updater does.
|
|
336
|
-
// and sign-in live outside it.
|
|
337
|
-
|
|
338
|
-
const aside = join(stage, `previous-${replaced.length}.app`)
|
|
339
|
-
await rename(previous, aside)
|
|
340
|
-
replaced.push([previous, aside])
|
|
341
|
-
}
|
|
342
|
-
if (await stat(destination).catch(() => null)) throw Error(`${tilde(destination)} already exists and isn’t ${manifest.name}.`)
|
|
343
|
-
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`))
|
|
344
321
|
} catch (error) {
|
|
345
|
-
|
|
346
|
-
for (const [original, moved] of replaced.reverse()) await rename(moved, original).catch(() => undefined)
|
|
347
|
-
if (error.reported) throw error
|
|
348
|
-
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.`)
|
|
349
323
|
}
|
|
350
324
|
live.done(grey(`${current ? 'Updated' : 'Installed'} in ${place}`))
|
|
351
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:
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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.
|
|
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": {
|