centralu 0.1.0-beta.4 → 0.1.0-beta.41
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 +466 -117
- package/bin/node-floor.mjs +42 -0
- package/bin/platform.mjs +380 -0
- package/bin/semver.mjs +37 -4
- package/bin/serve-args.mjs +116 -0
- package/bin/serve-autostart.mjs +489 -0
- package/bin/serve-detach.mjs +289 -0
- package/bin/serve-files.mjs +299 -0
- package/bin/serve-handover.mjs +152 -0
- package/bin/serve-host.mjs +395 -0
- package/bin/serve-install.mjs +204 -0
- package/bin/serve-port.mjs +79 -0
- package/bin/serve-run.mjs +197 -0
- package/bin/serve-util.mjs +127 -0
- package/bin/serve-window.mjs +112 -0
- package/bin/serve-windows.mjs +171 -0
- package/bin/serve.mjs +37 -0
- package/bin/swap.mjs +48 -0
- package/package.json +10 -6
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The oldest Node the bundled host runs on, and what the launcher says when it is run on an older one.
|
|
3
|
+
*
|
|
4
|
+
* **The floor is `engines.node` of this package, read here and nowhere restated.** The host's native
|
|
5
|
+
* database module (better-sqlite3 13) declares Node-API 10, which Node has from 22.14.0. Under an
|
|
6
|
+
* older 22.x (and under 20) it loads, then crashes on its first use with an access violation
|
|
7
|
+
* (`0xC0000005`, exit 3221225477, on Windows) or SIGSEGV (measured on 22.12.0, Windows and macOS,
|
|
8
|
+
* issue #699), before the host logs a line. The hub reads the same value from its copy of this
|
|
9
|
+
* package's `package.json` (`packages/agent-host/src/links/node-floor.ts`), and
|
|
10
|
+
* `tooling/engines.test.ts` keeps the other declarations equal to it.
|
|
11
|
+
*
|
|
12
|
+
* Lives in its own file so it can be tested (tooling/launcher-node-floor.test.ts): the launcher
|
|
13
|
+
* itself runs commands at import time. Plain JavaScript on Node's own modules and `semver.mjs`.
|
|
14
|
+
*/
|
|
15
|
+
import { isNewer } from './semver.mjs'
|
|
16
|
+
|
|
17
|
+
/** `>=22.14.0` or `>=22.14` → `22.14.0`; throws on any other shape, which `engines` must not take */
|
|
18
|
+
export function floorOf(range) {
|
|
19
|
+
const m = /^>=\s*(\d+)\.(\d+)(?:\.(\d+))?$/.exec(String(range).trim())
|
|
20
|
+
if (!m) throw new Error(`engines.node must be a plain floor like ">=22.14.0", not ${JSON.stringify(range)}`)
|
|
21
|
+
return `${m[1]}.${m[2]}.${m[3] ?? '0'}`
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Whether `found` (`22.12.0` or `v22.12.0`) is older than `floor`; a version that does not parse is let through */
|
|
25
|
+
export function belowFloor(found, floor) {
|
|
26
|
+
const v = String(found).trim().replace(/^v/i, '')
|
|
27
|
+
if (!/^\d+\.\d+\.\d+/.test(v)) return false
|
|
28
|
+
return isNewer(floor, v)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The one sentence for a launcher that will not start the host on this Node: the Node it found, the
|
|
33
|
+
* floor, and the two ways out. It names no command, since it is also what the hub reads out of the
|
|
34
|
+
* `--detach` answer.
|
|
35
|
+
*/
|
|
36
|
+
export function oldNodeSentence(found, floor) {
|
|
37
|
+
const v = String(found).replace(/^v/i, '')
|
|
38
|
+
return (
|
|
39
|
+
`centralu serve needs Node ${floor} or newer and this is Node ${v}: update Node on this machine, ` +
|
|
40
|
+
'or link this machine from a Centralu window on another computer, which installs a Node of its own there.'
|
|
41
|
+
)
|
|
42
|
+
}
|
package/bin/platform.mjs
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
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
|
+
* Whether replacing a file by rename fails while another program has the target open (a reader, a virus scanner):
|
|
47
|
+
* Windows, which then answers EPERM, EBUSY or EACCES for a moment. Elsewhere a rename over an open file just works.
|
|
48
|
+
*/
|
|
49
|
+
export function renameWaitsForReaders(platform = process.platform) {
|
|
50
|
+
return platform === 'win32'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The file that proves the artifact is there and is what gets started.
|
|
55
|
+
*
|
|
56
|
+
* On macOS and Linux that is the artifact itself (a bundle handed to `open`, an AppImage
|
|
57
|
+
* run directly). On Windows the artifact is a folder, and a folder that exists without its
|
|
58
|
+
* exe — an interrupted extract — must count as missing, not as found.
|
|
59
|
+
*/
|
|
60
|
+
export function executableIn(platform, app) {
|
|
61
|
+
return platform === 'win32' ? win32.join(app, 'centralu.exe') : app
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The bundled host (`resources/host`, the folder whose `main.mjs` the app runs) inside a
|
|
66
|
+
* platform package, for `centralu serve` (#82).
|
|
67
|
+
*
|
|
68
|
+
* - macOS: inside the bundle, where Tauri puts `bundle.resources`.
|
|
69
|
+
* - Windows: beside the exe, which is already how the folder ships (W1 in #307).
|
|
70
|
+
* - Linux: a `host/` folder next to the AppImage. The host inside the AppImage sits in a
|
|
71
|
+
* squashfs that is only reachable by mounting it (FUSE, which headless servers often lack)
|
|
72
|
+
* or by extracting it on every start, so `scripts/release-npm.mts` ships the same folder a
|
|
73
|
+
* second time, unpacked. It is 11 MB; the AppImage is several times that.
|
|
74
|
+
*/
|
|
75
|
+
export function hostDirIn(platform, app) {
|
|
76
|
+
if (platform === 'darwin') return posix.join(app, 'Contents', 'Resources', 'resources', 'host')
|
|
77
|
+
if (platform === 'win32') return win32.join(app, 'resources', 'host')
|
|
78
|
+
return posix.join(posix.dirname(app), 'host')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The host alone, without the window, for the machines a hub installs on (docs/plans/remote-hub.md
|
|
83
|
+
* §10.2, owner decision 2 of §10.9): `host/` at the top of the package on every platform. The shim
|
|
84
|
+
* never pins these (an npm install gets the host inside its platform package already); a hub's
|
|
85
|
+
* installer fetches one beside this package instead of the platform package, so a Linux remote does
|
|
86
|
+
* not download an 81 MB AppImage to throw away. Published from the same release jobs as the platform
|
|
87
|
+
* packages (`scripts/host-package.mts`); `tooling/host-package.test.ts` holds this table to them.
|
|
88
|
+
*/
|
|
89
|
+
export const HOST_PACKAGES = {
|
|
90
|
+
'linux-arm64': '@centralu/host-linux-arm64',
|
|
91
|
+
'linux-x64': '@centralu/host-linux-x64',
|
|
92
|
+
'win32-x64': '@centralu/host-win32-x64',
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Where `centralu serve` finds the host's `main.mjs`: in the platform package (an npm install), else
|
|
97
|
+
* in the host-only package (what a hub installed, `<data>/remote/versions/<v>/node_modules/`).
|
|
98
|
+
*
|
|
99
|
+
* `resolveRoot(name)` is the package's folder or null when it is not installed; `exists(file)` says
|
|
100
|
+
* whether a file is there. Answers `{ entry }`, or `{ missingHost: { pkg, entry } }` when a package
|
|
101
|
+
* is installed without its host, or `{ missingPackage: true }` when neither is installed.
|
|
102
|
+
*/
|
|
103
|
+
export function findHostEntry(platform, arch, { resolveRoot, exists }) {
|
|
104
|
+
const join = platform === 'win32' ? win32.join : posix.join
|
|
105
|
+
const target = targetFor(platform, arch)
|
|
106
|
+
const hostOnly = HOST_PACKAGES[`${platform}-${arch}`]
|
|
107
|
+
const places = []
|
|
108
|
+
if (target) places.push({ pkg: target.pkg, dir: (root) => hostDirIn(platform, join(root, target.artifact)) })
|
|
109
|
+
if (hostOnly) places.push({ pkg: hostOnly, dir: (root) => join(root, 'host') })
|
|
110
|
+
let missingHost = null
|
|
111
|
+
for (const place of places) {
|
|
112
|
+
const root = resolveRoot(place.pkg)
|
|
113
|
+
if (!root) continue
|
|
114
|
+
const entry = join(place.dir(root), 'main.mjs')
|
|
115
|
+
if (exists(entry)) return { entry }
|
|
116
|
+
missingHost ??= { pkg: place.pkg, entry }
|
|
117
|
+
}
|
|
118
|
+
return missingHost ? { missingHost } : { missingPackage: true }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* How to start the app: the command, its arguments, the spawn options, and whether the
|
|
123
|
+
* launcher stays attached until it exits.
|
|
124
|
+
*
|
|
125
|
+
* - macOS goes through `open`, so LaunchServices handles the dock icon and single instance.
|
|
126
|
+
* - Linux runs the AppImage attached to the terminal, because its two usual first failures
|
|
127
|
+
* (missing FUSE, a missing system library) explain themselves on stderr and nowhere else.
|
|
128
|
+
* - Windows starts `centralu.exe` **detached**. That is not only about handing the prompt
|
|
129
|
+
* back: libuv puts every non-detached child in a job object that kills it when the parent
|
|
130
|
+
* exits, so an attached app would die the moment this launcher returns, or when the
|
|
131
|
+
* console window is closed. `centralu.exe` is a GUI-subsystem program (checked by
|
|
132
|
+
* `scripts/release-npm.mts`), so no console window appears for it either way.
|
|
133
|
+
* `windowsHide` stays off on purpose: it starts the process with SW_HIDE, which a GUI
|
|
134
|
+
* program applies to its first window — the app would start invisible.
|
|
135
|
+
* The working directory is the home folder, because a process holds its working
|
|
136
|
+
* directory open on Windows, and that would stop the shell's folder from being renamed
|
|
137
|
+
* or deleted (and `centralu install` from replacing the app's own folder).
|
|
138
|
+
*/
|
|
139
|
+
export function launchPlan(platform, app, args, home) {
|
|
140
|
+
if (platform === 'darwin') {
|
|
141
|
+
return {
|
|
142
|
+
command: 'open',
|
|
143
|
+
args: ['-a', app, ...(args.length ? ['--args', ...args] : [])],
|
|
144
|
+
options: { stdio: 'inherit' },
|
|
145
|
+
attached: true,
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (platform === 'win32') {
|
|
149
|
+
return {
|
|
150
|
+
command: executableIn(platform, app),
|
|
151
|
+
args,
|
|
152
|
+
options: { detached: true, stdio: 'ignore', windowsHide: false, cwd: home },
|
|
153
|
+
attached: false,
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { command: app, args, options: { stdio: 'inherit' }, attached: true }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Where `centralu install` puts the app on Windows, and what it writes there.
|
|
161
|
+
*
|
|
162
|
+
* The analogue of copying the bundle into `/Applications` on macOS, not of the Linux menu
|
|
163
|
+
* entry that points back into the npm package. The reason is Windows-specific: a running
|
|
164
|
+
* program's files cannot be replaced, so while the app runs from inside the npm package,
|
|
165
|
+
* `npm i -g centralu@newer` fails with EBUSY. A copy elsewhere is what lets the package
|
|
166
|
+
* update while the app is open.
|
|
167
|
+
*
|
|
168
|
+
* `%LOCALAPPDATA%\Programs` is the per-user program folder (VS Code's user installer and
|
|
169
|
+
* winget's user scope use it). No admin rights, nothing outside the user's own profile.
|
|
170
|
+
* The Start menu entry is a shortcut in the per-user Start menu folder; Windows search
|
|
171
|
+
* indexes it.
|
|
172
|
+
*
|
|
173
|
+
* `versionFile` records which package version the copy came from, so the launcher can say
|
|
174
|
+
* when the copy is behind — the exe's own version resource does not carry a prerelease tag.
|
|
175
|
+
*/
|
|
176
|
+
export function windowsInstall(env, home) {
|
|
177
|
+
const local = env.LOCALAPPDATA || win32.join(home, 'AppData', 'Local')
|
|
178
|
+
const roaming = env.APPDATA || win32.join(home, 'AppData', 'Roaming')
|
|
179
|
+
const dir = win32.join(local, 'Programs', APP_NAME)
|
|
180
|
+
return {
|
|
181
|
+
dir,
|
|
182
|
+
shortcut: win32.join(roaming, 'Microsoft', 'Windows', 'Start Menu', 'Programs', `${APP_NAME}.lnk`),
|
|
183
|
+
exe: win32.join(dir, 'centralu.exe'),
|
|
184
|
+
versionFile: win32.join(dir, 'centralu-version.txt'),
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Which of the names in `%LOCALAPPDATA%\Programs` are copies an earlier `centralu install` renamed
|
|
190
|
+
* aside (`Centralu.old-<time>`). Only those: the folder is shared with every other per-user
|
|
191
|
+
* program, and `Centralu` itself and a `Centralu.new` being assembled are not leftovers.
|
|
192
|
+
*/
|
|
193
|
+
export function asideCopies(names) {
|
|
194
|
+
const prefix = `${APP_NAME}.old-`.toLowerCase()
|
|
195
|
+
return names.filter((n) => n.toLowerCase().startsWith(prefix) && /^\d+$/.test(n.slice(prefix.length)))
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* What `centralu install` creates, per platform. `uninstall` removes exactly these and
|
|
200
|
+
* `update` refreshes them when the first one exists.
|
|
201
|
+
*/
|
|
202
|
+
export function installedPaths(platform, env, home) {
|
|
203
|
+
if (platform === 'darwin') return [`/Applications/${APP_NAME}.app`]
|
|
204
|
+
if (platform === 'win32') {
|
|
205
|
+
const w = windowsInstall(env, home)
|
|
206
|
+
return [w.dir, w.shortcut]
|
|
207
|
+
}
|
|
208
|
+
return [posix.join(home, '.local/share/applications/centralu.desktop')]
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The Windows folder (`%SystemRoot%`), where Windows' own programs are named from */
|
|
212
|
+
export function systemRoot(env) {
|
|
213
|
+
return env.SystemRoot || env.SYSTEMROOT || 'C:\\Windows'
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A PowerShell invocation that writes the Start-menu shortcut.
|
|
218
|
+
*
|
|
219
|
+
* Windows has no file format for a shortcut that Node can write; `.lnk` is a binary shell
|
|
220
|
+
* format, and the supported way to write one is the WScript.Shell COM object. The paths go
|
|
221
|
+
* in through environment variables rather than the script text, so a profile path with a
|
|
222
|
+
* space or an apostrophe (`C:\Users\O'Brien Kim\…`) needs no quoting at all — the one way
|
|
223
|
+
* this cannot be got wrong. PowerShell is named by its full path under `%SystemRoot%` so a
|
|
224
|
+
* `powershell.exe` earlier on PATH (or in the current folder) is never the one that runs.
|
|
225
|
+
*/
|
|
226
|
+
export function shortcutCommand(env, { shortcut, target, workdir }) {
|
|
227
|
+
const script = [
|
|
228
|
+
'$ErrorActionPreference = "Stop"',
|
|
229
|
+
'New-Item -ItemType Directory -Force -Path (Split-Path -Parent $env:CENTRALU_SHORTCUT) | Out-Null',
|
|
230
|
+
'$s = (New-Object -ComObject WScript.Shell).CreateShortcut($env:CENTRALU_SHORTCUT)',
|
|
231
|
+
'$s.TargetPath = $env:CENTRALU_TARGET',
|
|
232
|
+
'$s.WorkingDirectory = $env:CENTRALU_WORKDIR',
|
|
233
|
+
'$s.IconLocation = "$env:CENTRALU_TARGET,0"',
|
|
234
|
+
`$s.Description = "${APP_NAME}"`,
|
|
235
|
+
'$s.Save()',
|
|
236
|
+
].join('; ')
|
|
237
|
+
return {
|
|
238
|
+
command: win32.join(systemRoot(env), 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'),
|
|
239
|
+
args: ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script],
|
|
240
|
+
env: { ...env, CENTRALU_SHORTCUT: shortcut, CENTRALU_TARGET: target, CENTRALU_WORKDIR: workdir },
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* How to run npm from the launcher (`centralu update`).
|
|
246
|
+
*
|
|
247
|
+
* On Windows `npm` is `npm.cmd`, and Node refuses to start a `.cmd` without a shell
|
|
248
|
+
* (EINVAL, since the April 2024 security releases) — and without one it would not find it
|
|
249
|
+
* either, as libuv only tries `.com` and `.exe`. So Windows goes through the shell, and
|
|
250
|
+
* because a shell re-parses its command line, every argument has to be a plain token. The
|
|
251
|
+
* version comes from the registry's response, so that is checked rather than trusted.
|
|
252
|
+
*/
|
|
253
|
+
export function npmCommand(platform, args) {
|
|
254
|
+
for (const a of args) {
|
|
255
|
+
if (!/^[\w@./:+-]+$/.test(a)) throw new Error(`refusing to pass an unexpected argument to npm: ${JSON.stringify(a)}`)
|
|
256
|
+
}
|
|
257
|
+
return platform === 'win32'
|
|
258
|
+
? { command: 'npm', args, options: { stdio: 'inherit', shell: true } }
|
|
259
|
+
: { command: 'npm', args, options: { stdio: 'inherit' } }
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export const WEBVIEW2_DOWNLOAD = 'https://developer.microsoft.com/microsoft-edge/webview2/'
|
|
263
|
+
/** Microsoft's link to the Evergreen Bootstrapper itself, for someone who wants one click. */
|
|
264
|
+
export const WEBVIEW2_BOOTSTRAPPER = 'https://go.microsoft.com/fwlink/p/?LinkId=2124703'
|
|
265
|
+
|
|
266
|
+
/** The Evergreen WebView2 Runtime's client id, the same under every registry hive. */
|
|
267
|
+
const WEBVIEW2_CLIENT = '{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}'
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Where an installed WebView2 Runtime registers itself: per machine (the 32-bit view on
|
|
271
|
+
* 64-bit Windows, which is where the installer writes), per machine on 32-bit Windows, and
|
|
272
|
+
* per user. This is Microsoft's documented detection
|
|
273
|
+
* (learn.microsoft.com/microsoft-edge/webview2/concepts/distribution, "Detect if a WebView2
|
|
274
|
+
* Runtime is already installed").
|
|
275
|
+
*/
|
|
276
|
+
export const WEBVIEW2_KEYS = [
|
|
277
|
+
`HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\EdgeUpdate\\Clients\\${WEBVIEW2_CLIENT}`,
|
|
278
|
+
`HKLM\\SOFTWARE\\Microsoft\\EdgeUpdate\\Clients\\${WEBVIEW2_CLIENT}`,
|
|
279
|
+
`HKCU\\Software\\Microsoft\\EdgeUpdate\\Clients\\${WEBVIEW2_CLIENT}`,
|
|
280
|
+
]
|
|
281
|
+
|
|
282
|
+
/** `reg.exe` by full path, for the same reason PowerShell is. */
|
|
283
|
+
export function regExe(env) {
|
|
284
|
+
return win32.join(systemRoot(env), 'System32', 'reg.exe')
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The `pv` value out of `reg query <key> /v pv`, or null. Microsoft's rule: the runtime is
|
|
289
|
+
* installed when `pv` exists and is neither empty nor `0.0.0.0` (an uninstall can leave
|
|
290
|
+
* the key behind with that value).
|
|
291
|
+
*/
|
|
292
|
+
export function parseRegPv(stdout) {
|
|
293
|
+
const m = /^\s*pv\s+REG_SZ\s+(\S*)\s*$/im.exec(String(stdout ?? ''))
|
|
294
|
+
const v = m?.[1] ?? ''
|
|
295
|
+
return v && v !== '0.0.0.0' ? v : null
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Whether WebView2 is there, from a `query(key)` that returns `reg`'s stdout, `null` when
|
|
300
|
+
* the key does not exist, or `undefined` when `reg` itself could not run.
|
|
301
|
+
*
|
|
302
|
+
* Three answers, not two. "Missing" stops the launch with a message, so it has to be a
|
|
303
|
+
* positive finding — every hive was readable and none has the runtime. If `reg` could not
|
|
304
|
+
* run at all the answer is "unknown" and the app starts anyway: refusing to launch because
|
|
305
|
+
* a check broke would be worse than the blank window the check exists to explain.
|
|
306
|
+
*
|
|
307
|
+
* `WEBVIEW2_BROWSER_EXECUTABLE_FOLDER` points WebView2 at a fixed-version runtime that the
|
|
308
|
+
* registry knows nothing about; when it is set, the person has made that choice.
|
|
309
|
+
*/
|
|
310
|
+
export function webview2Status(env, query) {
|
|
311
|
+
if (env.WEBVIEW2_BROWSER_EXECUTABLE_FOLDER) return { state: 'present', version: 'fixed' }
|
|
312
|
+
let unknown = false
|
|
313
|
+
for (const key of WEBVIEW2_KEYS) {
|
|
314
|
+
const out = query(key)
|
|
315
|
+
if (out === undefined) {
|
|
316
|
+
unknown = true
|
|
317
|
+
continue
|
|
318
|
+
}
|
|
319
|
+
const version = parseRegPv(out)
|
|
320
|
+
if (version) return { state: 'present', version }
|
|
321
|
+
}
|
|
322
|
+
return { state: unknown ? 'unknown' : 'missing' }
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function webview2MissingMessage() {
|
|
326
|
+
return (
|
|
327
|
+
`${APP_NAME} needs the Microsoft Edge WebView2 Runtime, and it is not installed on this machine.\n` +
|
|
328
|
+
'Windows 11 includes it; some Windows 10 installs do not.\n' +
|
|
329
|
+
`Install the "Evergreen Bootstrapper" from ${WEBVIEW2_DOWNLOAD}\n` +
|
|
330
|
+
`(direct download: ${WEBVIEW2_BOOTSTRAPPER}),\n` +
|
|
331
|
+
'then run `centralu` again.'
|
|
332
|
+
)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* What to say when the exe exits nonzero within moments of starting.
|
|
337
|
+
*
|
|
338
|
+
* The launcher is detached by then, so this is the only place a failure to start can be
|
|
339
|
+
* reported at all — a GUI program has no stderr to read. The registry check catches the
|
|
340
|
+
* usual cause before the start; this catches the rest (a runtime that is registered but
|
|
341
|
+
* broken, a blocked exe, a crash before the window).
|
|
342
|
+
*/
|
|
343
|
+
export function earlyExitMessage(code, home) {
|
|
344
|
+
return (
|
|
345
|
+
`${APP_NAME} exited right after starting (exit code ${code}).\n` +
|
|
346
|
+
`- If no window appeared, check the WebView2 Runtime: ${WEBVIEW2_DOWNLOAD}\n` +
|
|
347
|
+
`- The host log is ${win32.join(home, '.centralu', 'host.log')}\n` +
|
|
348
|
+
'Please report it at https://github.com/ijun17/centralu/issues/14 with that log.'
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* What to say when Windows refuses to replace or remove the app's files.
|
|
354
|
+
*
|
|
355
|
+
* EBUSY / EPERM on a program folder almost always means the program is running: Windows
|
|
356
|
+
* locks a running exe and the native modules the host loaded. Macs and Linux let both be
|
|
357
|
+
* replaced underneath a running process, which is why this message exists only here.
|
|
358
|
+
*/
|
|
359
|
+
export function busyMessage(path) {
|
|
360
|
+
return (
|
|
361
|
+
`Windows would not replace ${path} — ${APP_NAME} is probably still running.\n` +
|
|
362
|
+
'Quit it (close the window and choose Quit), then run the command again.'
|
|
363
|
+
)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function isBusyError(e) {
|
|
367
|
+
return e?.code === 'EBUSY' || e?.code === 'EPERM' || e?.code === 'EACCES' || e?.code === 'ENOTEMPTY'
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* The command that prints the version a macOS bundle carries (`CFBundleShortVersionString`).
|
|
372
|
+
*
|
|
373
|
+
* `plutil` reads XML and binary plists alike, so nothing breaks if Tauri ever writes a binary one.
|
|
374
|
+
* The in-app update runs the same command (`installedCopyVersionSource` in
|
|
375
|
+
* `packages/agent-host/src/os.ts`, pinned to this by `updates.test.ts`), so the host and the
|
|
376
|
+
* launcher can never disagree about which version a copy is (#645).
|
|
377
|
+
*/
|
|
378
|
+
export function bundleVersionCommand(bundle) {
|
|
379
|
+
return { command: '/usr/bin/plutil', args: ['-extract', 'CFBundleShortVersionString', 'raw', '-o', '-', posix.join(bundle, 'Contents', 'Info.plist')] }
|
|
380
|
+
}
|
package/bin/semver.mjs
CHANGED
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* Why not `.split('.').map(Number)`: that turns `0.1.0-beta.2` into
|
|
11
11
|
* [0, 1, NaN, 2], and `NaN !== NaN` ends the comparison at the third slot with
|
|
12
12
|
* `NaN > NaN` = false — so no beta ever saw the next beta. That is exactly how
|
|
13
|
-
* `centralu update` on 0.1.0-beta.1 answered "
|
|
14
|
-
* on the registry (found the day beta.2 shipped).
|
|
13
|
+
* `centralu update` on 0.1.0-beta.1 answered "Already up to date" while
|
|
14
|
+
* beta.2 sat on the registry (found the day beta.2 shipped).
|
|
15
15
|
*/
|
|
16
16
|
export function isNewer(a, b) {
|
|
17
17
|
const [coreA, preA] = split(String(a))
|
|
@@ -62,15 +62,48 @@ export function isNewer(a, b) {
|
|
|
62
62
|
*
|
|
63
63
|
* Lives beside `isNewer` because it ships and is tested for the same reason — a launcher
|
|
64
64
|
* already on someone's machine cannot be corrected later. 0.1.0-beta.1 is still out there
|
|
65
|
-
* answering "
|
|
65
|
+
* answering "Already up to date" and always will be.
|
|
66
66
|
*/
|
|
67
67
|
export function copyDiffers(pkgVersion, copyVersion) {
|
|
68
68
|
return typeof copyVersion === 'string' && copyVersion !== '' && copyVersion !== pkgVersion
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
|
|
71
|
+
/*
|
|
72
|
+
* Build metadata (`+sha`) is dropped first: semver ignores it for precedence, and left in, it turned `3+build5`
|
|
73
|
+
* into NaN (and the `-` in `+build-5` into a prerelease). A leading `v` (`v1.2.3`, as a tag is written) is dropped
|
|
74
|
+
* too, or the first slot is NaN. Either NaN ends the comparison as "not newer", the #42 failure again.
|
|
75
|
+
*/
|
|
76
|
+
function split(raw) {
|
|
77
|
+
let v = raw.trim().replace(/^v/i, '')
|
|
78
|
+
const plus = v.indexOf('+')
|
|
79
|
+
if (plus !== -1) v = v.slice(0, plus)
|
|
72
80
|
const dash = v.indexOf('-')
|
|
73
81
|
const core = (dash === -1 ? v : v.slice(0, dash)).split('.').map(Number)
|
|
74
82
|
const pre = dash === -1 ? null : v.slice(dash + 1)
|
|
75
83
|
return [core, pre]
|
|
76
84
|
}
|
|
85
|
+
|
|
86
|
+
const LOCAL_BUILD = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)-(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*)(?:\.(?:0|[1-9]\d*|\d*[A-Za-z-][0-9A-Za-z-]*))*\.local\.[1-9]\d*$/
|
|
87
|
+
|
|
88
|
+
/** A maintainer's local build: `<base>.local.N`, strict SemVer, with a prerelease base and a positive N (`scripts/local-manifest.mjs`). */
|
|
89
|
+
export function isLocalBuildVersion(v) {
|
|
90
|
+
return typeof v === 'string' && v.length <= 64 && LOCAL_BUILD.test(v)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Whether the copy `centralu install` made is kept rather than replaced by `target` (#645).
|
|
95
|
+
*
|
|
96
|
+
* npm is the source of truth for releases: any released version in the copy, even a higher one, is
|
|
97
|
+
* replaced by the target, so a release pulled from `latest` (a dist-tag rollback) does not survive in
|
|
98
|
+
* /Applications. The one exception is a maintainer's local build (`<base>.local.N`, `pnpm
|
|
99
|
+
* local-update`, #610/#613) newer than the target: it waits there for "Apply now", and putting the
|
|
100
|
+
* release it was cut from over it would undo it. The copy's version must be well-formed SemVer in
|
|
101
|
+
* that exact shape; anything unreadable or malformed (`garbage`, `$(VERSION)`, an empty file) is
|
|
102
|
+
* replaced, so a corrupt plist or version file can never block every later update.
|
|
103
|
+
*
|
|
104
|
+
* `centralu install` applies this unless `--force` is given; the in-app update applies the mirror in
|
|
105
|
+
* `packages/protocol/src/semver.ts` (pinned answer to answer by `tooling/launcher-semver.test.ts`).
|
|
106
|
+
*/
|
|
107
|
+
export function keepsInstalledCopy(target, copyVersion) {
|
|
108
|
+
return isLocalBuildVersion(copyVersion) && isNewer(copyVersion, target)
|
|
109
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/** The arguments after `centralu serve`, and its help */
|
|
2
|
+
import { DEFAULT_SERVE_PORT } from './serve-port.mjs'
|
|
3
|
+
|
|
4
|
+
export const SERVE_HELP = `centralu serve: run the Centralu host on this machine, without a window
|
|
5
|
+
|
|
6
|
+
centralu serve Start the host in the foreground, on 127.0.0.1 only
|
|
7
|
+
centralu serve --port <n> ... on that port (kept for the next start; default ${DEFAULT_SERVE_PORT})
|
|
8
|
+
centralu serve --detach Start it in the background, outside this session; return once it answers
|
|
9
|
+
centralu serve --stop Ask the running host to stop, and wait until it has
|
|
10
|
+
centralu serve --window What the Centralu window on Windows runs here: start the host if
|
|
11
|
+
none runs, print its port and token as a ready line, stay until
|
|
12
|
+
stdin ends, and leave the host running
|
|
13
|
+
centralu serve --connection Print how to reach it as one JSON line, then exit
|
|
14
|
+
centralu serve --rotate-token Replace the token (a running serve keeps the old one until restarted)
|
|
15
|
+
centralu serve --from <hub> With serve or --detach: the name of the computer whose link asks,
|
|
16
|
+
said by a second Centralu on this data folder when it is refused
|
|
17
|
+
centralu serve --autostart on|off|remove|status
|
|
18
|
+
Start it when this machine starts (Linux: a systemd user unit;
|
|
19
|
+
Windows and WSL: a scheduled task at sign-in), stop doing that
|
|
20
|
+
(Windows keeps the task, without its trigger, for --detach),
|
|
21
|
+
remove the entry altogether, or say whether it does. None of them
|
|
22
|
+
starts or stops a host that runs now
|
|
23
|
+
|
|
24
|
+
The data folder is ~/.centralu (CC_DATA_DIR overrides it). The app on another computer reaches
|
|
25
|
+
this host through an SSH local forward, and starts it with --detach when it is not running.
|
|
26
|
+
Stop a foreground one with Ctrl+C or SIGTERM, a detached one with --stop. A detached one writes
|
|
27
|
+
how its start went to serve.log in the data folder; the host's own log is host.log.
|
|
28
|
+
|
|
29
|
+
serve, --detach and --connection keep ~/.centralu/bin/centralu up to date: a launcher with absolute
|
|
30
|
+
paths, for SSH sessions whose PATH has neither npm's global folder nor node. Not when they run
|
|
31
|
+
from an install the app made over SSH, which has a launcher of its own.`
|
|
32
|
+
|
|
33
|
+
/** The flags that each make serve a different command */
|
|
34
|
+
const MODE_FLAGS = { '--connection': 'connection', '--rotate-token': 'rotate', '--detach': 'detach', '--stop': 'stop', '--window': 'window' }
|
|
35
|
+
const FLAG_OF = { ...Object.fromEntries(Object.entries(MODE_FLAGS).map(([f, m]) => [m, f])), autostart: '--autostart' }
|
|
36
|
+
|
|
37
|
+
/** What `--autostart` takes */
|
|
38
|
+
export const AUTOSTART_ACTIONS = ['on', 'off', 'remove', 'status']
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The argument a detached launcher is started with: what it needs from the `--detach` that started
|
|
42
|
+
* it, which a process created through WMI does not inherit (it gets the account's default
|
|
43
|
+
* environment, not this one). Never a secret: the token stays in serve.json. Internal, not in the help.
|
|
44
|
+
*/
|
|
45
|
+
export const CHILD_FLAG = '--detached-child='
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* What `--from` takes (#1223): a hub's name, short and of characters that cannot break the sentence a refused host says
|
|
49
|
+
* it in, nor a command line. The hub sends its own host name, cleaned the same way (`hubNameForRemote`, links/tunnel.ts)
|
|
50
|
+
*/
|
|
51
|
+
export const FROM_RE = /^[A-Za-z0-9._-]{1,64}$/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* What the arguments after `serve` ask for: `{ mode, port }` (plus `child` for a detached launcher,
|
|
55
|
+
* `autostart` for `--autostart`) or `{ error }`.
|
|
56
|
+
*
|
|
57
|
+
* `mode` is `serve`, `detach`, `stop`, `connection`, `rotate`, `autostart` or `help`; `port` is null
|
|
58
|
+
* when not given. `--port` goes with `serve` and `--detach` only: `--connection` has to report the
|
|
59
|
+
* port `serve` uses, not one the caller names, `--stop` stops the one that runs, and the entry
|
|
60
|
+
* `--autostart` writes starts serve on the port it recorded.
|
|
61
|
+
*/
|
|
62
|
+
export function parseServeArgs(argv) {
|
|
63
|
+
let mode = 'serve'
|
|
64
|
+
let port = null
|
|
65
|
+
let help = false
|
|
66
|
+
let child = null
|
|
67
|
+
let autostart = null
|
|
68
|
+
let from = null
|
|
69
|
+
for (let i = 0; i < argv.length; i++) {
|
|
70
|
+
const a = argv[i]
|
|
71
|
+
if (a === '--autostart' || a.startsWith('--autostart=')) {
|
|
72
|
+
const raw = a === '--autostart' ? argv[++i] : a.slice('--autostart='.length)
|
|
73
|
+
if (!AUTOSTART_ACTIONS.includes(raw)) return { error: `--autostart needs on, off, remove or status (got ${raw === undefined ? 'nothing' : JSON.stringify(raw)})` }
|
|
74
|
+
if (mode !== 'serve' && mode !== 'autostart') return { error: `${FLAG_OF[mode]} and --autostart are separate commands; pass one` }
|
|
75
|
+
mode = 'autostart'
|
|
76
|
+
autostart = raw
|
|
77
|
+
} else if (Object.hasOwn(MODE_FLAGS, a)) {
|
|
78
|
+
const m = MODE_FLAGS[a]
|
|
79
|
+
if (mode !== 'serve' && mode !== m) return { error: `${FLAG_OF[mode]} and ${a} are separate commands; pass one` }
|
|
80
|
+
mode = m
|
|
81
|
+
} else if (a === '--help' || a === '-h') help = true
|
|
82
|
+
else if (a.startsWith(CHILD_FLAG)) child = a.slice(CHILD_FLAG.length)
|
|
83
|
+
else if (a === '--port' || a.startsWith('--port=')) {
|
|
84
|
+
const raw = a === '--port' ? argv[++i] : a.slice('--port='.length)
|
|
85
|
+
const n = parsePort(raw)
|
|
86
|
+
if (n === null) return { error: `--port needs a port number between 1 and 65535 (got ${raw === undefined ? 'nothing' : JSON.stringify(raw)})` }
|
|
87
|
+
port = n
|
|
88
|
+
} else if (a === '--from' || a.startsWith('--from=')) {
|
|
89
|
+
const raw = a === '--from' ? argv[++i] : a.slice('--from='.length)
|
|
90
|
+
if (typeof raw !== 'string' || !FROM_RE.test(raw)) return { error: `--from needs a computer's name: letters, digits, '.', '_' and '-' (got ${raw === undefined ? 'nothing' : JSON.stringify(raw)})` }
|
|
91
|
+
from = raw
|
|
92
|
+
} else return { error: `unknown option for serve: ${a}\n\n${SERVE_HELP}` }
|
|
93
|
+
}
|
|
94
|
+
if (help) return { mode: 'help', port }
|
|
95
|
+
if (from !== null && mode !== 'serve' && mode !== 'detach') return { error: `--from goes with \`centralu serve\` and --detach, which start a host; not with ${FLAG_OF[mode]}` }
|
|
96
|
+
if (child !== null) {
|
|
97
|
+
if (mode !== 'serve') return { error: `${CHILD_FLAG.slice(0, -1)} is how --detach starts serve, not an option of ${FLAG_OF[mode]}` }
|
|
98
|
+
return { mode, port, child, ...(from !== null ? { from } : {}) }
|
|
99
|
+
}
|
|
100
|
+
if ((mode === 'connection' || mode === 'rotate') && port !== null) {
|
|
101
|
+
return { error: '--port goes with `centralu serve`; `--connection` reports the port serve listens on' }
|
|
102
|
+
}
|
|
103
|
+
if (mode === 'stop' && port !== null) return { error: '--stop stops the serve that is running, on the port it recorded; it takes no --port' }
|
|
104
|
+
if (mode === 'window' && port !== null) return { error: '--window uses the serve that runs, or starts one on the port it recorded; it takes no --port' }
|
|
105
|
+
if (mode === 'autostart') {
|
|
106
|
+
if (port !== null) return { error: '--autostart takes no --port: the entry it writes starts serve on the port serve recorded' }
|
|
107
|
+
return { mode, port, autostart }
|
|
108
|
+
}
|
|
109
|
+
return { mode, port, ...(from !== null ? { from } : {}) }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function parsePort(raw) {
|
|
113
|
+
if (typeof raw !== 'string' || !/^\d{1,5}$/.test(raw)) return null
|
|
114
|
+
const n = Number(raw)
|
|
115
|
+
return n >= 1 && n <= 65535 ? n : null
|
|
116
|
+
}
|