@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 +4 -2
- package/app-update.mjs +100 -1
- package/bin/morphite.mjs +145 -54
- package/manifest.mjs +10 -4
- package/package.json +3 -2
- package/windows.mjs +154 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Morphite
|
|
2
2
|
|
|
3
|
-
Install or update Morphite on your Mac:
|
|
3
|
+
Install or update Morphite on your Mac or Windows PC:
|
|
4
4
|
|
|
5
5
|
```sh
|
|
6
6
|
npx @webprodigies/morphite
|
|
@@ -8,4 +8,6 @@ npx @webprodigies/morphite
|
|
|
8
8
|
|
|
9
9
|
The installer checks your Mac, downloads the latest release for Apple silicon or Intel, verifies its SHA-256 checksum, puts the app in Applications and opens it. Run the same command again to update. Your projects and settings live outside the app and are never changed by the installer.
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
On Windows it installs Morphite for you alone in `%LOCALAPPDATA%\Programs` (no administrator needed), adds it to the Start menu and opens it.
|
|
12
|
+
|
|
13
|
+
Requires macOS 12 or later, or Windows 10 (version 1803) or later, and Node.js 18 or later. Linux is on the way.
|
package/app-update.mjs
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;
|
|
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
|
|
4
|
-
* latest release manifest, downloads the zip for this
|
|
5
|
-
* replaces the app
|
|
3
|
+
* WHAT: `npx @webprodigies/morphite` installs or updates Morphite on a Mac or a Windows PC: checks
|
|
4
|
+
* the computer, reads the latest release manifest, downloads the zip for this computer with
|
|
5
|
+
* progress, checks its SHA-256, replaces the app and opens it. It prints a few quiet lines
|
|
6
|
+
* (INSTALLER_LOOK). Each OS supplies its own steps (macSteps below, windowsSteps from
|
|
7
|
+
* ../windows.mjs); the flow between them is shared.
|
|
6
8
|
* WHY: Until Morphite has a Developer ID, a browser download is stopped by Gatekeeper and people must
|
|
7
9
|
* allow it in Privacy & Security. A file fetched by this program is not marked as downloaded, so the
|
|
8
10
|
* verified app opens directly. Running the same command again is the update.
|
|
9
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
|
|
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(
|
|
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 (
|
|
258
|
+
if (!host) {
|
|
154
259
|
header('Morphite')
|
|
155
|
-
note('Morphite runs on macOS for now.', '
|
|
260
|
+
note('Morphite runs on macOS and Windows for now.', 'Linux is on the way. Follow along at https://morphite.build')
|
|
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
|
|
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(
|
|
180
|
-
|
|
181
|
-
|
|
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
|
|
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
|
|
202
|
-
note('You’re up to date.', `${manifest.name} ${manifest.version} is installed in ${
|
|
301
|
+
const opened = !flag('--no-open') && (await host.open(installed[0], manifest))
|
|
302
|
+
note('You’re up to date.', `${manifest.name} ${manifest.version} is installed in ${host.placeOf(installed[0])}.${opened ? ' It’s open now.' : ''}`)
|
|
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 =
|
|
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
|
|
264
|
-
|
|
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
|
|
365
|
+
await host.prepare(candidate)
|
|
269
366
|
|
|
270
|
-
// Morphite must be closed before its app is replaced. The person quits it
|
|
271
|
-
// presses Enter and the installer asks it to quit the normal way (never a forced stop, so
|
|
272
|
-
// work ends cleanly).
|
|
273
|
-
let open = await
|
|
367
|
+
// Morphite must be closed before its app is replaced. The person quits it themselves, or on a
|
|
368
|
+
// Mac presses Enter and the installer asks it to quit the normal way (never a forced stop, so
|
|
369
|
+
// its work ends cleanly).
|
|
370
|
+
let open = await host.running([...installed, ...earlier], manifest)
|
|
274
371
|
if (open.length) {
|
|
275
|
-
const names = open.map((app) =>
|
|
372
|
+
const names = open.map((app) => host.nameOf(app)).join(' and ')
|
|
276
373
|
live.clear()
|
|
277
374
|
note(
|
|
278
375
|
`${names} is open.`,
|
|
279
|
-
|
|
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
|
|
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
|
|
319
|
-
// (APP_SWAP). The
|
|
320
|
-
await swapApps(installed, candidate, destination, (index) =>
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
+
}
|