@webprodigies/morphite 0.2.3 → 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 CHANGED
@@ -9,7 +9,8 @@
9
9
  * app's swap runs as SWAP_HELPER_SCRIPT under /bin/sh; it repeats swapApps's steps and is tested
10
10
  * against the same cases. Its inputs are positional arguments; no path is ever put into its text.
11
11
  * WHERE: bin/morphite.mjs (installer) and src/desktop/updates/release-install.ts (STAGED_UPDATE)
12
- * import these; manifest.mjs owns the release format. Plain JavaScript: the installer has no
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
13
14
  * build step or dependencies.
14
15
  */
15
16
  import { createHash } from 'node:crypto'
@@ -180,3 +181,101 @@ if [ "$result" -eq 0 ]; then say "Updated $destination."; opened=$destination; e
180
181
  if [ "$reopen" = 1 ]; then /usr/bin/open "$opened" || say "Morphite couldn't be reopened."; fi
181
182
  exit "$result"
182
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,15 +1,17 @@
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
11
  * WHERE: ../manifest.mjs owns the release file format and ../app-update.mjs the download, checks
10
12
  * and swap (appUpdateSteps), shared with the desktop app's own Update, which offers this command
11
13
  * only when it cannot prepare an update itself. scripts/release-publish.ts publishes releases.
12
- * User data lives outside the app bundle and is never touched here.
14
+ * User data lives outside the app and is never touched here.
13
15
  */
14
16
  import { access, constants, lstat, mkdir, mkdtemp, readdir, rm } from 'node:fs/promises'
15
17
  import { execFile } from 'node:child_process'
@@ -17,7 +19,19 @@ import { homedir, platform, tmpdir, arch as nodeArch } from 'node:os'
17
19
  import { join, basename, dirname } from 'node:path'
18
20
  import { createInterface } from 'node:readline'
19
21
  import { promisify } from 'node:util'
20
- import { archiveMatches, clearQuarantine, downloadArchive, readManifest, swapApps, unpackApp, verifyApp } from '../app-update.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'
21
35
 
22
36
  const run = promisify(execFile)
23
37
  const args = process.argv.slice(2)
@@ -132,17 +146,108 @@ async function moveToTrash(path) {
132
146
  () => false
133
147
  )
134
148
  }
135
-
136
- // ── Install ─────────────────────────────────────────────────────────────────
137
149
  async function writable(folder) {
138
150
  return access(folder, constants.W_OK).then(
139
151
  () => true,
140
152
  () => false
141
153
  )
142
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 ─────────────────────────────────────────────────────────────────
143
247
  async function main() {
248
+ const host = platform() === 'darwin' ? macSteps : platform() === 'win32' ? windowsSteps : null
144
249
  if (flag('--help') || flag('-h')) {
145
- 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'}.`), '',
146
251
  `--yes ${grey('Answer yes to every question')}`,
147
252
  `--no-open ${grey('Do not open Morphite afterwards')}`,
148
253
  `--reinstall ${grey('Install again even when this version is installed')}`,
@@ -150,18 +255,16 @@ async function main() {
150
255
  return
151
256
  }
152
257
  const from = option('--from')
153
- if (platform() !== 'darwin') {
258
+ if (!host) {
154
259
  header('Morphite')
155
- 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')
156
261
  say('')
157
262
  process.exitCode = 1
158
263
  return
159
264
  }
160
265
 
161
266
  live.set(grey('Checking for the latest release…'))
162
- const macOS = (await run('/usr/bin/sw_vers', ['-productVersion'])).stdout.trim()
163
- const cpu = await macCpu()
164
- const chip = cpu === 'arm64' ? 'Apple silicon' : 'Intel'
267
+ const computer = await host.computer()
165
268
  // The header names the app and version from the release, so read it before printing anything else.
166
269
  let manifest
167
270
  try {
@@ -176,30 +279,27 @@ async function main() {
176
279
  }
177
280
  live.clear()
178
281
  header(manifest.name, manifest.version)
179
- say(grey(`macOS ${macOS} · ${chip}`))
180
- if (!versionAtLeast(macOS, manifest.minimumMacOS)) {
181
- 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)
182
286
  say('')
183
287
  process.exitCode = 1
184
288
  return
185
289
  }
186
- const archive = manifest.archives[cpu]
290
+ const archive = manifest.archives[archiveTarget(platform(), computer.cpu)]
187
291
  if (!archive) {
188
- note(`This release has no ${chip} build yet.`, 'Nothing was changed.')
292
+ note(`This release has no ${computer.chip} build yet.`, 'Nothing was changed.')
189
293
  say('')
190
294
  process.exitCode = 1
191
295
  return
192
296
  }
193
- const applications = '/Applications'
194
- const personal = join(homedir(), 'Applications')
195
- const installed = await appsWithId([applications, personal], manifest.bundleId)
196
- const current = installed.length ? await versionOf(installed[0]) : null
197
- const earlier = await appsWithId([applications, personal], EARLIER_APP_ID)
297
+ const { installed, current, earlier } = await host.installed(manifest)
198
298
 
199
299
  // Already current (and nothing earlier to retire): say so and open it, without downloading again.
200
300
  if (current === manifest.version && !earlier.length && !flag('--reinstall')) {
201
- const opened = !flag('--no-open') && (await run('/usr/bin/open', [installed[0]]).then(() => true, () => false))
202
- 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.' : ''}`)
203
303
  say('')
204
304
  return
205
305
  }
@@ -222,9 +322,7 @@ async function main() {
222
322
  say('')
223
323
  }
224
324
 
225
- const target = (await writable(applications)) ? applications : personal
226
- const place = target === applications ? 'Applications' : tilde(target)
227
- const destination = join(target, `${manifest.name}.app`)
325
+ const { target, place, destination } = await host.target(manifest)
228
326
  const work = await mkdtemp(join(tmpdir(), 'morphite-installer-'))
229
327
  const cleanup = () => rm(work, { recursive: true, force: true }).catch(() => undefined)
230
328
  const stopped = () => {
@@ -260,36 +358,32 @@ async function main() {
260
358
  // Stage beside the destination so the final move is a rename on the same disk.
261
359
  const stage = await mkdtemp(join(target, '.morphite-install-'))
262
360
  try {
263
- await unpackApp(file.path, stage)
264
- const candidate = join(stage, `${manifest.name}.app`)
265
- await verifyApp(candidate, { bundleId: manifest.bundleId, version: manifest.version }).catch((error) => {
361
+ const candidate = await host.unpack(file.path, stage, manifest)
362
+ await host.verify(candidate, manifest).catch((error) => {
266
363
  throw stop(error.message, 'Nothing was changed.')
267
364
  })
268
- await clearQuarantine(candidate)
365
+ await host.prepare(candidate)
269
366
 
270
- // Morphite must be closed before its app is replaced. The person quits it from its menu, or
271
- // presses Enter and the installer asks it to quit the normal way (never a forced stop, so its
272
- // work ends cleanly). No keyboard shortcut is suggested: ⌘Q here would quit Terminal.
273
- 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)
274
371
  if (open.length) {
275
- const names = open.map((app) => basename(app, '.app')).join(' and ')
372
+ const names = open.map((app) => host.nameOf(app)).join(' and ')
276
373
  live.clear()
277
374
  note(
278
375
  `${names} is open.`,
279
- `Quit it from its menu bar (${names} → Quit), and this ${current ? 'update' : 'install'} continues on its own.`,
280
- tty ? 'Or press Return to quit it now. Control-C stops without changing anything.' : 'Control-C stops without changing anything.'
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.'
281
378
  )
282
379
  say('')
283
380
  const input = tty && process.stdin.isTTY ? process.stdin : null
284
381
  const key = (data) => {
285
382
  const text = String(data)
286
383
  if (text.includes('\u0003')) return stopped()
287
- if (!/[\r\n]/.test(text)) return
384
+ if (!/[\r\n]/.test(text) || !host.quit) return
288
385
  live.set(grey(`Quitting ${names}…`))
289
- for (const app of open) {
290
- const id = earlier.includes(app) ? EARLIER_APP_ID : manifest.bundleId
291
- void run('/usr/bin/osascript', ['-e', `tell application id ${JSON.stringify(id)} to quit`]).catch(() => undefined)
292
- }
386
+ for (const app of open) host.quit(earlier.includes(app) ? EARLIER_APP_ID : manifest.bundleId)
293
387
  }
294
388
  if (input) {
295
389
  input.setRawMode(true)
@@ -300,7 +394,7 @@ async function main() {
300
394
  try {
301
395
  while (open.length) {
302
396
  await new Promise((resolve) => setTimeout(resolve, 700))
303
- open = await runningFrom([...installed, ...earlier])
397
+ open = await host.running([...installed, ...earlier], manifest)
304
398
  }
305
399
  } finally {
306
400
  if (input) {
@@ -315,9 +409,9 @@ async function main() {
315
409
 
316
410
  try {
317
411
  // Set the old app aside in the staging folder; it is removed with that folder only once the
318
- // new app is in place, as any Mac updater does. On failure every earlier copy is put back
319
- // (APP_SWAP). The bundle holds no data: projects, settings and sign-in live outside it.
320
- await swapApps(installed, candidate, destination, (index) => join(stage, `previous-${index}.app`))
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))
321
415
  } catch (error) {
322
416
  throw stop(`${manifest.name} couldn’t be installed.`, `${error.message.replace(destination, tilde(destination))} Nothing was changed.`)
323
417
  }
@@ -329,13 +423,10 @@ async function main() {
329
423
  // Agreed above: the earlier app goes to the Trash (recoverable); its data folder stays.
330
424
  for (const app of earlier)
331
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)
332
427
 
333
- const opened = !flag('--no-open') && (await run('/usr/bin/open', [destination]).then(() => true, () => false))
334
- note(
335
- `${manifest.name} is ready.`,
336
- opened ? `It’s open now, and in ${place} and Spotlight.` : `Open it from ${place} or Spotlight.`,
337
- 'Run this command again to update.'
338
- )
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.')
339
430
  say('')
340
431
  } finally {
341
432
  await cleanup()
package/manifest.mjs CHANGED
@@ -1,6 +1,7 @@
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.
@@ -17,6 +18,11 @@ import { join } from 'node:path'
17
18
  export const INSTALL_MANIFEST_FILE = 'morphite-install.json'
18
19
  export const RELEASES_REPOSITORY = 'webprodigies-org/morphite-releases'
19
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)
20
26
 
21
27
  const text = (value, pattern, field) => {
22
28
  if (typeof value !== 'string' || !pattern.test(value)) throw Error(`Release manifest: invalid ${field}.`)
@@ -27,7 +33,7 @@ const text = (value, pattern, field) => {
27
33
  export function parseInstallManifest(input) {
28
34
  if (!input || typeof input !== 'object' || input.format !== 1) throw Error('Release manifest: unsupported format.')
29
35
  const archives = {}
30
- for (const arch of MAC_ARCHES) {
36
+ for (const arch of ARCHIVE_TARGETS) {
31
37
  const entry = input.archives?.[arch]
32
38
  if (!entry) continue
33
39
  archives[arch] = {
@@ -36,7 +42,7 @@ export function parseInstallManifest(input) {
36
42
  size: Number.isSafeInteger(entry.size) && entry.size > 0 ? entry.size : text('', /x/, `${arch} size`)
37
43
  }
38
44
  }
39
- if (!Object.keys(archives).length) throw Error('Release manifest: no Mac archives.')
45
+ if (!Object.keys(archives).length) throw Error('Release manifest: no archives.')
40
46
  return {
41
47
  format: 1,
42
48
  // The app's folder name in Applications, e.g. "Morphite (Alpha)".
@@ -59,7 +65,7 @@ export const latestManifestUrl = () =>
59
65
  * local update test kit (update:try), so a local release has the published format. */
60
66
  export async function writeReleaseFiles(folder, { files, ...release }) {
61
67
  const archives = {}
62
- for (const arch of MAC_ARCHES) {
68
+ for (const arch of ARCHIVE_TARGETS) {
63
69
  const file = files[arch]
64
70
  if (!file) continue
65
71
  const hash = createHash('sha256')
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@webprodigies/morphite",
3
- "version": "0.2.3",
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,7 @@
9
9
  "files": [
10
10
  "bin/morphite.mjs",
11
11
  "manifest.mjs",
12
+ "windows.mjs",
12
13
  "app-update.mjs",
13
14
  "README.md"
14
15
  ],
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
+ }