@webprodigies/morphite 0.2.2 → 0.2.4
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/README.md +4 -2
- package/app-update.mjs +281 -0
- package/bin/morphite.mjs +157 -129
- package/manifest.mjs +39 -6
- package/package.json +4 -2
- package/windows.mjs +154 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Morphite
|
|
2
2
|
|
|
3
|
-
Install or update Morphite on your Mac:
|
|
3
|
+
Install or update Morphite on your Mac or Windows PC:
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npx @webprodigies/morphite
|
|
@@ -8,4 +8,6 @@ npx @webprodigies/morphite
|
|
|
8
8
|
|
|
9
9
|
The installer checks your Mac, downloads the latest release for Apple silicon or Intel, verifies its SHA-256 checksum, puts the app in Applications and opens it. Run the same command again to update. Your projects and settings live outside the app and are never changed by the installer.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
On Windows it installs Morphite for you alone in `%LOCALAPPDATA%\Programs` (no administrator needed), adds it to the Start menu and opens it.
|
|
12
|
+
|
|
13
|
+
Requires macOS 12 or later, or Windows 10 (version 1803) or later, and Node.js 18 or later. Linux is on the way.
|
package/app-update.mjs
ADDED
|
@@ -0,0 +1,281 @@
|
|
|
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; on Windows the swap helper is WINDOWS_SWAP_HELPER_SCRIPT and the unpacking and
|
|
13
|
+
* checks are windows.mjs's (WINDOWS_INSTALL); manifest.mjs owns the release format. Plain JavaScript: the installer has no
|
|
14
|
+
* build step or dependencies.
|
|
15
|
+
*/
|
|
16
|
+
import { createHash } from 'node:crypto'
|
|
17
|
+
import { createReadStream, createWriteStream } from 'node:fs'
|
|
18
|
+
import { execFile } from 'node:child_process'
|
|
19
|
+
import { rename, stat, readFile } from 'node:fs/promises'
|
|
20
|
+
import { join } from 'node:path'
|
|
21
|
+
import { once } from 'node:events'
|
|
22
|
+
import { promisify } from 'node:util'
|
|
23
|
+
import { INSTALL_MANIFEST_FILE, latestManifestUrl, parseInstallManifest, releaseFileUrl } from './manifest.mjs'
|
|
24
|
+
|
|
25
|
+
const run = promisify(execFile)
|
|
26
|
+
/** Folders Morphite creates beside an installed app while updating it; only these are cleaned up. */
|
|
27
|
+
export const STAGE_PREFIX = '.morphite-update-'
|
|
28
|
+
/** The file inside such a folder that proves Morphite made it. */
|
|
29
|
+
export const STAGE_MARK = 'morphite-update.json'
|
|
30
|
+
|
|
31
|
+
export async function fetchWithRetry(url, { attempts = 3, userAgent = '@webprodigies/morphite installer', signal } = {}) {
|
|
32
|
+
let failure
|
|
33
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
34
|
+
try {
|
|
35
|
+
const response = await fetch(url, { headers: { 'user-agent': userAgent }, redirect: 'follow', signal })
|
|
36
|
+
if (response.status === 404) return response
|
|
37
|
+
if (!response.ok) throw Error(`HTTP ${response.status}`)
|
|
38
|
+
return response
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (signal?.aborted) throw error
|
|
41
|
+
failure = error
|
|
42
|
+
await new Promise((resolve) => setTimeout(resolve, 600 * attempt))
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
throw Error(`Could not reach GitHub (${failure?.message ?? 'network error'}). Check your connection and run the command again.`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The checked manifest of the newest release, or of the release in the local folder `from`. */
|
|
49
|
+
export async function readManifest(from, options) {
|
|
50
|
+
if (from) return parseInstallManifest(JSON.parse(await readFile(join(from, INSTALL_MANIFEST_FILE), 'utf8')))
|
|
51
|
+
const response = await fetchWithRetry(latestManifestUrl(), options)
|
|
52
|
+
if (response.status === 404) throw Error('No Morphite release is available yet. Try again soon.')
|
|
53
|
+
return parseInstallManifest(await response.json())
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Downloads (or copies from `from`) one archive to `destination`, measuring it as it arrives. */
|
|
57
|
+
export async function downloadArchive(manifest, archive, destination, { from, progress, signal, ...options } = {}) {
|
|
58
|
+
const hash = createHash('sha256')
|
|
59
|
+
let received = 0
|
|
60
|
+
const report = () => progress?.(received / archive.size, received)
|
|
61
|
+
const output = createWriteStream(destination, { mode: 0o600 })
|
|
62
|
+
const write = async (chunk) => {
|
|
63
|
+
hash.update(chunk)
|
|
64
|
+
received += chunk.length
|
|
65
|
+
if (received > archive.size) throw Error('The download is larger than the release says. Nothing was installed.')
|
|
66
|
+
if (!output.write(chunk)) await once(output, 'drain')
|
|
67
|
+
}
|
|
68
|
+
const timer = progress ? setInterval(report, 120) : undefined
|
|
69
|
+
try {
|
|
70
|
+
if (from) {
|
|
71
|
+
for await (const chunk of createReadStream(join(from, archive.file), { signal })) await write(chunk)
|
|
72
|
+
} else {
|
|
73
|
+
const response = await fetchWithRetry(releaseFileUrl(manifest.tag, archive.file), { ...options, signal })
|
|
74
|
+
if (!response.ok || !response.body) throw Error(`The release file is unavailable (HTTP ${response.status}).`)
|
|
75
|
+
for await (const chunk of response.body) await write(chunk)
|
|
76
|
+
}
|
|
77
|
+
output.end()
|
|
78
|
+
await once(output, 'finish')
|
|
79
|
+
} finally {
|
|
80
|
+
clearInterval(timer)
|
|
81
|
+
output.destroy()
|
|
82
|
+
}
|
|
83
|
+
report()
|
|
84
|
+
return { path: destination, sha256: hash.digest('hex'), size: received }
|
|
85
|
+
}
|
|
86
|
+
export const archiveMatches = (file, archive) => file.size === archive.size && file.sha256 === archive.sha256
|
|
87
|
+
|
|
88
|
+
/** Unpacks a release zip (made with ditto --keepParent) into `folder`. */
|
|
89
|
+
export async function unpackApp(zip, folder) {
|
|
90
|
+
await run('/usr/bin/ditto', ['-x', '-k', zip, folder])
|
|
91
|
+
}
|
|
92
|
+
const infoValue = (app, key) =>
|
|
93
|
+
run('/usr/bin/plutil', ['-extract', key, 'raw', '-o', '-', join(app, 'Contents/Info.plist')]).then(
|
|
94
|
+
(result) => result.stdout.trim(),
|
|
95
|
+
() => null
|
|
96
|
+
)
|
|
97
|
+
export class AppCheckError extends Error {
|
|
98
|
+
constructor(reason, message) {
|
|
99
|
+
super(message)
|
|
100
|
+
this.reason = reason
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Throws an AppCheckError unless `app` is the expected Morphite build with an intact signature. */
|
|
104
|
+
export async function verifyApp(app, { bundleId, version }) {
|
|
105
|
+
if ((await infoValue(app, 'CFBundleIdentifier')) !== bundleId)
|
|
106
|
+
throw new AppCheckError('bundle-id', 'The downloaded app isn’t the expected Morphite build.')
|
|
107
|
+
if ((await infoValue(app, 'CFBundleShortVersionString')) !== version)
|
|
108
|
+
throw new AppCheckError('version', 'The downloaded app isn’t the version the release names.')
|
|
109
|
+
await run('/usr/bin/codesign', ['--verify', '--deep', '--strict', app]).catch(() => {
|
|
110
|
+
throw new AppCheckError('signature', 'The downloaded app failed its signature check.')
|
|
111
|
+
})
|
|
112
|
+
}
|
|
113
|
+
/** A file fetched by Morphite carries no download mark; one is cleared defensively all the same. */
|
|
114
|
+
export async function clearQuarantine(app) {
|
|
115
|
+
await run('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', app]).catch(() => undefined)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** APP_SWAP: sets every installed copy aside (aside(index)), renames `candidate` to `destination`
|
|
119
|
+
* and returns what was set aside; on any failure puts every copy back and throws. */
|
|
120
|
+
export async function swapApps(installed, candidate, destination, aside) {
|
|
121
|
+
const replaced = []
|
|
122
|
+
try {
|
|
123
|
+
for (const previous of installed) {
|
|
124
|
+
const moved = aside(replaced.length)
|
|
125
|
+
await rename(previous, moved)
|
|
126
|
+
replaced.push([previous, moved])
|
|
127
|
+
}
|
|
128
|
+
if (await stat(destination).catch(() => null)) throw Error(`${destination} already exists and isn’t Morphite.`)
|
|
129
|
+
await rename(candidate, destination)
|
|
130
|
+
return replaced
|
|
131
|
+
} catch (error) {
|
|
132
|
+
for (const [original, moved] of replaced.reverse()) await rename(moved, original).catch(() => undefined)
|
|
133
|
+
throw error
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** APP_SWAP for the app's own Update, run detached by /bin/sh after Morphite starts quitting:
|
|
138
|
+
* sh -c SWAP_HELPER_SCRIPT morphite-update PID CURRENT STAGED ASIDE DESTINATION REOPEN ELEVATE
|
|
139
|
+
* Waits (up to 10 minutes) for PID to exit, sets CURRENT aside to ASIDE, renames STAGED to
|
|
140
|
+
* DESTINATION, puts CURRENT back on any failure, clears the download mark and, with REOPEN=1,
|
|
141
|
+
* opens whichever app is in place. With ELEVATE=1 only the swap runs through macOS's administrator
|
|
142
|
+
* prompt: the fixed AppleScript passes this same script (MORPHITE_SWAP_SCRIPT) and the paths as
|
|
143
|
+
* arguments, quoted by AppleScript's `quoted form of`. Exit codes: 0 updated, 3 Morphite did not
|
|
144
|
+
* quit, 4 nothing staged, 5 aside taken, 6 could not set aside, 7 rolled back, 8 not authorized. */
|
|
145
|
+
export const SWAP_HELPER_SCRIPT = String.raw`
|
|
146
|
+
set -u
|
|
147
|
+
say() { printf '%s %s\n' "$(/bin/date '+%Y-%m-%dT%H:%M:%S')" "$1"; }
|
|
148
|
+
swap() {
|
|
149
|
+
current=$1 staged=$2 aside=$3 destination=$4
|
|
150
|
+
if [ ! -d "$staged" ]; then say "The prepared update is missing. Nothing was changed."; return 4; fi
|
|
151
|
+
if [ -e "$aside" ]; then say "The previous app's place is taken. Nothing was changed."; return 5; fi
|
|
152
|
+
if ! /bin/mv "$current" "$aside"; then say "The installed app couldn't be set aside. Nothing was changed."; return 6; fi
|
|
153
|
+
if [ -e "$destination" ] || ! /bin/mv "$staged" "$destination"; then
|
|
154
|
+
/bin/mv "$aside" "$current" || say "The previous app is at $aside."
|
|
155
|
+
say "The update couldn't be moved into place; the previous app was put back."
|
|
156
|
+
return 7
|
|
157
|
+
fi
|
|
158
|
+
/usr/bin/xattr -dr com.apple.quarantine "$destination" 2>/dev/null
|
|
159
|
+
return 0
|
|
160
|
+
}
|
|
161
|
+
if [ "$1" = swap ]; then shift; swap "$@"; exit $?; fi
|
|
162
|
+
pid=$1 current=$2 staged=$3 aside=$4 destination=$5 reopen=$6 elevate=$7
|
|
163
|
+
waited=0
|
|
164
|
+
while /bin/kill -0 "$pid" 2>/dev/null; do
|
|
165
|
+
if [ "$waited" -ge 1200 ]; then say "Morphite didn't quit. Nothing was changed."; exit 3; fi
|
|
166
|
+
/bin/sleep 0.5
|
|
167
|
+
waited=$((waited + 1))
|
|
168
|
+
done
|
|
169
|
+
if [ "$elevate" = 1 ]; then
|
|
170
|
+
/usr/bin/osascript \
|
|
171
|
+
-e 'on run argv' \
|
|
172
|
+
-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' \
|
|
173
|
+
-e 'end run' "$MORPHITE_SWAP_SCRIPT" "$current" "$staged" "$aside" "$destination"
|
|
174
|
+
result=$?
|
|
175
|
+
[ "$result" -eq 0 ] || { say "The update wasn't authorized or couldn't be moved into place. The previous app is in place."; result=8; }
|
|
176
|
+
else
|
|
177
|
+
swap "$current" "$staged" "$aside" "$destination"
|
|
178
|
+
result=$?
|
|
179
|
+
fi
|
|
180
|
+
if [ "$result" -eq 0 ]; then say "Updated $destination."; opened=$destination; else opened=$current; fi
|
|
181
|
+
if [ "$reopen" = 1 ]; then /usr/bin/open "$opened" || say "Morphite couldn't be reopened."; fi
|
|
182
|
+
exit "$result"
|
|
183
|
+
`
|
|
184
|
+
|
|
185
|
+
/** APP_SWAP for the app's own Update on Windows: the same steps as SWAP_HELPER_SCRIPT, as a PowerShell
|
|
186
|
+
* script started detached while Morphite quits (a running exe cannot be replaced). Its inputs are
|
|
187
|
+
* environment variables, never put into its text: MORPHITE_SWAP_PID, _CURRENT, _STAGED, _ASIDE,
|
|
188
|
+
* _DESTINATION, _REOPEN and _LOG (the file its lines are added to). It waits (up to 10 minutes) for PID and then for every program still
|
|
189
|
+
* running from CURRENT's folder (Morphite's helpers), sets CURRENT aside to ASIDE (retrying while
|
|
190
|
+
* Windows briefly holds a file), renames STAGED to DESTINATION, puts CURRENT back on any failure
|
|
191
|
+
* and, with REOPEN=1, starts <name>.exe in whichever folder is in place. Same exit codes; there is
|
|
192
|
+
* no administrator step: a Windows install lives in the person's own Programs folder. */
|
|
193
|
+
export const WINDOWS_SWAP_HELPER_SCRIPT = String.raw`
|
|
194
|
+
$ErrorActionPreference = 'Stop'
|
|
195
|
+
$ProgressPreference = 'SilentlyContinue'
|
|
196
|
+
Set-Location -LiteralPath $env:SystemRoot
|
|
197
|
+
function Say($text) {
|
|
198
|
+
$line = '{0} {1}' -f (Get-Date -Format 's'), $text
|
|
199
|
+
# Straight to the console, never the pipeline: Swap's return value is its exit code alone.
|
|
200
|
+
if ($env:MORPHITE_SWAP_LOG) { Add-Content -LiteralPath $env:MORPHITE_SWAP_LOG -Value $line } else { [Console]::Out.WriteLine($line) }
|
|
201
|
+
}
|
|
202
|
+
$morphite = [int]$env:MORPHITE_SWAP_PID
|
|
203
|
+
$current = $env:MORPHITE_SWAP_CURRENT; $staged = $env:MORPHITE_SWAP_STAGED; $aside = $env:MORPHITE_SWAP_ASIDE
|
|
204
|
+
$destination = $env:MORPHITE_SWAP_DESTINATION; $reopen = $env:MORPHITE_SWAP_REOPEN
|
|
205
|
+
$inside = $current.TrimEnd('\') + '\'
|
|
206
|
+
$waited = 0
|
|
207
|
+
while ($true) {
|
|
208
|
+
$open = @(Get-Process -Id $morphite -ErrorAction SilentlyContinue)
|
|
209
|
+
if (-not $open.Count) {
|
|
210
|
+
$open = @(Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath -and $_.ExecutablePath.StartsWith($inside, [StringComparison]::OrdinalIgnoreCase) })
|
|
211
|
+
if (-not $open.Count) { break }
|
|
212
|
+
}
|
|
213
|
+
if ($waited -ge 1200) { Say "Morphite didn't quit. Nothing was changed."; exit 3 }
|
|
214
|
+
Start-Sleep -Milliseconds 500
|
|
215
|
+
$waited++
|
|
216
|
+
}
|
|
217
|
+
function Swap {
|
|
218
|
+
if (-not (Test-Path -LiteralPath $staged -PathType Container)) { Say 'The prepared update is missing. Nothing was changed.'; return 4 }
|
|
219
|
+
if (Test-Path -LiteralPath $aside) { Say "The previous app's place is taken. Nothing was changed."; return 5 }
|
|
220
|
+
$moved = $false
|
|
221
|
+
for ($try = 0; $try -lt 20 -and -not $moved; $try++) {
|
|
222
|
+
try { [IO.Directory]::Move($current, $aside); $moved = $true } catch { Start-Sleep -Milliseconds 500 }
|
|
223
|
+
}
|
|
224
|
+
if (-not $moved) { Say "The installed app couldn't be set aside. Nothing was changed."; return 6 }
|
|
225
|
+
try {
|
|
226
|
+
if (Test-Path -LiteralPath $destination) { throw 'taken' }
|
|
227
|
+
[IO.Directory]::Move($staged, $destination)
|
|
228
|
+
return 0
|
|
229
|
+
} catch {
|
|
230
|
+
try { [IO.Directory]::Move($aside, $current) } catch { Say "The previous app is at $aside." }
|
|
231
|
+
Say "The update couldn't be moved into place; the previous app was put back."
|
|
232
|
+
return 7
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
$result = Swap
|
|
236
|
+
if ($result -eq 0) { Say "Updated $destination."; $opened = $destination } else { $opened = $current }
|
|
237
|
+
if ($reopen -eq '1') {
|
|
238
|
+
$program = Join-Path $opened ((Split-Path -Leaf $destination) + '.exe')
|
|
239
|
+
try { Start-Process -FilePath $program -WorkingDirectory $opened } catch { Say "Morphite couldn't be reopened." }
|
|
240
|
+
}
|
|
241
|
+
exit $result
|
|
242
|
+
`
|
|
243
|
+
/** How the swap helper starts for `args` (PID CURRENT STAGED ASIDE DESTINATION REOPEN ELEVATE): the
|
|
244
|
+
* /bin/sh script on macOS, spawned detached with its output in the log. On Windows a short hidden
|
|
245
|
+
* PowerShell launcher that starts the helper with Start-Process and exits: a program Node starts on
|
|
246
|
+
* Windows ends with its parent's job (and PowerShell cannot run detached, without a console), while
|
|
247
|
+
* a program that one starts breaks away and outlives Morphite. The helper writes `log` itself. */
|
|
248
|
+
export function swapHelperCommand(platform, args, env = process.env, log = '') {
|
|
249
|
+
if (platform !== 'win32')
|
|
250
|
+
return {
|
|
251
|
+
file: '/bin/sh',
|
|
252
|
+
args: ['-c', SWAP_HELPER_SCRIPT, 'morphite-update', ...args],
|
|
253
|
+
env: { PATH: '/usr/bin:/bin:/usr/sbin:/sbin', MORPHITE_SWAP_SCRIPT: SWAP_HELPER_SCRIPT }
|
|
254
|
+
}
|
|
255
|
+
const [pid, current, staged, aside, destination, reopen] = args
|
|
256
|
+
const variable = (name) => env[Object.keys(env).find((key) => key.toUpperCase() === name) ?? name]
|
|
257
|
+
const system32 = join(variable('SYSTEMROOT') || variable('WINDIR') || 'C:\\Windows', 'System32')
|
|
258
|
+
const powershell = join(system32, 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
|
259
|
+
const encoded = (script) => Buffer.from(script, 'utf16le').toString('base64')
|
|
260
|
+
// Working from the system folder: a program working inside Morphite's folder keeps it from moving.
|
|
261
|
+
const launcher = "Start-Process -WindowStyle Hidden -WorkingDirectory $env:MORPHITE_SWAP_SYSTEM -FilePath $env:MORPHITE_SWAP_POWERSHELL -ArgumentList '-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-EncodedCommand',$env:MORPHITE_SWAP_HELPER"
|
|
262
|
+
return {
|
|
263
|
+
file: powershell,
|
|
264
|
+
cwd: system32,
|
|
265
|
+
args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded(launcher)],
|
|
266
|
+
// The reopened Morphite starts with Morphite's own environment.
|
|
267
|
+
env: {
|
|
268
|
+
...env,
|
|
269
|
+
MORPHITE_SWAP_POWERSHELL: powershell,
|
|
270
|
+
MORPHITE_SWAP_SYSTEM: system32,
|
|
271
|
+
MORPHITE_SWAP_HELPER: encoded(WINDOWS_SWAP_HELPER_SCRIPT),
|
|
272
|
+
MORPHITE_SWAP_PID: pid,
|
|
273
|
+
MORPHITE_SWAP_CURRENT: current,
|
|
274
|
+
MORPHITE_SWAP_STAGED: staged,
|
|
275
|
+
MORPHITE_SWAP_ASIDE: aside,
|
|
276
|
+
MORPHITE_SWAP_DESTINATION: destination,
|
|
277
|
+
MORPHITE_SWAP_REOPEN: reopen,
|
|
278
|
+
MORPHITE_SWAP_LOG: log
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
package/bin/morphite.mjs
CHANGED
|
@@ -1,25 +1,37 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/** SOURCE OF TRUTH KEYWORDS: terminalInstaller, installMorphite, INSTALLER_STEPS
|
|
3
|
-
* WHAT: `npx @webprodigies/morphite` installs or updates
|
|
4
|
-
* latest release manifest, downloads the zip for this
|
|
5
|
-
* replaces the app
|
|
3
|
+
* WHAT: `npx @webprodigies/morphite` installs or updates Morphite on a Mac or a Windows PC: checks
|
|
4
|
+
* the computer, reads the latest release manifest, downloads the zip for this computer with
|
|
5
|
+
* progress, checks its SHA-256, replaces the app and opens it. It prints a few quiet lines
|
|
6
|
+
* (INSTALLER_LOOK). Each OS supplies its own steps (macSteps below, windowsSteps from
|
|
7
|
+
* ../windows.mjs); the flow between them is shared.
|
|
6
8
|
* WHY: Until Morphite has a Developer ID, a browser download is stopped by Gatekeeper and people must
|
|
7
9
|
* allow it in Privacy & Security. A file fetched by this program is not marked as downloaded, so the
|
|
8
10
|
* 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
|
-
*
|
|
11
|
+
* WHERE: ../manifest.mjs owns the release file format and ../app-update.mjs the download, checks
|
|
12
|
+
* and swap (appUpdateSteps), shared with the desktop app's own Update, which offers this command
|
|
13
|
+
* only when it cannot prepare an update itself. scripts/release-publish.ts publishes releases.
|
|
14
|
+
* User data lives outside the app and is never touched here.
|
|
12
15
|
*/
|
|
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'
|
|
16
|
+
import { access, constants, lstat, mkdir, mkdtemp, readdir, rm } from 'node:fs/promises'
|
|
16
17
|
import { execFile } from 'node:child_process'
|
|
17
18
|
import { homedir, platform, tmpdir, arch as nodeArch } from 'node:os'
|
|
18
19
|
import { join, basename, dirname } from 'node:path'
|
|
19
|
-
import { once } from 'node:events'
|
|
20
20
|
import { createInterface } from 'node:readline'
|
|
21
21
|
import { promisify } from 'node:util'
|
|
22
|
-
import {
|
|
22
|
+
import { AppCheckError, archiveMatches, clearQuarantine, downloadArchive, readManifest, swapApps, unpackApp, verifyApp } from '../app-update.mjs'
|
|
23
|
+
import { archiveTarget } from '../manifest.mjs'
|
|
24
|
+
import {
|
|
25
|
+
openWindowsApp,
|
|
26
|
+
runningWindowsApps,
|
|
27
|
+
supportedWindows,
|
|
28
|
+
unpackWindowsApp,
|
|
29
|
+
verifyWindowsApp,
|
|
30
|
+
windowsAppInfo,
|
|
31
|
+
windowsPlaces,
|
|
32
|
+
windowsVersion,
|
|
33
|
+
writeStartMenuShortcut
|
|
34
|
+
} from '../windows.mjs'
|
|
23
35
|
|
|
24
36
|
const run = promisify(execFile)
|
|
25
37
|
const args = process.argv.slice(2)
|
|
@@ -134,70 +146,108 @@ async function moveToTrash(path) {
|
|
|
134
146
|
() => false
|
|
135
147
|
)
|
|
136
148
|
}
|
|
137
|
-
|
|
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
|
-
// ── Install ─────────────────────────────────────────────────────────────────
|
|
192
149
|
async function writable(folder) {
|
|
193
150
|
return access(folder, constants.W_OK).then(
|
|
194
151
|
() => true,
|
|
195
152
|
() => false
|
|
196
153
|
)
|
|
197
154
|
}
|
|
155
|
+
const applications = '/Applications'
|
|
156
|
+
const personal = join(homedir(), 'Applications')
|
|
157
|
+
// Local release tests use a throwaway Applications folder. The installed app's identity and
|
|
158
|
+
// destination cannot be redirected by a published installer or a production release.
|
|
159
|
+
const testApplications = (manifest) =>
|
|
160
|
+
process.env.NODE_ENV === 'test' && option('--from') && manifest.bundleId === 'com.morphite.local'
|
|
161
|
+
? process.env.MORPHITE_TEST_APPLICATIONS
|
|
162
|
+
: undefined
|
|
163
|
+
/** INSTALLER_STEPS on macOS: the app bundle in Applications (or ~/Applications), opened with open. */
|
|
164
|
+
const macSteps = {
|
|
165
|
+
computerName: 'Mac',
|
|
166
|
+
async computer() {
|
|
167
|
+
const macOS = (await run('/usr/bin/sw_vers', ['-productVersion'])).stdout.trim()
|
|
168
|
+
const cpu = await macCpu()
|
|
169
|
+
const chip = cpu === 'arm64' ? 'Apple silicon' : 'Intel'
|
|
170
|
+
return { cpu, chip, line: `macOS ${macOS} · ${chip}`, macOS }
|
|
171
|
+
},
|
|
172
|
+
unsupported: (manifest, computer) =>
|
|
173
|
+
versionAtLeast(computer.macOS, manifest.minimumMacOS)
|
|
174
|
+
? null
|
|
175
|
+
: [`${manifest.name} needs macOS ${manifest.minimumMacOS} or later.`, `This Mac has macOS ${computer.macOS}.`],
|
|
176
|
+
async installed(manifest) {
|
|
177
|
+
const folders = testApplications(manifest) ? [testApplications(manifest)] : [applications, personal]
|
|
178
|
+
const installed = await appsWithId(folders, manifest.bundleId)
|
|
179
|
+
const current = installed.length ? await versionOf(installed[0]) : null
|
|
180
|
+
return { installed, current, earlier: testApplications(manifest) ? [] : await appsWithId(folders, EARLIER_APP_ID) }
|
|
181
|
+
},
|
|
182
|
+
placeOf: (app) => (dirname(app) === '/Applications' ? 'Applications' : tilde(dirname(app))),
|
|
183
|
+
async target(manifest) {
|
|
184
|
+
const target = testApplications(manifest) || ((await writable(applications)) ? applications : personal)
|
|
185
|
+
return { target, place: target === applications ? 'Applications' : tilde(target), destination: join(target, `${manifest.name}.app`) }
|
|
186
|
+
},
|
|
187
|
+
async unpack(zip, stage, manifest) {
|
|
188
|
+
await unpackApp(zip, stage)
|
|
189
|
+
return join(stage, `${manifest.name}.app`)
|
|
190
|
+
},
|
|
191
|
+
verify: (candidate, manifest) => verifyApp(candidate, { bundleId: manifest.bundleId, version: manifest.version }),
|
|
192
|
+
prepare: (candidate) => clearQuarantine(candidate),
|
|
193
|
+
running: (apps, manifest) => testApplications(manifest)
|
|
194
|
+
? Promise.resolve([])
|
|
195
|
+
: runningFrom(apps),
|
|
196
|
+
nameOf: (app) => basename(app, '.app'),
|
|
197
|
+
// No keyboard shortcut is suggested: ⌘Q here would quit Terminal.
|
|
198
|
+
quitHint: (names, work) => `Quit it from its menu bar (${names} → Quit), and this ${work} continues on its own.`,
|
|
199
|
+
quit: (id) => void run('/usr/bin/osascript', ['-e', `tell application id ${JSON.stringify(id)} to quit`]).catch(() => undefined),
|
|
200
|
+
aside: (stage, index) => join(stage, `previous-${index}.app`),
|
|
201
|
+
afterInstall: async () => undefined,
|
|
202
|
+
open: (app) => run('/usr/bin/open', [app]).then(() => true, () => false),
|
|
203
|
+
ready: (opened, place) => (opened ? `It’s open now, and in ${place} and Spotlight.` : `Open it from ${place} or Spotlight.`)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Windows ─────────────────────────────────────────────────────────────────
|
|
207
|
+
/** INSTALLER_STEPS on Windows (WINDOWS_INSTALL): %LOCALAPPDATA%\Programs\<name> and a Start-menu
|
|
208
|
+
* shortcut; no administrator, nothing else registered. */
|
|
209
|
+
const windowsSteps = {
|
|
210
|
+
computerName: 'PC',
|
|
211
|
+
async computer() {
|
|
212
|
+
const cpu = nodeArch()
|
|
213
|
+
return { cpu, chip: `Windows ${cpu}`, line: `Windows ${windowsVersion()} · ${cpu}` }
|
|
214
|
+
},
|
|
215
|
+
unsupported: () =>
|
|
216
|
+
supportedWindows() ? null : ['Morphite needs Windows 10 (version 1803) or later.', `This PC has Windows ${windowsVersion()}.`],
|
|
217
|
+
async installed(manifest) {
|
|
218
|
+
const folder = join(windowsPlaces().programs, manifest.name)
|
|
219
|
+
const info = await windowsAppInfo(folder, manifest.name)
|
|
220
|
+
return { installed: info?.name === manifest.name ? [folder] : [], current: info?.name === manifest.name ? info.version : null, earlier: [] }
|
|
221
|
+
},
|
|
222
|
+
placeOf: (app) => tilde(dirname(app)),
|
|
223
|
+
async target(manifest) {
|
|
224
|
+
const target = windowsPlaces().programs
|
|
225
|
+
return { target, place: tilde(target), destination: join(target, manifest.name) }
|
|
226
|
+
},
|
|
227
|
+
unpack: (zip, stage) => unpackWindowsApp(zip, stage),
|
|
228
|
+
verify: (candidate, manifest) => verifyWindowsApp(candidate, manifest, AppCheckError),
|
|
229
|
+
prepare: async () => undefined,
|
|
230
|
+
running: runningWindowsApps,
|
|
231
|
+
nameOf: (app) => basename(app),
|
|
232
|
+
// Closing Morphite's window keeps it in the tray, so its tray icon is where it quits.
|
|
233
|
+
quitHint: (names, work) => `Quit it from its tray icon (${names} → Quit Morphite), and this ${work} continues on its own.`,
|
|
234
|
+
quit: null,
|
|
235
|
+
aside: (stage, index) => join(stage, `previous-${index}`),
|
|
236
|
+
async afterInstall(destination, manifest) {
|
|
237
|
+
await writeStartMenuShortcut(windowsPlaces(), destination, manifest).then(
|
|
238
|
+
() => say(grey('Added to the Start menu')),
|
|
239
|
+
(error) => say(grey(`Couldn’t add ${manifest.name} to the Start menu (${String(error?.message ?? error).split('\n')[0]})`))
|
|
240
|
+
)
|
|
241
|
+
},
|
|
242
|
+
open: (app, manifest) => openWindowsApp(app, manifest.name),
|
|
243
|
+
ready: (opened) => (opened ? 'It’s open now, and in the Start menu.' : 'Open it from the Start menu.')
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ── Install ─────────────────────────────────────────────────────────────────
|
|
198
247
|
async function main() {
|
|
248
|
+
const host = platform() === 'darwin' ? macSteps : platform() === 'win32' ? windowsSteps : null
|
|
199
249
|
if (flag('--help') || flag('-h')) {
|
|
200
|
-
say('', bold('npx ' + PACKAGE), grey(
|
|
250
|
+
say('', bold('npx ' + PACKAGE), grey(`Installs or updates Morphite on this ${host?.computerName ?? 'computer'}.`), '',
|
|
201
251
|
`--yes ${grey('Answer yes to every question')}`,
|
|
202
252
|
`--no-open ${grey('Do not open Morphite afterwards')}`,
|
|
203
253
|
`--reinstall ${grey('Install again even when this version is installed')}`,
|
|
@@ -205,18 +255,16 @@ async function main() {
|
|
|
205
255
|
return
|
|
206
256
|
}
|
|
207
257
|
const from = option('--from')
|
|
208
|
-
if (
|
|
258
|
+
if (!host) {
|
|
209
259
|
header('Morphite')
|
|
210
|
-
note('Morphite runs on macOS for now.', '
|
|
260
|
+
note('Morphite runs on macOS and Windows for now.', 'Linux is on the way. Follow along at https://morphite.build')
|
|
211
261
|
say('')
|
|
212
262
|
process.exitCode = 1
|
|
213
263
|
return
|
|
214
264
|
}
|
|
215
265
|
|
|
216
266
|
live.set(grey('Checking for the latest release…'))
|
|
217
|
-
const
|
|
218
|
-
const cpu = await macCpu()
|
|
219
|
-
const chip = cpu === 'arm64' ? 'Apple silicon' : 'Intel'
|
|
267
|
+
const computer = await host.computer()
|
|
220
268
|
// The header names the app and version from the release, so read it before printing anything else.
|
|
221
269
|
let manifest
|
|
222
270
|
try {
|
|
@@ -231,30 +279,27 @@ async function main() {
|
|
|
231
279
|
}
|
|
232
280
|
live.clear()
|
|
233
281
|
header(manifest.name, manifest.version)
|
|
234
|
-
say(grey(
|
|
235
|
-
|
|
236
|
-
|
|
282
|
+
say(grey(computer.line))
|
|
283
|
+
const unsupported = host.unsupported(manifest, computer)
|
|
284
|
+
if (unsupported) {
|
|
285
|
+
note(...unsupported)
|
|
237
286
|
say('')
|
|
238
287
|
process.exitCode = 1
|
|
239
288
|
return
|
|
240
289
|
}
|
|
241
|
-
const archive = manifest.archives[cpu]
|
|
290
|
+
const archive = manifest.archives[archiveTarget(platform(), computer.cpu)]
|
|
242
291
|
if (!archive) {
|
|
243
|
-
note(`This release has no ${chip} build yet.`, 'Nothing was changed.')
|
|
292
|
+
note(`This release has no ${computer.chip} build yet.`, 'Nothing was changed.')
|
|
244
293
|
say('')
|
|
245
294
|
process.exitCode = 1
|
|
246
295
|
return
|
|
247
296
|
}
|
|
248
|
-
const
|
|
249
|
-
const personal = join(homedir(), 'Applications')
|
|
250
|
-
const installed = await appsWithId([applications, personal], manifest.bundleId)
|
|
251
|
-
const current = installed.length ? await versionOf(installed[0]) : null
|
|
252
|
-
const earlier = await appsWithId([applications, personal], EARLIER_APP_ID)
|
|
297
|
+
const { installed, current, earlier } = await host.installed(manifest)
|
|
253
298
|
|
|
254
299
|
// Already current (and nothing earlier to retire): say so and open it, without downloading again.
|
|
255
300
|
if (current === manifest.version && !earlier.length && !flag('--reinstall')) {
|
|
256
|
-
const opened = !flag('--no-open') && (await
|
|
257
|
-
note('You’re up to date.', `${manifest.name} ${manifest.version} is installed in ${
|
|
301
|
+
const opened = !flag('--no-open') && (await host.open(installed[0], manifest))
|
|
302
|
+
note('You’re up to date.', `${manifest.name} ${manifest.version} is installed in ${host.placeOf(installed[0])}.${opened ? ' It’s open now.' : ''}`)
|
|
258
303
|
say('')
|
|
259
304
|
return
|
|
260
305
|
}
|
|
@@ -277,9 +322,7 @@ async function main() {
|
|
|
277
322
|
say('')
|
|
278
323
|
}
|
|
279
324
|
|
|
280
|
-
const target =
|
|
281
|
-
const place = target === applications ? 'Applications' : tilde(target)
|
|
282
|
-
const destination = join(target, `${manifest.name}.app`)
|
|
325
|
+
const { target, place, destination } = await host.target(manifest)
|
|
283
326
|
const work = await mkdtemp(join(tmpdir(), 'morphite-installer-'))
|
|
284
327
|
const cleanup = () => rm(work, { recursive: true, force: true }).catch(() => undefined)
|
|
285
328
|
const stopped = () => {
|
|
@@ -298,11 +341,15 @@ async function main() {
|
|
|
298
341
|
try {
|
|
299
342
|
let file
|
|
300
343
|
try {
|
|
301
|
-
file = await
|
|
344
|
+
file = await downloadArchive(manifest, archive, join(work, archive.file), {
|
|
345
|
+
from,
|
|
346
|
+
userAgent: `${PACKAGE} installer`,
|
|
347
|
+
progress: (ratio, received) => live.set(progressLine(ratio, `${megabytes(received)} of ${megabytes(archive.size)} MB`))
|
|
348
|
+
})
|
|
302
349
|
} catch (error) {
|
|
303
350
|
throw stop('The download stopped.', `${error.message} Nothing was changed.`)
|
|
304
351
|
}
|
|
305
|
-
if (file
|
|
352
|
+
if (!archiveMatches(file, archive))
|
|
306
353
|
throw stop('The download doesn’t match the release.', 'Nothing was changed. Run the command again.')
|
|
307
354
|
live.done(progressLine(1, `${megabytes(file.size)} MB · verified`))
|
|
308
355
|
|
|
@@ -311,38 +358,32 @@ async function main() {
|
|
|
311
358
|
// Stage beside the destination so the final move is a rename on the same disk.
|
|
312
359
|
const stage = await mkdtemp(join(target, '.morphite-install-'))
|
|
313
360
|
try {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
await run('/usr/bin/codesign', ['--verify', '--deep', '--strict', candidate]).catch(() => {
|
|
318
|
-
throw stop('The downloaded app failed its signature check.', 'Nothing was changed.')
|
|
361
|
+
const candidate = await host.unpack(file.path, stage, manifest)
|
|
362
|
+
await host.verify(candidate, manifest).catch((error) => {
|
|
363
|
+
throw stop(error.message, 'Nothing was changed.')
|
|
319
364
|
})
|
|
320
|
-
|
|
321
|
-
await run('/usr/bin/xattr', ['-dr', 'com.apple.quarantine', candidate]).catch(() => undefined)
|
|
365
|
+
await host.prepare(candidate)
|
|
322
366
|
|
|
323
|
-
// Morphite must be closed before its app is replaced. The person quits it
|
|
324
|
-
// presses Enter and the installer asks it to quit the normal way (never a forced stop, so
|
|
325
|
-
// work ends cleanly).
|
|
326
|
-
let open = await
|
|
367
|
+
// Morphite must be closed before its app is replaced. The person quits it themselves, or on a
|
|
368
|
+
// Mac presses Enter and the installer asks it to quit the normal way (never a forced stop, so
|
|
369
|
+
// its work ends cleanly).
|
|
370
|
+
let open = await host.running([...installed, ...earlier], manifest)
|
|
327
371
|
if (open.length) {
|
|
328
|
-
const names = open.map((app) =>
|
|
372
|
+
const names = open.map((app) => host.nameOf(app)).join(' and ')
|
|
329
373
|
live.clear()
|
|
330
374
|
note(
|
|
331
375
|
`${names} is open.`,
|
|
332
|
-
|
|
333
|
-
tty ? 'Or press Return to quit it now. Control-C stops without changing anything.' : 'Control-C stops without changing anything.'
|
|
376
|
+
host.quitHint(names, current ? 'update' : 'install'),
|
|
377
|
+
tty && host.quit ? 'Or press Return to quit it now. Control-C stops without changing anything.' : 'Control-C stops without changing anything.'
|
|
334
378
|
)
|
|
335
379
|
say('')
|
|
336
380
|
const input = tty && process.stdin.isTTY ? process.stdin : null
|
|
337
381
|
const key = (data) => {
|
|
338
382
|
const text = String(data)
|
|
339
383
|
if (text.includes('\u0003')) return stopped()
|
|
340
|
-
if (!/[\r\n]/.test(text)) return
|
|
384
|
+
if (!/[\r\n]/.test(text) || !host.quit) return
|
|
341
385
|
live.set(grey(`Quitting ${names}…`))
|
|
342
|
-
for (const app of open)
|
|
343
|
-
const id = earlier.includes(app) ? EARLIER_APP_ID : manifest.bundleId
|
|
344
|
-
void run('/usr/bin/osascript', ['-e', `tell application id ${JSON.stringify(id)} to quit`]).catch(() => undefined)
|
|
345
|
-
}
|
|
386
|
+
for (const app of open) host.quit(earlier.includes(app) ? EARLIER_APP_ID : manifest.bundleId)
|
|
346
387
|
}
|
|
347
388
|
if (input) {
|
|
348
389
|
input.setRawMode(true)
|
|
@@ -353,7 +394,7 @@ async function main() {
|
|
|
353
394
|
try {
|
|
354
395
|
while (open.length) {
|
|
355
396
|
await new Promise((resolve) => setTimeout(resolve, 700))
|
|
356
|
-
open = await
|
|
397
|
+
open = await host.running([...installed, ...earlier], manifest)
|
|
357
398
|
}
|
|
358
399
|
} finally {
|
|
359
400
|
if (input) {
|
|
@@ -366,23 +407,13 @@ async function main() {
|
|
|
366
407
|
live.set(grey(`${current ? 'Updating' : 'Installing'} in ${place}…`))
|
|
367
408
|
}
|
|
368
409
|
|
|
369
|
-
const replaced = []
|
|
370
410
|
try {
|
|
371
411
|
// 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
|
|
373
|
-
// and sign-in live outside it.
|
|
374
|
-
|
|
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)
|
|
412
|
+
// new app is in place, as any updater does. On failure every earlier copy is put back
|
|
413
|
+
// (APP_SWAP). The app holds no data: projects, settings and sign-in live outside it.
|
|
414
|
+
await swapApps(installed, candidate, destination, (index) => host.aside(stage, index))
|
|
381
415
|
} catch (error) {
|
|
382
|
-
|
|
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.`)
|
|
416
|
+
throw stop(`${manifest.name} couldn’t be installed.`, `${error.message.replace(destination, tilde(destination))} Nothing was changed.`)
|
|
386
417
|
}
|
|
387
418
|
live.done(grey(`${current ? 'Updated' : 'Installed'} in ${place}`))
|
|
388
419
|
} finally {
|
|
@@ -392,13 +423,10 @@ async function main() {
|
|
|
392
423
|
// Agreed above: the earlier app goes to the Trash (recoverable); its data folder stays.
|
|
393
424
|
for (const app of earlier)
|
|
394
425
|
say(grey((await moveToTrash(app)) ? 'Moved the earlier Morphite to the Trash' : `Drag ${basename(app)} from ${tilde(dirname(app))} to the Trash`))
|
|
426
|
+
await host.afterInstall(destination, manifest)
|
|
395
427
|
|
|
396
|
-
const opened = !flag('--no-open') && (await
|
|
397
|
-
note(
|
|
398
|
-
`${manifest.name} is ready.`,
|
|
399
|
-
opened ? `It’s open now, and in ${place} and Spotlight.` : `Open it from ${place} or Spotlight.`,
|
|
400
|
-
'Run this command again to update.'
|
|
401
|
-
)
|
|
428
|
+
const opened = !flag('--no-open') && (await host.open(destination, manifest))
|
|
429
|
+
note(`${manifest.name} is ready.`, host.ready(opened, place), 'Run this command again to update.')
|
|
402
430
|
say('')
|
|
403
431
|
} finally {
|
|
404
432
|
await cleanup()
|
package/manifest.mjs
CHANGED
|
@@ -1,16 +1,28 @@
|
|
|
1
1
|
/** SOURCE OF TRUTH KEYWORDS: installManifest, INSTALL_MANIFEST, parseInstallManifest, INSTALL_MANIFEST_FILE
|
|
2
2
|
* WHAT: The one file a Morphite release carries for the terminal installer: the app's name, bundle
|
|
3
|
-
* id and version, and for each Mac CPU the zipped app with its size
|
|
3
|
+
* id and version, and for each Mac CPU (and Windows, as windows-x64) the zipped app with its size
|
|
4
|
+
* and SHA-256.
|
|
4
5
|
* WHY: The installer trusts nothing it has not checked: it installs only releases that carry this
|
|
5
6
|
* file (earlier releases without it are never picked up), checks every field before a path or URL
|
|
6
7
|
* is built from it, and refuses an archive whose size or hash differs.
|
|
7
|
-
* WHERE:
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* WHERE: writeReleaseFiles writes it beside the zips (scripts/release-publish.ts, and the local
|
|
9
|
+
* update test kit scripts/update-try.ts); bin/morphite.mjs and the app's own Update
|
|
10
|
+
* (app-update.mjs readManifest → src/desktop/updates/release-install.ts) read it from the releases
|
|
11
|
+
* repository or a local folder. Plain JavaScript so the published installer has no dependencies.
|
|
10
12
|
*/
|
|
13
|
+
import { createHash } from 'node:crypto'
|
|
14
|
+
import { createReadStream } from 'node:fs'
|
|
15
|
+
import { stat, writeFile } from 'node:fs/promises'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
|
|
11
18
|
export const INSTALL_MANIFEST_FILE = 'morphite-install.json'
|
|
12
19
|
export const RELEASES_REPOSITORY = 'webprodigies-org/morphite-releases'
|
|
13
20
|
export const MAC_ARCHES = ['arm64', 'x64']
|
|
21
|
+
/** Every archive a release can carry: Mac CPUs by their bare names (as earlier releases have them),
|
|
22
|
+
* Windows as windows-<cpu>. Installers that predate Windows read only the Mac keys. */
|
|
23
|
+
export const ARCHIVE_TARGETS = [...MAC_ARCHES, 'windows-x64']
|
|
24
|
+
/** The archive key for this computer. */
|
|
25
|
+
export const archiveTarget = (system, cpu) => (system === 'win32' ? `windows-${cpu}` : cpu)
|
|
14
26
|
|
|
15
27
|
const text = (value, pattern, field) => {
|
|
16
28
|
if (typeof value !== 'string' || !pattern.test(value)) throw Error(`Release manifest: invalid ${field}.`)
|
|
@@ -21,7 +33,7 @@ const text = (value, pattern, field) => {
|
|
|
21
33
|
export function parseInstallManifest(input) {
|
|
22
34
|
if (!input || typeof input !== 'object' || input.format !== 1) throw Error('Release manifest: unsupported format.')
|
|
23
35
|
const archives = {}
|
|
24
|
-
for (const arch of
|
|
36
|
+
for (const arch of ARCHIVE_TARGETS) {
|
|
25
37
|
const entry = input.archives?.[arch]
|
|
26
38
|
if (!entry) continue
|
|
27
39
|
archives[arch] = {
|
|
@@ -30,7 +42,7 @@ export function parseInstallManifest(input) {
|
|
|
30
42
|
size: Number.isSafeInteger(entry.size) && entry.size > 0 ? entry.size : text('', /x/, `${arch} size`)
|
|
31
43
|
}
|
|
32
44
|
}
|
|
33
|
-
if (!Object.keys(archives).length) throw Error('Release manifest: no
|
|
45
|
+
if (!Object.keys(archives).length) throw Error('Release manifest: no archives.')
|
|
34
46
|
return {
|
|
35
47
|
format: 1,
|
|
36
48
|
// The app's folder name in Applications, e.g. "Morphite (Alpha)".
|
|
@@ -47,3 +59,24 @@ export const releaseFileUrl = (tag, file) =>
|
|
|
47
59
|
`https://github.com/${RELEASES_REPOSITORY}/releases/download/${encodeURIComponent(tag)}/${encodeURIComponent(file)}`
|
|
48
60
|
export const latestManifestUrl = () =>
|
|
49
61
|
`https://github.com/${RELEASES_REPOSITORY}/releases/latest/download/${INSTALL_MANIFEST_FILE}`
|
|
62
|
+
|
|
63
|
+
/** Describes the zips already in `folder` for a release: measures each, writes morphite-install.json
|
|
64
|
+
* and SHA256SUMS beside them and returns the checked manifest. Used by release:publish and the
|
|
65
|
+
* local update test kit (update:try), so a local release has the published format. */
|
|
66
|
+
export async function writeReleaseFiles(folder, { files, ...release }) {
|
|
67
|
+
const archives = {}
|
|
68
|
+
for (const arch of ARCHIVE_TARGETS) {
|
|
69
|
+
const file = files[arch]
|
|
70
|
+
if (!file) continue
|
|
71
|
+
const hash = createHash('sha256')
|
|
72
|
+
for await (const chunk of createReadStream(join(folder, file))) hash.update(chunk)
|
|
73
|
+
archives[arch] = { file, sha256: hash.digest('hex'), size: (await stat(join(folder, file))).size }
|
|
74
|
+
}
|
|
75
|
+
const manifest = parseInstallManifest({ format: 1, ...release, archives })
|
|
76
|
+
await writeFile(join(folder, INSTALL_MANIFEST_FILE), JSON.stringify(manifest, null, 2) + '\n')
|
|
77
|
+
await writeFile(
|
|
78
|
+
join(folder, 'SHA256SUMS'),
|
|
79
|
+
Object.values(manifest.archives).map((entry) => `${entry.sha256} ${entry.file}`).join('\n') + '\n'
|
|
80
|
+
)
|
|
81
|
+
return manifest
|
|
82
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webprodigies/morphite",
|
|
3
|
-
"version": "0.2.
|
|
4
|
-
"description": "Install or update Morphite on your Mac: npx @webprodigies/morphite",
|
|
3
|
+
"version": "0.2.4",
|
|
4
|
+
"description": "Install or update Morphite on your Mac or Windows PC: npx @webprodigies/morphite",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"morphite": "bin/morphite.mjs"
|
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
"files": [
|
|
10
10
|
"bin/morphite.mjs",
|
|
11
11
|
"manifest.mjs",
|
|
12
|
+
"windows.mjs",
|
|
13
|
+
"app-update.mjs",
|
|
12
14
|
"README.md"
|
|
13
15
|
],
|
|
14
16
|
"engines": {
|
package/windows.mjs
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/** SOURCE OF TRUTH KEYWORDS: windowsInstallSteps, WINDOWS_INSTALL, writeStartMenuShortcut
|
|
2
|
+
* WHAT: The Windows steps of the terminal installer. The app lives in %LOCALAPPDATA%\Programs\<name>
|
|
3
|
+
* for this person only (no administrator), is unpacked with Windows' own tar, checked by its exe's
|
|
4
|
+
* product name and version, and gets one Start-menu shortcut whose AppUserModelID is the release's
|
|
5
|
+
* bundle id: Windows groups the app's windows and notifications by that id, and Electron's
|
|
6
|
+
* app.setAppUserModelId (desktop main) sets the same. Nothing else is registered.
|
|
7
|
+
* WHY: One terminal installer for every OS (bin/morphite.mjs); only these steps differ on Windows.
|
|
8
|
+
* The shortcut is written here, not by the app, so Morphite is in the Start menu before it first runs.
|
|
9
|
+
* WHERE: bin/morphite.mjs runs these beside its Mac steps; ../app-update.mjs's download, archive check
|
|
10
|
+
* and swap are shared. User data (appDataFolder: %LOCALAPPDATA%\<profile>) is outside Programs and
|
|
11
|
+
* is never touched here. Plain JavaScript: the installer has no build step or dependencies.
|
|
12
|
+
*/
|
|
13
|
+
import { spawn, execFile } from 'node:child_process'
|
|
14
|
+
import { mkdir, readdir, stat } from 'node:fs/promises'
|
|
15
|
+
import { homedir, release } from 'node:os'
|
|
16
|
+
import { join } from 'node:path'
|
|
17
|
+
import { promisify } from 'node:util'
|
|
18
|
+
|
|
19
|
+
const run = promisify(execFile)
|
|
20
|
+
const variable = (env, name) => env[Object.keys(env).find((key) => key.toUpperCase() === name.toUpperCase()) ?? name]
|
|
21
|
+
const system32 = (env = process.env) => join(variable(env, 'SystemRoot') || variable(env, 'windir') || 'C:\\Windows', 'System32')
|
|
22
|
+
const powershell = (env = process.env) => join(system32(env), 'WindowsPowerShell', 'v1.0', 'powershell.exe')
|
|
23
|
+
/** PowerShell with the script as -EncodedCommand (no quoting on the way) and its inputs as variables.
|
|
24
|
+
* A failure carries PowerShell's own first error line, not the encoded command. */
|
|
25
|
+
function runPowerShell(script, values = {}) {
|
|
26
|
+
const encoded = Buffer.from(script, 'utf16le').toString('base64')
|
|
27
|
+
return run(powershell(), ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded], {
|
|
28
|
+
env: { ...process.env, ...values },
|
|
29
|
+
windowsHide: true,
|
|
30
|
+
maxBuffer: 16 * 1024 * 1024
|
|
31
|
+
}).catch((error) => {
|
|
32
|
+
const reason = String(error.stderr ?? '').split(/\r?\n/).map((line) => line.trim()).find(Boolean)
|
|
33
|
+
throw Error(reason || 'PowerShell could not finish.')
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Where the app and its shortcut go for this person. */
|
|
38
|
+
export function windowsPlaces(env = process.env, home = homedir()) {
|
|
39
|
+
const local = variable(env, 'LOCALAPPDATA') || join(home, 'AppData', 'Local')
|
|
40
|
+
const roaming = variable(env, 'APPDATA') || join(home, 'AppData', 'Roaming')
|
|
41
|
+
return {
|
|
42
|
+
programs: join(local, 'Programs'),
|
|
43
|
+
startMenu: join(roaming, 'Microsoft', 'Windows', 'Start Menu', 'Programs')
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
/** "Windows 10" or later, by the kernel's 10.0 version; tar and curl ship with Windows 10 1803 on. */
|
|
47
|
+
export const windowsVersion = () => release()
|
|
48
|
+
export const supportedWindows = (version = release()) => Number(version.split('.')[0]) >= 10 && Number(version.split('.')[2] ?? 0) >= 17134
|
|
49
|
+
export const exeOf = (folder, name) => join(folder, `${name}.exe`)
|
|
50
|
+
|
|
51
|
+
/** The installed copy's product name and version from its exe, or null. */
|
|
52
|
+
export async function windowsAppInfo(folder, name) {
|
|
53
|
+
const exe = exeOf(folder, name)
|
|
54
|
+
if (!(await stat(exe).catch(() => null))?.isFile()) return null
|
|
55
|
+
const { stdout } = await runPowerShell(
|
|
56
|
+
'$v = (Get-Item -LiteralPath $env:MORPHITE_EXE).VersionInfo; @{ name = $v.ProductName; version = $v.ProductVersion; file = $v.FileVersion } | ConvertTo-Json -Compress',
|
|
57
|
+
{ MORPHITE_EXE: exe }
|
|
58
|
+
).catch(() => ({ stdout: '' }))
|
|
59
|
+
try {
|
|
60
|
+
const info = JSON.parse(stdout)
|
|
61
|
+
return { name: info.name ?? null, version: info.version ?? info.file ?? null }
|
|
62
|
+
} catch {
|
|
63
|
+
return null
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Unpacks a release zip into `folder` and returns the app folder it holds (the zip's one folder). */
|
|
68
|
+
export async function unpackWindowsApp(zip, folder) {
|
|
69
|
+
await run(join(system32(), 'tar.exe'), ['-x', '-f', zip, '-C', folder], { windowsHide: true })
|
|
70
|
+
const entries = (await readdir(folder, { withFileTypes: true })).filter((entry) => entry.isDirectory())
|
|
71
|
+
if (entries.length !== 1) throw Error('The release zip doesn’t hold one app folder.')
|
|
72
|
+
return join(folder, entries[0].name)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Throws unless `app` is the named Morphite build at the release's version, with its app archive. */
|
|
76
|
+
export async function verifyWindowsApp(app, { name, version }, CheckError = Error) {
|
|
77
|
+
const info = await windowsAppInfo(app, name)
|
|
78
|
+
if (!info || info.name !== name) throw new CheckError('bundle-id', 'The downloaded app isn’t the expected Morphite build.')
|
|
79
|
+
if (info.version !== version) throw new CheckError('version', 'The downloaded app isn’t the version the release names.')
|
|
80
|
+
// Listed, never opened: inside Morphite, Electron's fs treats app.asar as an archive (and keeps it
|
|
81
|
+
// open, so the staging folder could not be removed).
|
|
82
|
+
const resources = await readdir(join(app, 'resources')).catch(() => [])
|
|
83
|
+
if (!resources.includes('app.asar')) throw new CheckError('bundle-id', 'The downloaded app isn’t the expected Morphite build.')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The app folders among `folders` with a program running from inside them. */
|
|
87
|
+
export async function runningWindowsApps(folders) {
|
|
88
|
+
if (!folders.length) return []
|
|
89
|
+
const { stdout } = await runPowerShell(
|
|
90
|
+
'(Get-CimInstance Win32_Process | Where-Object { $_.ExecutablePath } | ForEach-Object { $_.ExecutablePath }) -join "`n"'
|
|
91
|
+
).catch(() => ({ stdout: '' }))
|
|
92
|
+
const paths = stdout.split(/\r?\n/).map((path) => path.trim().toLowerCase()).filter(Boolean)
|
|
93
|
+
return folders.filter((folder) => paths.some((path) => path.startsWith(folder.toLowerCase() + '\\')))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A Start-menu shortcut with an AppUserModelID needs the shell's own link object and property store.
|
|
97
|
+
const SHORTCUT = String.raw`
|
|
98
|
+
Add-Type -TypeDefinition @'
|
|
99
|
+
using System; using System.Runtime.InteropServices; using System.Runtime.InteropServices.ComTypes; using System.Text;
|
|
100
|
+
[ComImport, Guid("00021401-0000-0000-C000-000000000046")] class CShellLink {}
|
|
101
|
+
[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("000214F9-0000-0000-C000-000000000046")]
|
|
102
|
+
interface IShellLinkW {
|
|
103
|
+
void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder f, int c, IntPtr d, uint fl); void GetIDList(out IntPtr p); void SetIDList(IntPtr p);
|
|
104
|
+
void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder n, int c); void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string n);
|
|
105
|
+
void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder d, int c); void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string d);
|
|
106
|
+
void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder a, int c); void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string a);
|
|
107
|
+
void GetHotkey(out short h); void SetHotkey(short h); void GetShowCmd(out int s); void SetShowCmd(int s);
|
|
108
|
+
void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] StringBuilder p, int c, out int i); void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string p, int i);
|
|
109
|
+
void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string p, uint r); void Resolve(IntPtr h, uint f); void SetPath([MarshalAs(UnmanagedType.LPWStr)] string f);
|
|
110
|
+
}
|
|
111
|
+
[ComImport, InterfaceType(ComInterfaceType.InterfaceIsIUnknown), Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99")]
|
|
112
|
+
interface IPropertyStore { void GetCount(out uint c); void GetAt(uint i, out PropertyKey k); void GetValue(ref PropertyKey k, out PropVariant v); void SetValue(ref PropertyKey k, ref PropVariant v); void Commit(); }
|
|
113
|
+
[StructLayout(LayoutKind.Sequential, Pack = 4)] public struct PropertyKey { public Guid fmtid; public uint pid; }
|
|
114
|
+
[StructLayout(LayoutKind.Explicit)] public struct PropVariant { [FieldOffset(0)] public ushort vt; [FieldOffset(8)] public IntPtr value; }
|
|
115
|
+
public static class MorphiteShortcut {
|
|
116
|
+
public static void Write(string link, string target, string folder, string id) {
|
|
117
|
+
var shell = (IShellLinkW)new CShellLink();
|
|
118
|
+
shell.SetPath(target); shell.SetWorkingDirectory(folder); shell.SetIconLocation(target, 0);
|
|
119
|
+
var key = new PropertyKey { fmtid = new Guid("9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3"), pid = 5 };
|
|
120
|
+
var value = new PropVariant { vt = 31, value = Marshal.StringToCoTaskMemUni(id) };
|
|
121
|
+
try { var store = (IPropertyStore)shell; store.SetValue(ref key, ref value); store.Commit(); ((IPersistFile)shell).Save(link, true); }
|
|
122
|
+
finally { Marshal.FreeCoTaskMem(value.value); }
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
'@
|
|
126
|
+
[MorphiteShortcut]::Write($env:MORPHITE_LINK, $env:MORPHITE_TARGET, $env:MORPHITE_FOLDER, $env:MORPHITE_APP_ID)
|
|
127
|
+
`
|
|
128
|
+
/** Writes (or replaces) the Start-menu shortcut to the app, with its AppUserModelID. */
|
|
129
|
+
export async function writeStartMenuShortcut({ startMenu }, app, { name, bundleId }) {
|
|
130
|
+
const link = join(startMenu, `${name}.lnk`)
|
|
131
|
+
await mkdir(startMenu, { recursive: true })
|
|
132
|
+
await runPowerShell(SHORTCUT, { MORPHITE_LINK: link, MORPHITE_TARGET: exeOf(app, name), MORPHITE_FOLDER: app, MORPHITE_APP_ID: bundleId })
|
|
133
|
+
return link
|
|
134
|
+
}
|
|
135
|
+
/** The AppUserModelID a shortcut carries, as the shell reads it. */
|
|
136
|
+
export async function shortcutAppId(link) {
|
|
137
|
+
const { stdout } = await runPowerShell(
|
|
138
|
+
'$f = Get-Item -LiteralPath $env:MORPHITE_LINK; (New-Object -ComObject Shell.Application).NameSpace($f.DirectoryName).ParseName($f.Name).ExtendedProperty("System.AppUserModel.ID")',
|
|
139
|
+
{ MORPHITE_LINK: link }
|
|
140
|
+
)
|
|
141
|
+
return stdout.trim() || null
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Starts the app on its own, as the Start menu would. */
|
|
145
|
+
export async function openWindowsApp(app, name) {
|
|
146
|
+
return new Promise((resolve) => {
|
|
147
|
+
const child = spawn(exeOf(app, name), [], { cwd: app, detached: true, stdio: 'ignore', windowsHide: false })
|
|
148
|
+
child.once('error', () => resolve(false))
|
|
149
|
+
child.once('spawn', () => {
|
|
150
|
+
child.unref()
|
|
151
|
+
resolve(true)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
}
|