centralu 0.1.0-beta.7 → 0.1.0-beta.9
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/bin/centralu.mjs +214 -47
- package/bin/platform.mjs +292 -0
- package/package.json +8 -5
package/bin/centralu.mjs
CHANGED
|
@@ -16,10 +16,31 @@
|
|
|
16
16
|
* "the app" even is — macOS hands a `.app` directory to LaunchServices, Linux runs a
|
|
17
17
|
* self-contained AppImage itself — so that difference lives in one table (`TARGETS`)
|
|
18
18
|
* instead of being rediscovered in every function.
|
|
19
|
+
*
|
|
20
|
+
* Windows joined in W3 of #14. Its decisions — the exe inside the folder, a detached start,
|
|
21
|
+
* where `install` copies to, the WebView2 check — live in `platform.mjs` as functions of the
|
|
22
|
+
* platform, so they are tested on macOS CI (tooling/launcher-platform.test.ts) instead of
|
|
23
|
+
* being first exercised on someone's laptop.
|
|
19
24
|
*/
|
|
20
25
|
import { execFileSync, spawn } from 'node:child_process'
|
|
21
26
|
import { copyDiffers, isNewer } from './semver.mjs'
|
|
22
|
-
import {
|
|
27
|
+
import {
|
|
28
|
+
busyMessage,
|
|
29
|
+
earlyExitMessage,
|
|
30
|
+
executableIn,
|
|
31
|
+
installedPaths,
|
|
32
|
+
isBusyError,
|
|
33
|
+
launchPlan,
|
|
34
|
+
npmCommand,
|
|
35
|
+
regExe,
|
|
36
|
+
shortcutCommand,
|
|
37
|
+
targetFor,
|
|
38
|
+
TARGETS,
|
|
39
|
+
webview2MissingMessage,
|
|
40
|
+
webview2Status,
|
|
41
|
+
windowsInstall,
|
|
42
|
+
} from './platform.mjs'
|
|
43
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
23
44
|
import { createRequire } from 'node:module'
|
|
24
45
|
import { homedir } from 'node:os'
|
|
25
46
|
import { dirname, join } from 'node:path'
|
|
@@ -31,28 +52,17 @@ const APP_NAME = 'Centralu'
|
|
|
31
52
|
const BUNDLE = `${APP_NAME}.app`
|
|
32
53
|
const INSTALLED = `/Applications/${BUNDLE}`
|
|
33
54
|
|
|
55
|
+
const PLATFORM = process.platform
|
|
56
|
+
const HOME = homedir()
|
|
57
|
+
|
|
34
58
|
/** Where a menu entry for the app goes on freedesktop desktops */
|
|
35
|
-
const DESKTOP_ENTRY = join(
|
|
59
|
+
const DESKTOP_ENTRY = join(HOME, '.local/share/applications/centralu.desktop')
|
|
36
60
|
|
|
37
|
-
/**
|
|
38
|
-
|
|
39
|
-
*
|
|
40
|
-
* `artifact` must match `scripts/release-npm.mts` exactly — that script renames what
|
|
41
|
-
* Tauri produced (`Centralu_0.1.0-beta.2_amd64.AppImage`) to a fixed name precisely so
|
|
42
|
-
* this file does not have to know version or architecture. If the two ever disagree the
|
|
43
|
-
* symptom is "installed fine, does nothing", so keep them in one place mentally.
|
|
44
|
-
*
|
|
45
|
-
* Only combinations that are actually published belong here. Listing a package that was
|
|
46
|
-
* never released would turn "not supported yet" into "your install is broken", which
|
|
47
|
-
* sends the user off to reinstall something that can never appear.
|
|
48
|
-
*/
|
|
49
|
-
const TARGETS = {
|
|
50
|
-
'darwin-arm64': { pkg: 'centralu-darwin-arm64', artifact: BUNDLE },
|
|
51
|
-
'linux-arm64': { pkg: 'centralu-linux-arm64', artifact: `${APP_NAME}.AppImage` },
|
|
52
|
-
'linux-x64': { pkg: 'centralu-linux-x64', artifact: `${APP_NAME}.AppImage` },
|
|
53
|
-
}
|
|
61
|
+
/** Where `centralu install` copies the app on Windows (see `windowsInstall`) */
|
|
62
|
+
const WIN = PLATFORM === 'win32' ? windowsInstall(process.env, HOME) : null
|
|
54
63
|
|
|
55
|
-
|
|
64
|
+
/** The arch package for this machine (the table lives in platform.mjs) */
|
|
65
|
+
const TARGET = targetFor(PLATFORM, process.arch)
|
|
56
66
|
|
|
57
67
|
/** The app inside the architecture-specific package. null if it cannot be found. */
|
|
58
68
|
function bundledApp() {
|
|
@@ -64,7 +74,7 @@ function bundledApp() {
|
|
|
64
74
|
return null // The package for this architecture is not installed — the reason is explained below.
|
|
65
75
|
}
|
|
66
76
|
const app = join(root, TARGET.artifact)
|
|
67
|
-
return existsSync(app) ? app : null
|
|
77
|
+
return existsSync(executableIn(PLATFORM, app)) ? app : null
|
|
68
78
|
}
|
|
69
79
|
|
|
70
80
|
/**
|
|
@@ -75,7 +85,7 @@ function explainMissing() {
|
|
|
75
85
|
if (!TARGET) {
|
|
76
86
|
// Kept as its own case: "not published for your machine" and "not published for
|
|
77
87
|
// Intel Macs, and here is the reason" send the user to different places.
|
|
78
|
-
if (
|
|
88
|
+
if (PLATFORM === 'darwin') {
|
|
79
89
|
return (
|
|
80
90
|
`${APP_NAME} is Apple Silicon only for now (current architecture: ${process.arch}).\n` +
|
|
81
91
|
'Intel Mac support needs native addons bundled in as well, and is tracked as separate work.'
|
|
@@ -83,7 +93,7 @@ function explainMissing() {
|
|
|
83
93
|
}
|
|
84
94
|
const supported = Object.keys(TARGETS).join(', ')
|
|
85
95
|
return (
|
|
86
|
-
`${APP_NAME} does not support ${
|
|
96
|
+
`${APP_NAME} does not support ${PLATFORM}/${process.arch} yet.\n` +
|
|
87
97
|
`Currently supported combinations: ${supported}\n` +
|
|
88
98
|
'Progress is tracked at https://github.com/ijun17/centralu/issues/14.'
|
|
89
99
|
)
|
|
@@ -104,21 +114,27 @@ function requireApp() {
|
|
|
104
114
|
}
|
|
105
115
|
|
|
106
116
|
/**
|
|
107
|
-
* Launches the app. If it is installed in `/Applications
|
|
117
|
+
* Launches the app. If it is installed in `/Applications` (macOS) or
|
|
118
|
+
* `%LOCALAPPDATA%\Programs\Centralu` (Windows), that copy is used first.
|
|
108
119
|
*
|
|
109
|
-
*
|
|
120
|
+
* Linux has no such preference, and not for lack of an equivalent: `centralu install`
|
|
110
121
|
* on Linux writes a launcher that points back at this same package, so there is never a
|
|
111
122
|
* second copy to prefer.
|
|
123
|
+
*
|
|
124
|
+
* Resolves once the launcher has nothing more to wait for. Only Windows waits at all —
|
|
125
|
+
* briefly, to report an exe that dies on start (see runWindows).
|
|
112
126
|
*/
|
|
113
|
-
function run(args) {
|
|
114
|
-
if (
|
|
127
|
+
async function run(args) {
|
|
128
|
+
if (PLATFORM === 'darwin') {
|
|
115
129
|
const app = existsSync(INSTALLED) ? INSTALLED : requireApp()
|
|
116
130
|
// `open` goes through LaunchServices — the dock icon and single-instance behavior only
|
|
117
131
|
// work correctly that way.
|
|
118
|
-
const
|
|
132
|
+
const plan = launchPlan(PLATFORM, app, args, HOME)
|
|
133
|
+
const r = spawn(plan.command, plan.args, plan.options)
|
|
119
134
|
r.on('exit', (code) => process.exit(code ?? 0))
|
|
120
135
|
return
|
|
121
136
|
}
|
|
137
|
+
if (PLATFORM === 'win32') return runWindows(args)
|
|
122
138
|
// Linux has no LaunchServices — the AppImage is its own launcher, so run it directly.
|
|
123
139
|
//
|
|
124
140
|
// Staying attached to the terminal is on purpose. The two usual first failures on
|
|
@@ -127,7 +143,8 @@ function run(args) {
|
|
|
127
143
|
// missing system library from the loader. Detaching would hand back the shell prompt
|
|
128
144
|
// and throw away the one line that says what to do.
|
|
129
145
|
const app = requireApp()
|
|
130
|
-
const
|
|
146
|
+
const plan = launchPlan(PLATFORM, app, args, HOME)
|
|
147
|
+
const r = spawn(plan.command, plan.args, plan.options)
|
|
131
148
|
r.on('error', (e) => {
|
|
132
149
|
console.error(`Failed to run ${app}: ${e.message}`)
|
|
133
150
|
// EACCES here means the executable bit did not survive the trip through npm, which
|
|
@@ -138,6 +155,68 @@ function run(args) {
|
|
|
138
155
|
r.on('exit', (code) => process.exit(code ?? 0))
|
|
139
156
|
}
|
|
140
157
|
|
|
158
|
+
/** How long to watch a freshly started exe for an immediate exit */
|
|
159
|
+
const EARLY_EXIT_MS = 3000
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Windows: check WebView2, start `centralu.exe` detached, and watch it for a few seconds.
|
|
163
|
+
*
|
|
164
|
+
* The app is a GUI program, so a failure to start has nowhere to print. Without WebView2
|
|
165
|
+
* the exe ends before it has a window, and the person sees nothing happen at all — the
|
|
166
|
+
* registry check turns that into a message with the download link before anything starts.
|
|
167
|
+
* The short watch afterwards catches whatever the registry cannot see (a broken runtime, a
|
|
168
|
+
* blocked exe), then lets go so the app outlives this launcher.
|
|
169
|
+
*/
|
|
170
|
+
async function runWindows(args) {
|
|
171
|
+
const webview = webview2Status(process.env, queryRegistry)
|
|
172
|
+
if (webview.state === 'missing') {
|
|
173
|
+
console.error(webview2MissingMessage())
|
|
174
|
+
process.exit(1)
|
|
175
|
+
}
|
|
176
|
+
const app = existsSync(WIN.exe) ? WIN.dir : requireApp()
|
|
177
|
+
const plan = launchPlan(PLATFORM, app, args, HOME)
|
|
178
|
+
const child = spawn(plan.command, plan.args, plan.options)
|
|
179
|
+
await new Promise((resolve) => {
|
|
180
|
+
const done = () => {
|
|
181
|
+
clearTimeout(timer)
|
|
182
|
+
child.removeAllListeners()
|
|
183
|
+
child.unref()
|
|
184
|
+
resolve()
|
|
185
|
+
}
|
|
186
|
+
const timer = setTimeout(done, EARLY_EXIT_MS)
|
|
187
|
+
child.on('error', (e) => {
|
|
188
|
+
console.error(`Failed to start ${plan.command}: ${e.message}`)
|
|
189
|
+
process.exitCode = 1
|
|
190
|
+
done()
|
|
191
|
+
})
|
|
192
|
+
child.on('exit', (code) => {
|
|
193
|
+
// 0 is a normal answer this early: a second start hands over to the window that is
|
|
194
|
+
// already open and leaves.
|
|
195
|
+
if (code !== 0 && code !== null) {
|
|
196
|
+
console.error(earlyExitMessage(code, HOME))
|
|
197
|
+
process.exitCode = 1
|
|
198
|
+
}
|
|
199
|
+
done()
|
|
200
|
+
})
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* `reg query <key> /v pv`: stdout, null when the key or value does not exist (reg exits 1),
|
|
206
|
+
* undefined when reg itself could not run.
|
|
207
|
+
*/
|
|
208
|
+
function queryRegistry(key) {
|
|
209
|
+
try {
|
|
210
|
+
return execFileSync(regExe(process.env), ['query', key, '/v', 'pv'], {
|
|
211
|
+
encoding: 'utf8',
|
|
212
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
213
|
+
windowsHide: true,
|
|
214
|
+
})
|
|
215
|
+
} catch (e) {
|
|
216
|
+
return typeof e?.status === 'number' ? null : undefined
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
141
220
|
/**
|
|
142
221
|
* Registers the app in the system menu.
|
|
143
222
|
*
|
|
@@ -147,7 +226,8 @@ function run(args) {
|
|
|
147
226
|
*/
|
|
148
227
|
function install() {
|
|
149
228
|
const app = requireApp()
|
|
150
|
-
if (
|
|
229
|
+
if (PLATFORM === 'win32') return installWindows(app)
|
|
230
|
+
if (PLATFORM !== 'darwin') return installDesktopEntry(app)
|
|
151
231
|
if (existsSync(INSTALLED)) {
|
|
152
232
|
console.log(`Replacing the existing ${INSTALLED} with the new version.`)
|
|
153
233
|
rmSync(INSTALLED, { recursive: true, force: true })
|
|
@@ -193,16 +273,78 @@ function installDesktopEntry(app) {
|
|
|
193
273
|
console.log('It can now be found in the app list too (some desktops require logging back in before it shows up).')
|
|
194
274
|
}
|
|
195
275
|
|
|
276
|
+
/**
|
|
277
|
+
* The Windows answer to "copy it into /Applications": copy the folder into
|
|
278
|
+
* `%LOCALAPPDATA%\Programs\Centralu` and put a shortcut in the Start menu.
|
|
279
|
+
*
|
|
280
|
+
* A copy rather than a shortcut into the npm package (the Linux shape), because Windows
|
|
281
|
+
* will not replace a running program's files: with the app running from the package,
|
|
282
|
+
* `npm i -g centralu@newer` fails. Running from the copy leaves the package free to update.
|
|
283
|
+
*
|
|
284
|
+
* The old copy is never deleted before the new one is complete. The new folder is
|
|
285
|
+
* assembled beside it, the old one is renamed aside, and only then is the new one moved in
|
|
286
|
+
* — so a copy that fails halfway, or an app that is still running, leaves the working
|
|
287
|
+
* install untouched rather than a folder with half its files.
|
|
288
|
+
*/
|
|
289
|
+
function installWindows(app) {
|
|
290
|
+
const staging = `${WIN.dir}.new`
|
|
291
|
+
const aside = `${WIN.dir}.old-${Date.now()}`
|
|
292
|
+
rmSync(staging, { recursive: true, force: true })
|
|
293
|
+
mkdirSync(dirname(WIN.dir), { recursive: true })
|
|
294
|
+
cpSync(app, staging, { recursive: true })
|
|
295
|
+
writeFileSync(join(staging, 'centralu-version.txt'), `${pkg.version}\n`)
|
|
296
|
+
if (existsSync(WIN.dir)) {
|
|
297
|
+
console.log(`Replacing the existing ${WIN.dir} with the new version.`)
|
|
298
|
+
try {
|
|
299
|
+
renameSync(WIN.dir, aside)
|
|
300
|
+
} catch (e) {
|
|
301
|
+
rmSync(staging, { recursive: true, force: true })
|
|
302
|
+
if (!isBusyError(e)) throw e
|
|
303
|
+
console.error(busyMessage(WIN.dir))
|
|
304
|
+
process.exit(1)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
renameSync(staging, WIN.dir)
|
|
308
|
+
try {
|
|
309
|
+
rmSync(aside, { recursive: true, force: true })
|
|
310
|
+
} catch {
|
|
311
|
+
// A rename can succeed while the old copy is running; its files then stay locked until
|
|
312
|
+
// it quits. Harmless — it is no longer the one anything starts — but worth a line.
|
|
313
|
+
console.log(`The previous copy is still in use and was left at ${aside}. Delete it after quitting ${APP_NAME}.`)
|
|
314
|
+
}
|
|
315
|
+
const sc = shortcutCommand(process.env, { shortcut: WIN.shortcut, target: WIN.exe, workdir: HOME })
|
|
316
|
+
try {
|
|
317
|
+
execFileSync(sc.command, sc.args, { env: sc.env, stdio: ['ignore', 'ignore', 'inherit'], windowsHide: true })
|
|
318
|
+
} catch (e) {
|
|
319
|
+
console.log(`Installed: ${WIN.dir}`)
|
|
320
|
+
console.error(`Could not create the Start menu shortcut (${e.message}). Start ${WIN.exe} directly, or run \`centralu\`.`)
|
|
321
|
+
process.exit(1)
|
|
322
|
+
}
|
|
323
|
+
console.log(`Installed: ${WIN.dir}`)
|
|
324
|
+
console.log(`Start menu: ${WIN.shortcut}`)
|
|
325
|
+
console.log('It can now be found in Start and Windows search, and `centralu` starts this copy too.')
|
|
326
|
+
}
|
|
327
|
+
|
|
196
328
|
function uninstall() {
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
|
|
329
|
+
const paths = installedPaths(PLATFORM, process.env, HOME)
|
|
330
|
+
const present = paths.filter((p) => existsSync(p))
|
|
331
|
+
if (present.length === 0) {
|
|
332
|
+
console.log(`${paths[0]} does not exist — nothing to remove.`)
|
|
200
333
|
return
|
|
201
334
|
}
|
|
202
|
-
|
|
203
|
-
|
|
335
|
+
for (const installed of present) {
|
|
336
|
+
try {
|
|
337
|
+
rmSync(installed, { recursive: true, force: true })
|
|
338
|
+
} catch (e) {
|
|
339
|
+
if (PLATFORM !== 'win32' || !isBusyError(e)) throw e
|
|
340
|
+
console.error(busyMessage(installed))
|
|
341
|
+
process.exit(1)
|
|
342
|
+
}
|
|
343
|
+
console.log(`Removed: ${installed}`)
|
|
344
|
+
}
|
|
204
345
|
console.log('To remove the package itself: npm uninstall -g centralu')
|
|
205
|
-
|
|
346
|
+
const data = PLATFORM === 'win32' ? '%USERPROFILE%\\.centralu' : '~/.centralu'
|
|
347
|
+
console.log(`Conversation history is left in place (${data}). Remove it yourself if you want to.`)
|
|
206
348
|
}
|
|
207
349
|
|
|
208
350
|
/**
|
|
@@ -250,15 +392,26 @@ async function update() {
|
|
|
250
392
|
return
|
|
251
393
|
}
|
|
252
394
|
console.log(`Upgrading ${pkg.version} → ${latest}.`)
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
395
|
+
const npm = npmCommand(PLATFORM, ['i', '-g', `${pkg.name}@${latest}`])
|
|
396
|
+
try {
|
|
397
|
+
execFileSync(npm.command, npm.args, npm.options)
|
|
398
|
+
} catch (e) {
|
|
399
|
+
// On Windows the usual cause is the app running from inside the package: its files are
|
|
400
|
+
// locked, and npm cannot replace them. npm's own output (above) has the details.
|
|
401
|
+
if (PLATFORM === 'win32') console.error(`\n${busyMessage('the npm package')}`)
|
|
402
|
+
process.exit(typeof e?.status === 'number' ? e.status : 1)
|
|
403
|
+
}
|
|
404
|
+
// Someone with a copy in /Applications (or %LOCALAPPDATA%\Programs) needs that updated
|
|
405
|
+
// too, or the old version stays behind.
|
|
256
406
|
// On Linux the menu entry points at the package instead of a copy, so rewriting it is
|
|
257
407
|
// cheap — but it is still worth doing, because the Exec path is what would go stale.
|
|
258
|
-
|
|
408
|
+
//
|
|
409
|
+
// The *new* launcher does the refresh: this file has just been replaced on disk. It is
|
|
410
|
+
// run through `node` rather than as a script, because Windows cannot execute a `.mjs`.
|
|
411
|
+
const installed = installedPaths(PLATFORM, process.env, HOME)[0]
|
|
259
412
|
if (existsSync(installed)) {
|
|
260
413
|
console.log(`Updating ${installed} too.`)
|
|
261
|
-
execFileSync(process.argv[1],
|
|
414
|
+
execFileSync(process.execPath, [process.argv[1], 'install'], { stdio: 'inherit' })
|
|
262
415
|
}
|
|
263
416
|
console.log('Done. If the app is open, please restart it.')
|
|
264
417
|
}
|
|
@@ -270,9 +423,19 @@ async function update() {
|
|
|
270
423
|
* to write it as XML today, and if it ever switches to a binary plist, anything reading it
|
|
271
424
|
* with a regex would silently stop working. `defaults` is always present on a Mac and reads
|
|
272
425
|
* both formats.
|
|
426
|
+
*
|
|
427
|
+
* On Windows it is the file `centralu install` wrote beside the copy (see `windowsInstall`
|
|
428
|
+
* for why not the exe's own version).
|
|
273
429
|
*/
|
|
274
430
|
function installedCopyVersion() {
|
|
275
|
-
if (
|
|
431
|
+
if (PLATFORM === 'win32') {
|
|
432
|
+
try {
|
|
433
|
+
return readFileSync(WIN.versionFile, 'utf8').trim()
|
|
434
|
+
} catch {
|
|
435
|
+
return null
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
if (PLATFORM !== 'darwin' || !existsSync(INSTALLED)) return null
|
|
276
439
|
try {
|
|
277
440
|
const out = execFileSync('/usr/bin/defaults', ['read', join(INSTALLED, 'Contents/Info.plist'), 'CFBundleShortVersionString'], {
|
|
278
441
|
encoding: 'utf8',
|
|
@@ -300,7 +463,8 @@ function installedCopyVersion() {
|
|
|
300
463
|
function notifyIfCopyStale() {
|
|
301
464
|
const version = installedCopyVersion()
|
|
302
465
|
if (!copyDiffers(pkg.version, version)) return
|
|
303
|
-
|
|
466
|
+
const where = PLATFORM === 'win32' ? WIN.dir : '/Applications'
|
|
467
|
+
console.log(`\nThe ${where} copy is ${version} (this package is ${pkg.version}).\n centralu install`)
|
|
304
468
|
}
|
|
305
469
|
|
|
306
470
|
/** Only announced after the app has launched — checking for updates must never delay startup. */
|
|
@@ -316,7 +480,8 @@ async function notifyIfOutdated() {
|
|
|
316
480
|
const HELP = `${APP_NAME} ${pkg.version}
|
|
317
481
|
|
|
318
482
|
centralu Launches the app
|
|
319
|
-
centralu install Registers it in the app list (macOS: /Applications, Linux: a menu entry
|
|
483
|
+
centralu install Registers it in the app list (macOS: /Applications, Linux: a menu entry,
|
|
484
|
+
Windows: %LOCALAPPDATA%\\Programs\\Centralu and a Start menu shortcut)
|
|
320
485
|
centralu uninstall Removes that registration (conversation history is kept)
|
|
321
486
|
centralu update Upgrades if a new version is available
|
|
322
487
|
centralu --version Prints the version
|
|
@@ -343,8 +508,10 @@ switch (cmd) {
|
|
|
343
508
|
case 'help':
|
|
344
509
|
console.log(HELP)
|
|
345
510
|
break
|
|
346
|
-
default:
|
|
347
|
-
|
|
511
|
+
default: {
|
|
512
|
+
// Started before the update check, never after it: the check must not delay startup.
|
|
513
|
+
const launched = run(cmd ? [cmd, ...rest] : [])
|
|
348
514
|
notifyIfCopyStale()
|
|
349
|
-
await notifyIfOutdated()
|
|
515
|
+
await Promise.all([launched, notifyIfOutdated()])
|
|
516
|
+
}
|
|
350
517
|
}
|
package/bin/platform.mjs
ADDED
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The launcher's per-platform decisions, as plain functions of the platform.
|
|
3
|
+
*
|
|
4
|
+
* Lives apart from `centralu.mjs` for the same reason `semver.mjs` does: the launcher runs
|
|
5
|
+
* a command at import time, so a test cannot import it. Everything here takes the platform
|
|
6
|
+
* (and the environment, and the home folder) as an argument instead of reading `process`,
|
|
7
|
+
* so `tooling/launcher-platform.test.ts` can ask what Windows would do from a Mac — which
|
|
8
|
+
* is the only place this project can run a test before the Windows package ships.
|
|
9
|
+
*
|
|
10
|
+
* package.json "files" lists the whole bin/ directory, so this ships automatically.
|
|
11
|
+
*/
|
|
12
|
+
import { posix, win32 } from 'node:path'
|
|
13
|
+
|
|
14
|
+
export const APP_NAME = 'Centralu'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The arch package for each machine, and what the app is called inside it.
|
|
18
|
+
*
|
|
19
|
+
* `artifact` must match `scripts/release-npm.mts` exactly — that script renames what
|
|
20
|
+
* Tauri produced (`Centralu_0.1.0-beta.2_amd64.AppImage`) to a fixed name precisely so
|
|
21
|
+
* the launcher does not have to know version or architecture. If the two ever disagree
|
|
22
|
+
* the symptom is "installed fine, does nothing".
|
|
23
|
+
*
|
|
24
|
+
* Only combinations that are actually published belong here. Listing a package that was
|
|
25
|
+
* never released would turn "not supported yet" into "your install is broken", which
|
|
26
|
+
* sends the user off to reinstall something that can never appear.
|
|
27
|
+
* `tooling/launcher-platform.test.ts` holds this table to the shim's `optionalDependencies`.
|
|
28
|
+
*
|
|
29
|
+
* Windows ships a folder, not a single file: `Centralu\centralu.exe` beside
|
|
30
|
+
* `Centralu\resources\host\`. Tauri looks for its resources next to the exe when nothing
|
|
31
|
+
* is installed, so the folder runs where npm unpacked it (#14, W1 in #307).
|
|
32
|
+
*/
|
|
33
|
+
export const TARGETS = {
|
|
34
|
+
'darwin-arm64': { pkg: 'centralu-darwin-arm64', artifact: `${APP_NAME}.app` },
|
|
35
|
+
'linux-arm64': { pkg: 'centralu-linux-arm64', artifact: `${APP_NAME}.AppImage` },
|
|
36
|
+
'linux-x64': { pkg: 'centralu-linux-x64', artifact: `${APP_NAME}.AppImage` },
|
|
37
|
+
'win32-x64': { pkg: '@centralu/win32-x64', artifact: APP_NAME },
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The target for a platform/arch pair, or null when nothing is published for it. */
|
|
41
|
+
export function targetFor(platform, arch) {
|
|
42
|
+
return TARGETS[`${platform}-${arch}`] ?? null
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The file that proves the artifact is there and is what gets started.
|
|
47
|
+
*
|
|
48
|
+
* On macOS and Linux that is the artifact itself (a bundle handed to `open`, an AppImage
|
|
49
|
+
* run directly). On Windows the artifact is a folder, and a folder that exists without its
|
|
50
|
+
* exe — an interrupted extract — must count as missing, not as found.
|
|
51
|
+
*/
|
|
52
|
+
export function executableIn(platform, app) {
|
|
53
|
+
return platform === 'win32' ? win32.join(app, 'centralu.exe') : app
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* How to start the app: the command, its arguments, the spawn options, and whether the
|
|
58
|
+
* launcher stays attached until it exits.
|
|
59
|
+
*
|
|
60
|
+
* - macOS goes through `open`, so LaunchServices handles the dock icon and single instance.
|
|
61
|
+
* - Linux runs the AppImage attached to the terminal, because its two usual first failures
|
|
62
|
+
* (missing FUSE, a missing system library) explain themselves on stderr and nowhere else.
|
|
63
|
+
* - Windows starts `centralu.exe` **detached**. That is not only about handing the prompt
|
|
64
|
+
* back: libuv puts every non-detached child in a job object that kills it when the parent
|
|
65
|
+
* exits, so an attached app would die the moment this launcher returns, or when the
|
|
66
|
+
* console window is closed. `centralu.exe` is a GUI-subsystem program (checked by
|
|
67
|
+
* `scripts/release-npm.mts`), so no console window appears for it either way.
|
|
68
|
+
* `windowsHide` stays off on purpose: it starts the process with SW_HIDE, which a GUI
|
|
69
|
+
* program applies to its first window — the app would start invisible.
|
|
70
|
+
* The working directory is the home folder, because a process holds its working
|
|
71
|
+
* directory open on Windows, and that would stop the shell's folder from being renamed
|
|
72
|
+
* or deleted (and `centralu install` from replacing the app's own folder).
|
|
73
|
+
*/
|
|
74
|
+
export function launchPlan(platform, app, args, home) {
|
|
75
|
+
if (platform === 'darwin') {
|
|
76
|
+
return {
|
|
77
|
+
command: 'open',
|
|
78
|
+
args: ['-a', app, ...(args.length ? ['--args', ...args] : [])],
|
|
79
|
+
options: { stdio: 'inherit' },
|
|
80
|
+
attached: true,
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
if (platform === 'win32') {
|
|
84
|
+
return {
|
|
85
|
+
command: executableIn(platform, app),
|
|
86
|
+
args,
|
|
87
|
+
options: { detached: true, stdio: 'ignore', windowsHide: false, cwd: home },
|
|
88
|
+
attached: false,
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { command: app, args, options: { stdio: 'inherit' }, attached: true }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Where `centralu install` puts the app on Windows, and what it writes there.
|
|
96
|
+
*
|
|
97
|
+
* The analogue of copying the bundle into `/Applications` on macOS, not of the Linux menu
|
|
98
|
+
* entry that points back into the npm package. The reason is Windows-specific: a running
|
|
99
|
+
* program's files cannot be replaced, so while the app runs from inside the npm package,
|
|
100
|
+
* `npm i -g centralu@newer` fails with EBUSY. A copy elsewhere is what lets the package
|
|
101
|
+
* update while the app is open.
|
|
102
|
+
*
|
|
103
|
+
* `%LOCALAPPDATA%\Programs` is the per-user program folder (VS Code's user installer and
|
|
104
|
+
* winget's user scope use it). No admin rights, nothing outside the user's own profile.
|
|
105
|
+
* The Start menu entry is a shortcut in the per-user Start menu folder; Windows search
|
|
106
|
+
* indexes it.
|
|
107
|
+
*
|
|
108
|
+
* `versionFile` records which package version the copy came from, so the launcher can say
|
|
109
|
+
* when the copy is behind — the exe's own version resource does not carry a prerelease tag.
|
|
110
|
+
*/
|
|
111
|
+
export function windowsInstall(env, home) {
|
|
112
|
+
const local = env.LOCALAPPDATA || win32.join(home, 'AppData', 'Local')
|
|
113
|
+
const roaming = env.APPDATA || win32.join(home, 'AppData', 'Roaming')
|
|
114
|
+
const dir = win32.join(local, 'Programs', APP_NAME)
|
|
115
|
+
return {
|
|
116
|
+
dir,
|
|
117
|
+
shortcut: win32.join(roaming, 'Microsoft', 'Windows', 'Start Menu', 'Programs', `${APP_NAME}.lnk`),
|
|
118
|
+
exe: win32.join(dir, 'centralu.exe'),
|
|
119
|
+
versionFile: win32.join(dir, 'centralu-version.txt'),
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* What `centralu install` creates, per platform. `uninstall` removes exactly these and
|
|
125
|
+
* `update` refreshes them when the first one exists.
|
|
126
|
+
*/
|
|
127
|
+
export function installedPaths(platform, env, home) {
|
|
128
|
+
if (platform === 'darwin') return [`/Applications/${APP_NAME}.app`]
|
|
129
|
+
if (platform === 'win32') {
|
|
130
|
+
const w = windowsInstall(env, home)
|
|
131
|
+
return [w.dir, w.shortcut]
|
|
132
|
+
}
|
|
133
|
+
return [posix.join(home, '.local/share/applications/centralu.desktop')]
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function systemRoot(env) {
|
|
137
|
+
return env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows'
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A PowerShell invocation that writes the Start-menu shortcut.
|
|
142
|
+
*
|
|
143
|
+
* Windows has no file format for a shortcut that Node can write; `.lnk` is a binary shell
|
|
144
|
+
* format, and the supported way to write one is the WScript.Shell COM object. The paths go
|
|
145
|
+
* in through environment variables rather than the script text, so a profile path with a
|
|
146
|
+
* space or an apostrophe (`C:\Users\O'Brien Kim\…`) needs no quoting at all — the one way
|
|
147
|
+
* this cannot be got wrong. PowerShell is named by its full path under `%SystemRoot%` so a
|
|
148
|
+
* `powershell.exe` earlier on PATH (or in the current folder) is never the one that runs.
|
|
149
|
+
*/
|
|
150
|
+
export function shortcutCommand(env, { shortcut, target, workdir }) {
|
|
151
|
+
const script = [
|
|
152
|
+
'$ErrorActionPreference = "Stop"',
|
|
153
|
+
'New-Item -ItemType Directory -Force -Path (Split-Path -Parent $env:CENTRALU_SHORTCUT) | Out-Null',
|
|
154
|
+
'$s = (New-Object -ComObject WScript.Shell).CreateShortcut($env:CENTRALU_SHORTCUT)',
|
|
155
|
+
'$s.TargetPath = $env:CENTRALU_TARGET',
|
|
156
|
+
'$s.WorkingDirectory = $env:CENTRALU_WORKDIR',
|
|
157
|
+
'$s.IconLocation = "$env:CENTRALU_TARGET,0"',
|
|
158
|
+
`$s.Description = "${APP_NAME}"`,
|
|
159
|
+
'$s.Save()',
|
|
160
|
+
].join('; ')
|
|
161
|
+
return {
|
|
162
|
+
command: win32.join(systemRoot(env), 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
|
163
|
+
args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
164
|
+
env: { ...env, CENTRALU_SHORTCUT: shortcut, CENTRALU_TARGET: target, CENTRALU_WORKDIR: workdir },
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* How to run npm from the launcher (`centralu update`).
|
|
170
|
+
*
|
|
171
|
+
* On Windows `npm` is `npm.cmd`, and Node refuses to start a `.cmd` without a shell
|
|
172
|
+
* (EINVAL, since the April 2024 security releases) — and without one it would not find it
|
|
173
|
+
* either, as libuv only tries `.com` and `.exe`. So Windows goes through the shell, and
|
|
174
|
+
* because a shell re-parses its command line, every argument has to be a plain token. The
|
|
175
|
+
* version comes from the registry's response, so that is checked rather than trusted.
|
|
176
|
+
*/
|
|
177
|
+
export function npmCommand(platform, args) {
|
|
178
|
+
for (const a of args) {
|
|
179
|
+
if (!/^[\w@./:+-]+$/.test(a)) throw new Error(`refusing to pass an unexpected argument to npm: ${JSON.stringify(a)}`)
|
|
180
|
+
}
|
|
181
|
+
return platform === 'win32'
|
|
182
|
+
? { command: 'npm', args, options: { stdio: 'inherit', shell: true } }
|
|
183
|
+
: { command: 'npm', args, options: { stdio: 'inherit' } }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export const WEBVIEW2_DOWNLOAD = 'https://developer.microsoft.com/microsoft-edge/webview2/'
|
|
187
|
+
/** Microsoft's link to the Evergreen Bootstrapper itself, for someone who wants one click. */
|
|
188
|
+
export const WEBVIEW2_BOOTSTRAPPER = 'https://go.microsoft.com/fwlink/p/?LinkId=2124703'
|
|
189
|
+
|
|
190
|
+
/** The Evergreen WebView2 Runtime's client id, the same under every registry hive. */
|
|
191
|
+
const WEBVIEW2_CLIENT = '{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}'
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Where an installed WebView2 Runtime registers itself: per machine (the 32-bit view on
|
|
195
|
+
* 64-bit Windows, which is where the installer writes), per machine on 32-bit Windows, and
|
|
196
|
+
* per user. This is Microsoft's documented detection
|
|
197
|
+
* (learn.microsoft.com/microsoft-edge/webview2/concepts/distribution, "Detect if a WebView2
|
|
198
|
+
* Runtime is already installed").
|
|
199
|
+
*/
|
|
200
|
+
export const WEBVIEW2_KEYS = [
|
|
201
|
+
`HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\${WEBVIEW2_CLIENT}`,
|
|
202
|
+
`HKLM\\SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\${WEBVIEW2_CLIENT}`,
|
|
203
|
+
`HKCU\\Software\\Microsoft\\EdgeUpdate\\Clients\\${WEBVIEW2_CLIENT}`,
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
/** `reg.exe` by full path, for the same reason PowerShell is. */
|
|
207
|
+
export function regExe(env) {
|
|
208
|
+
return win32.join(systemRoot(env), 'System32', 'reg.exe')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The `pv` value out of `reg query <key> /v pv`, or null. Microsoft's rule: the runtime is
|
|
213
|
+
* installed when `pv` exists and is neither empty nor `0.0.0.0` (an uninstall can leave
|
|
214
|
+
* the key behind with that value).
|
|
215
|
+
*/
|
|
216
|
+
export function parseRegPv(stdout) {
|
|
217
|
+
const m = /^\s*pv\s+REG_SZ\s+(\S*)\s*$/im.exec(String(stdout ?? ''))
|
|
218
|
+
const v = m?.[1] ?? ''
|
|
219
|
+
return v && v !== '0.0.0.0' ? v : null
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Whether WebView2 is there, from a `query(key)` that returns `reg`'s stdout, `null` when
|
|
224
|
+
* the key does not exist, or `undefined` when `reg` itself could not run.
|
|
225
|
+
*
|
|
226
|
+
* Three answers, not two. "Missing" stops the launch with a message, so it has to be a
|
|
227
|
+
* positive finding — every hive was readable and none has the runtime. If `reg` could not
|
|
228
|
+
* run at all the answer is "unknown" and the app starts anyway: refusing to launch because
|
|
229
|
+
* a check broke would be worse than the blank window the check exists to explain.
|
|
230
|
+
*
|
|
231
|
+
* `WEBVIEW2_BROWSER_EXECUTABLE_FOLDER` points WebView2 at a fixed-version runtime that the
|
|
232
|
+
* registry knows nothing about; when it is set, the person has made that choice.
|
|
233
|
+
*/
|
|
234
|
+
export function webview2Status(env, query) {
|
|
235
|
+
if (env.WEBVIEW2_BROWSER_EXECUTABLE_FOLDER) return { state: 'present', version: 'fixed' }
|
|
236
|
+
let unknown = false
|
|
237
|
+
for (const key of WEBVIEW2_KEYS) {
|
|
238
|
+
const out = query(key)
|
|
239
|
+
if (out === undefined) {
|
|
240
|
+
unknown = true
|
|
241
|
+
continue
|
|
242
|
+
}
|
|
243
|
+
const version = parseRegPv(out)
|
|
244
|
+
if (version) return { state: 'present', version }
|
|
245
|
+
}
|
|
246
|
+
return { state: unknown ? 'unknown' : 'missing' }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export function webview2MissingMessage() {
|
|
250
|
+
return (
|
|
251
|
+
`${APP_NAME} needs the Microsoft Edge WebView2 Runtime, and it is not installed on this machine.\n` +
|
|
252
|
+
'Windows 11 includes it; some Windows 10 installs do not.\n' +
|
|
253
|
+
`Install the "Evergreen Bootstrapper" from ${WEBVIEW2_DOWNLOAD}\n` +
|
|
254
|
+
`(direct download: ${WEBVIEW2_BOOTSTRAPPER}),\n` +
|
|
255
|
+
'then run `centralu` again.'
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* What to say when the exe exits nonzero within moments of starting.
|
|
261
|
+
*
|
|
262
|
+
* The launcher is detached by then, so this is the only place a failure to start can be
|
|
263
|
+
* reported at all — a GUI program has no stderr to read. The registry check catches the
|
|
264
|
+
* usual cause before the start; this catches the rest (a runtime that is registered but
|
|
265
|
+
* broken, a blocked exe, a crash before the window).
|
|
266
|
+
*/
|
|
267
|
+
export function earlyExitMessage(code, home) {
|
|
268
|
+
return (
|
|
269
|
+
`${APP_NAME} exited right after starting (exit code ${code}).\n` +
|
|
270
|
+
`- If no window appeared, check the WebView2 Runtime: ${WEBVIEW2_DOWNLOAD}\n` +
|
|
271
|
+
`- The host log is ${win32.join(home, '.centralu', 'host.log')}\n` +
|
|
272
|
+
'Please report it at https://github.com/ijun17/centralu/issues/14 with that log.'
|
|
273
|
+
)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* What to say when Windows refuses to replace or remove the app's files.
|
|
278
|
+
*
|
|
279
|
+
* EBUSY / EPERM on a program folder almost always means the program is running: Windows
|
|
280
|
+
* locks a running exe and the native modules the host loaded. Macs and Linux let both be
|
|
281
|
+
* replaced underneath a running process, which is why this message exists only here.
|
|
282
|
+
*/
|
|
283
|
+
export function busyMessage(path) {
|
|
284
|
+
return (
|
|
285
|
+
`Windows would not replace ${path} — ${APP_NAME} is probably still running.\n` +
|
|
286
|
+
'Quit it (close the window and choose Quit), then run the command again.'
|
|
287
|
+
)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export function isBusyError(e) {
|
|
291
|
+
return e?.code === 'EBUSY' || e?.code === 'EPERM' || e?.code === 'EACCES' || e?.code === 'ENOTEMPTY'
|
|
292
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "centralu",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.9",
|
|
4
4
|
"description": "Run, watch and steer several Claude Code and Codex CLI sessions from one window",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "ijun17",
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
"agent",
|
|
19
19
|
"macos",
|
|
20
20
|
"linux",
|
|
21
|
+
"windows",
|
|
21
22
|
"desktop"
|
|
22
23
|
],
|
|
23
24
|
"type": "module",
|
|
@@ -32,11 +33,13 @@
|
|
|
32
33
|
},
|
|
33
34
|
"os": [
|
|
34
35
|
"darwin",
|
|
35
|
-
"linux"
|
|
36
|
+
"linux",
|
|
37
|
+
"win32"
|
|
36
38
|
],
|
|
37
39
|
"optionalDependencies": {
|
|
38
|
-
"centralu-darwin-arm64": "0.1.0-beta.
|
|
39
|
-
"centralu-linux-arm64": "0.1.0-beta.
|
|
40
|
-
"centralu-linux-x64": "0.1.0-beta.
|
|
40
|
+
"centralu-darwin-arm64": "0.1.0-beta.9",
|
|
41
|
+
"centralu-linux-arm64": "0.1.0-beta.9",
|
|
42
|
+
"centralu-linux-x64": "0.1.0-beta.9",
|
|
43
|
+
"@centralu/win32-x64": "0.1.0-beta.9"
|
|
41
44
|
}
|
|
42
45
|
}
|