@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 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
- Requires macOS 12 or later and Node.js 18 or later. Windows and Linux are on the way.
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 the Mac app: checks the Mac, reads the
4
- * latest release manifest, downloads the zip for this CPU with progress, checks its SHA-256,
5
- * replaces the app in Applications and opens it. It prints a few quiet lines (INSTALLER_LOOK).
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; 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.
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 { 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'
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 { INSTALL_MANIFEST_FILE, latestManifestUrl, parseInstallManifest, releaseFileUrl } from '../manifest.mjs'
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('Installs or updates Morphite on this Mac.'), '',
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 (platform() !== 'darwin') {
258
+ if (!host) {
209
259
  header('Morphite')
210
- note('Morphite runs on macOS for now.', 'Windows and Linux are on the way. Follow along at https://morphite.build')
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 macOS = (await run('/usr/bin/sw_vers', ['-productVersion'])).stdout.trim()
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(`macOS ${macOS} · ${chip}`))
235
- if (!versionAtLeast(macOS, manifest.minimumMacOS)) {
236
- note(`${manifest.name} needs macOS ${manifest.minimumMacOS} or later.`, `This Mac has macOS ${macOS}.`)
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 applications = '/Applications'
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 run('/usr/bin/open', [installed[0]]).then(() => true, () => false))
257
- note('You’re up to date.', `${manifest.name} ${manifest.version} is installed in ${dirname(installed[0]) === '/Applications' ? 'Applications' : tilde(dirname(installed[0]))}.${opened ? ' It’s open now.' : ''}`)
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 = (await writable(applications)) ? applications : personal
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 download(manifest, archive, work, from, (ratio, detail) => live.set(progressLine(ratio, detail)))
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.size !== archive.size || file.sha256 !== archive.sha256)
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
- await run('/usr/bin/ditto', ['-x', '-k', file.path, stage])
315
- 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.')
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
- // 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)
365
+ await host.prepare(candidate)
322
366
 
323
- // Morphite must be closed before its app is replaced. The person quits it from its menu, or
324
- // presses Enter and the installer asks it to quit the normal way (never a forced stop, so its
325
- // work ends cleanly). No keyboard shortcut is suggested: ⌘Q here would quit Terminal.
326
- let open = await runningFrom([...installed, ...earlier])
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) => basename(app, '.app')).join(' and ')
372
+ const names = open.map((app) => host.nameOf(app)).join(' and ')
329
373
  live.clear()
330
374
  note(
331
375
  `${names} is open.`,
332
- `Quit it from its menu bar (${names} → Quit), and this ${current ? 'update' : 'install'} continues on its own.`,
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 runningFrom([...installed, ...earlier])
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 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)
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
- // 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.`)
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 run('/usr/bin/open', [destination]).then(() => true, () => false))
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 and SHA-256.
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: 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.
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 MAC_ARCHES) {
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 Mac archives.')
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.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
+ }