herdr-remote 0.2.21 → 0.2.22

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.
Files changed (40) hide show
  1. package/README.md +12 -2
  2. package/README.zh-CN.md +11 -1
  3. package/dist/cli.js +23 -4
  4. package/dist/config.js +8 -1
  5. package/dist/herdr-api.js +5 -3
  6. package/dist/herdr-command.js +32 -15
  7. package/dist/herdr-plugin.js +5 -1
  8. package/dist/herdr-server.js +3 -2
  9. package/dist/i18n/en.js +8 -0
  10. package/dist/i18n/zh.js +8 -0
  11. package/dist/keepalive/detached.js +13 -3
  12. package/dist/keepalive/index.js +16 -8
  13. package/dist/keepalive/launchd.js +4 -4
  14. package/dist/keepalive/systemd.js +8 -4
  15. package/dist/keepalive/windows-service-setup.js +129 -0
  16. package/dist/keepalive/windows-service.js +272 -0
  17. package/dist/lifecycle.js +15 -1
  18. package/dist/net-interfaces.js +15 -6
  19. package/dist/paths.js +16 -4
  20. package/dist/pty-session.js +10 -2
  21. package/dist/service.js +2 -0
  22. package/dist/socket-discovery.js +15 -3
  23. package/dist/supervisor.js +1 -0
  24. package/dist/terminal-font/deps.js +11 -1
  25. package/dist/terminal-font/files.js +13 -10
  26. package/dist/terminal-font/readers.js +60 -3
  27. package/dist/terminal-font/terminals.js +3 -0
  28. package/dist/terminal-palette.js +78 -18
  29. package/dist/tui/api.js +3 -2
  30. package/dist/tui/screens/About.js +3 -3
  31. package/dist/tui/screens/Keepalive.js +18 -8
  32. package/dist/tui/screens/Wizard.js +7 -2
  33. package/dist/tui/screens/useRelayUpdate.js +3 -3
  34. package/dist/updater/npm.js +62 -0
  35. package/dist/updater.js +1 -31
  36. package/herdr-plugin.toml +2 -2
  37. package/package.json +2 -1
  38. package/vendor/winsw/LICENSE +21 -0
  39. package/vendor/winsw/README.md +9 -0
  40. package/vendor/winsw/WinSW.NET461.exe +0 -0
@@ -4,7 +4,7 @@ import crypto from 'node:crypto';
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
6
  import { MAX_TERMINAL_FONT_BYTES, TERMINAL_FONT_STYLES, } from 'herdr-remote-relay/protocol';
7
- import { GENERIC_FAMILIES, withDefaults } from './deps.js';
7
+ import { GENERIC_FAMILIES, localAppData, withDefaults } from './deps.js';
8
8
  const FACE_PATTERNS = {
9
9
  regular: ':weight=regular:slant=roman',
10
10
  bold: ':weight=bold:slant=roman',
@@ -117,12 +117,15 @@ function readSfntNames(filePath) {
117
117
  }
118
118
  }
119
119
  }
120
- function macFontFiles(deps) {
121
- const roots = [
122
- path.join(deps.home, 'Library', 'Fonts'),
123
- '/Library/Fonts',
124
- '/System/Library/Fonts',
125
- ];
120
+ function fontFolderFiles(deps) {
121
+ const roots = deps.platform === 'darwin'
122
+ ? [path.join(deps.home, 'Library', 'Fonts'), '/Library/Fonts', '/System/Library/Fonts']
123
+ : deps.platform === 'win32'
124
+ ? [
125
+ path.join(deps.env.WINDIR || deps.env.SystemRoot || 'C:\\Windows', 'Fonts'),
126
+ path.join(localAppData(deps), 'Microsoft', 'Windows', 'Fonts'),
127
+ ]
128
+ : [];
126
129
  const files = [];
127
130
  const walk = (directory, depth) => {
128
131
  let entries;
@@ -161,7 +164,7 @@ export function familyForPostScriptName(postscript, deps) {
161
164
  return listed.stdout.trim().split('\n')[0].split(',')[0].trim();
162
165
  if (deps.platform !== 'darwin')
163
166
  return null;
164
- for (const file of macFontFiles(deps)) {
167
+ for (const file of fontFolderFiles(deps)) {
165
168
  const names = readSfntNames(file);
166
169
  if (names?.postscript === postscript)
167
170
  return names.family;
@@ -202,9 +205,9 @@ function locateFaceFiles(family, deps) {
202
205
  continue;
203
206
  located[style] = file;
204
207
  }
205
- if (fontconfig || deps.platform !== 'darwin')
208
+ if (fontconfig || (deps.platform !== 'darwin' && deps.platform !== 'win32'))
206
209
  return located;
207
- for (const file of macFontFiles(deps)) {
210
+ for (const file of fontFolderFiles(deps)) {
208
211
  const names = readSfntNames(file);
209
212
  if (!names || names.family?.toLowerCase() !== family.toLowerCase())
210
213
  continue;
@@ -1,7 +1,7 @@
1
1
  // Each terminal's own settings, read the way that terminal reads them.
2
2
  import path from 'node:path';
3
3
  import { parseBinaryPlist, unarchiveKeyed } from '../binary-plist.js';
4
- import { configHome, dataHome, expandHome, pointsToPx, positiveNumber, } from './deps.js';
4
+ import { configHome, dataHome, expandHome, localAppData, pointsToPx, positiveNumber, roamingAppData, } from './deps.js';
5
5
  import { familyForPostScriptName } from './files.js';
6
6
  import { firstCssFamily, optionValue, parseFontconfigPattern, parseGVariantString, parseIni, parseJsonc, parsePangoFontDescription, parseQtFontString, parseTomlSubset, parseXFontName, xResource, xrmResources, } from './formats.js';
7
7
  function gsettingsGet(deps, schema, key) {
@@ -25,7 +25,54 @@ function readGSettingsProfileFont(deps, { schema, path: profilePath, fontKey = '
25
25
  function systemMonospaceFont(deps) {
26
26
  return parsePangoFontDescription(parseGVariantString(gsettingsGet(deps, 'org.gnome.desktop.interface', 'monospace-font-name')));
27
27
  }
28
+ function settingsObject(value) {
29
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
30
+ ? value
31
+ : null;
32
+ }
33
+ function windowsTerminalProfileFont(profile) {
34
+ const values = settingsObject(profile);
35
+ const font = settingsObject(values?.font);
36
+ return {
37
+ family: firstCssFamily(font?.face) || firstCssFamily(values?.fontFace),
38
+ size: positiveNumber(font?.size) || positiveNumber(values?.fontSize),
39
+ };
40
+ }
41
+ function readWindowsTerminalFont(deps) {
42
+ const local = localAppData(deps);
43
+ let settings = null;
44
+ for (const file of [
45
+ path.join(local, 'Packages', 'Microsoft.WindowsTerminal_8wekyb3d8bbwe', 'LocalState', 'settings.json'),
46
+ path.join(local, 'Packages', 'Microsoft.WindowsTerminalPreview_8wekyb3d8bbwe', 'LocalState', 'settings.json'),
47
+ path.join(local, 'Microsoft', 'Windows Terminal', 'settings.json'),
48
+ ]) {
49
+ settings = parseJsonc(deps.readFile(file));
50
+ if (settings)
51
+ break;
52
+ }
53
+ if (!settings)
54
+ return null;
55
+ const profiles = settings.profiles;
56
+ const profileSettings = settingsObject(profiles);
57
+ const list = Array.isArray(profiles)
58
+ ? profiles
59
+ : Array.isArray(profileSettings?.list)
60
+ ? profileSettings.list
61
+ : [];
62
+ const guidOf = (profile) => String(settingsObject(profile)?.guid || '').toLowerCase();
63
+ const currentId = String(deps.env.WT_PROFILE_ID || '').toLowerCase();
64
+ const defaultId = String(settings.defaultProfile || '').toLowerCase();
65
+ const selected = (currentId ? list.find((profile) => guidOf(profile) === currentId) : undefined) ||
66
+ (defaultId ? list.find((profile) => guidOf(profile) === defaultId) : undefined);
67
+ const selectedFont = windowsTerminalProfileFont(selected);
68
+ const defaults = windowsTerminalProfileFont(profileSettings?.defaults);
69
+ // These are Windows Terminal's own defaults when settings omit the values.
70
+ const family = selectedFont.family || defaults.family || 'Cascadia Mono';
71
+ const size = selectedFont.size || defaults.size || 12;
72
+ return { family, sizePx: pointsToPx(size) };
73
+ }
28
74
  export const READERS = {
75
+ 'windows-terminal': readWindowsTerminalFont,
29
76
  'gnome-terminal': (deps) => {
30
77
  const id = parseGVariantString(gsettingsGet(deps, 'org.gnome.Terminal.ProfilesList', 'default'));
31
78
  if (!id)
@@ -111,6 +158,9 @@ export const READERS = {
111
158
  },
112
159
  alacritty: (deps) => {
113
160
  const candidates = [
161
+ ...(deps.platform === 'win32'
162
+ ? [path.join(roamingAppData(deps), 'alacritty', 'alacritty.toml')]
163
+ : []),
114
164
  path.join(configHome(deps), 'alacritty', 'alacritty.toml'),
115
165
  path.join(configHome(deps), 'alacritty.toml'),
116
166
  path.join(deps.home, '.alacritty.toml'),
@@ -212,7 +262,9 @@ export const READERS = {
212
262
  vscode: (deps) => {
213
263
  const base = deps.platform === 'darwin'
214
264
  ? path.join(deps.home, 'Library', 'Application Support')
215
- : configHome(deps);
265
+ : deps.platform === 'win32'
266
+ ? roamingAppData(deps)
267
+ : configHome(deps);
216
268
  for (const product of ['Code', 'Cursor', 'Code - Insiders', 'VSCodium', 'Windsurf']) {
217
269
  const settings = parseJsonc(deps.readFile(path.join(base, product, 'User', 'settings.json')));
218
270
  if (!settings)
@@ -225,7 +277,12 @@ export const READERS = {
225
277
  if (family)
226
278
  return { family, sizePx };
227
279
  }
228
- return { family: deps.platform === 'darwin' ? 'Menlo' : 'Droid Sans Mono', sizePx: 14 };
280
+ const family = deps.platform === 'darwin'
281
+ ? 'Menlo'
282
+ : deps.platform === 'win32'
283
+ ? 'Consolas'
284
+ : 'Droid Sans Mono';
285
+ return { family, sizePx: 14 };
229
286
  },
230
287
  iterm2: (deps) => {
231
288
  const prefs = readPreferences('com.googlecode.iterm2', deps);
@@ -64,6 +64,9 @@ export function identifyTerminal({ env = process.env, ancestry = [], } = {}) {
64
64
  return 'gnome-terminal';
65
65
  if (/^rxvt-unicode/.test(env.TERM || ''))
66
66
  return 'urxvt';
67
+ // WT_SESSION is inherited by nested terminals, so specific terminal markers win first.
68
+ if (env.WT_SESSION)
69
+ return 'windows-terminal';
67
70
  // Last: a terminal started from xterm inherits this too.
68
71
  if (env.XTERM_VERSION)
69
72
  return 'xterm';
@@ -115,7 +115,11 @@ function paletteFromEnvironment(env = process.env) {
115
115
  }
116
116
  }
117
117
  function runStty(args, ttyPath) {
118
- return spawnSync('stty', [...args, '-F', ttyPath], { encoding: 'utf8', timeout: 1000 });
118
+ return spawnSync('stty', [...args, '-F', ttyPath], {
119
+ encoding: 'utf8',
120
+ timeout: 1000,
121
+ windowsHide: true,
122
+ });
119
123
  }
120
124
  /**
121
125
  * Takes one reply out of the buffered bytes, leaving the rest.
@@ -131,6 +135,17 @@ function takeOscColorReply(state, expectedPrefix) {
131
135
  state.pending = state.pending.slice(0, found.start) + state.pending.slice(found.end);
132
136
  return found.color;
133
137
  }
138
+ const PALETTE_QUERIES = [
139
+ { query: '\x1b]10;?\x1b\\', prefix: '\x1b]10', paletteKey: 'foreground' },
140
+ { query: '\x1b]11;?\x1b\\', prefix: '\x1b]11', paletteKey: 'background' },
141
+ { query: '\x1b]12;?\x1b\\', prefix: '\x1b]12', paletteKey: 'cursor' },
142
+ ...ANSI_KEYS.map((ansiKey, slot) => ({
143
+ query: `\x1b]4;${slot};?\x1b\\`,
144
+ prefix: `\x1b]4;${slot}`,
145
+ paletteKey: 'ansi',
146
+ ansiKey,
147
+ })),
148
+ ];
134
149
  /**
135
150
  * Runs the color conversation over an injected `ask(query, prefix)`.
136
151
  *
@@ -150,21 +165,17 @@ function takeOscColorReply(state, expectedPrefix) {
150
165
  */
151
166
  function collectPalette(ask) {
152
167
  const palette = {};
153
- const foreground = ask('\x1b]10;?\x1b\\', '\x1b]10');
154
- if (foreground)
155
- palette.foreground = foreground;
156
- const background = ask('\x1b]11;?\x1b\\', '\x1b]11');
157
- if (background)
158
- palette.background = background;
159
- const cursor = ask('\x1b]12;?\x1b\\', '\x1b]12');
160
- if (cursor)
161
- palette.cursor = cursor;
162
168
  const ansi = {};
163
- for (let slot = 0; slot < ANSI_SLOTS; slot += 1) {
164
- const color = ask(`\x1b]4;${slot};?\x1b\\`, `\x1b]4;${slot}`);
165
- if (!color)
166
- break;
167
- ansi[ANSI_KEYS[slot]] = color;
169
+ for (const query of PALETTE_QUERIES) {
170
+ const color = ask(query.query, query.prefix);
171
+ if (query.paletteKey === 'ansi') {
172
+ if (!color)
173
+ break;
174
+ ansi[query.ansiKey] = color;
175
+ }
176
+ else if (color) {
177
+ palette[query.paletteKey] = color;
178
+ }
168
179
  }
169
180
  if (Object.keys(ansi).length === ANSI_SLOTS)
170
181
  palette.ansi = ansi;
@@ -251,6 +262,55 @@ function probeTerminalPalette({ ttyPath = '/dev/tty', timeoutMs = TOTAL_TIMEOUT_
251
262
  }
252
263
  }
253
264
  }
265
+ /** Queries Windows Terminal through the raw stdin stream, whose replies are VT bytes. */
266
+ async function probeConsolePalette({ input = process.stdin, output = process.stdout, timeoutMs = TOTAL_TIMEOUT_MS, } = {}) {
267
+ if (!input.isTTY || !output.isTTY)
268
+ return null;
269
+ const state = { pending: '', overallDeadline: Date.now() + timeoutMs };
270
+ let listener = null;
271
+ let timer;
272
+ try {
273
+ input.setRawMode(true);
274
+ await new Promise((resolve) => {
275
+ let finished = false;
276
+ const finish = () => {
277
+ if (finished)
278
+ return;
279
+ finished = true;
280
+ resolve();
281
+ };
282
+ listener = (chunk) => {
283
+ state.pending += typeof chunk === 'string' ? chunk : chunk.toString('latin1');
284
+ if (/\x1b\[\?[\d;]*c/.test(state.pending))
285
+ finish();
286
+ };
287
+ input.on('data', listener);
288
+ input.resume();
289
+ timer = setTimeout(finish, Math.max(0, state.overallDeadline - Date.now()));
290
+ // Windows has no /dev/tty-style synchronous read with a timeout. Terminals
291
+ // answer in order, so DA1 confirms all earlier color replies arrived.
292
+ output.write(`${PALETTE_QUERIES.map(({ query }) => query).join('')}\x1b[c`);
293
+ });
294
+ return collectPalette((_query, prefix) => takeOscColorReply(state, prefix));
295
+ }
296
+ catch {
297
+ // A broken or disconnected console must not prevent the CLI from starting.
298
+ return null;
299
+ }
300
+ finally {
301
+ if (timer)
302
+ clearTimeout(timer);
303
+ if (listener)
304
+ input.removeListener('data', listener);
305
+ try {
306
+ input.setRawMode(false);
307
+ }
308
+ catch {
309
+ // A closed console can reject mode restoration, but pausing still releases the stream.
310
+ }
311
+ input.pause();
312
+ }
313
+ }
254
314
  /**
255
315
  * Remembers a palette for the next start that has no terminal to ask.
256
316
  *
@@ -297,13 +357,13 @@ function resolveHostPalette({ env = process.env, probe = probeTerminalPalette, }
297
357
  * the very start of the command, means every later caller in this process tree
298
358
  * simply inherits the answer through the environment.
299
359
  */
300
- function captureTerminalPalette({ env = process.env, probe = probeTerminalPalette, } = {}) {
360
+ async function captureTerminalPalette({ env = process.env, probe = probeTerminalPalette, platform = process.platform, consoleProbe = probeConsolePalette, } = {}) {
301
361
  if (env.HERDR_TERM_PALETTE_JSON)
302
362
  return paletteFromEnvironment(env);
303
363
  // Without both ends on a terminal there is nobody to answer the query.
304
364
  if (!process.stdin.isTTY || !process.stdout.isTTY)
305
365
  return null;
306
- const palette = probe();
366
+ const palette = platform === 'win32' ? await consoleProbe() : probe();
307
367
  if (!palette)
308
368
  return null;
309
369
  env.HERDR_TERM_PALETTE_JSON = JSON.stringify(palette);
@@ -336,4 +396,4 @@ function hostTerminalPalette({ refresh = false } = {}) {
336
396
  cachedHostPalette = probed ? rememberTerminalPalette(probed) : rememberedTerminalPalette();
337
397
  return cachedHostPalette;
338
398
  }
339
- export { ANSI_KEYS, ANSI_SLOTS, parseXColor, parseOscColorReply, takeOscColorReply, collectPalette, sanitizePalette, paletteFromEnvironment, probeTerminalPalette, resolveHostPalette, hostTerminalPalette, captureTerminalPalette, rememberTerminalPalette, rememberedTerminalPalette, };
399
+ export { ANSI_KEYS, ANSI_SLOTS, parseXColor, parseOscColorReply, takeOscColorReply, collectPalette, sanitizePalette, paletteFromEnvironment, probeTerminalPalette, probeConsolePalette, resolveHostPalette, hostTerminalPalette, captureTerminalPalette, rememberTerminalPalette, rememberedTerminalPalette, };
package/dist/tui/api.js CHANGED
@@ -4,6 +4,7 @@
4
4
  // shared with the CLI. Funnelling it through one module gives the screens a
5
5
  // single surface to call.
6
6
  import { OFFICIAL_RELAY_URL, configExists, loadConfig } from '../config.js';
7
+ export { KEEPALIVE_MANAGERS } from '../config.js';
7
8
  import { configPath, stateDir } from '../paths.js';
8
9
  import { bindAddress, resolveAdminOrigin, runsLocalRelay } from '../relay-urls.js';
9
10
  import { MIN_HERDR_VERSION, herdrVersion } from '../herdr-command.js';
@@ -13,11 +14,11 @@ import { createDraft, fieldsForMode, getField, getFieldPlaceholder, isOfficialRe
13
14
  import { extractPairingCode, requestJson } from '../relay-client.js';
14
15
  import { ensureRuntime, regenerateHostIdentity, setRelayPassword } from '../runtime.js';
15
16
  import { pair, readLogTail } from '../service.js';
16
- import { fullStatus, restartAll, startAll, stopAll } from '../lifecycle.js';
17
+ import { fullStatus, restartAll, startAll, stopAll, whileServicesStopped } from '../lifecycle.js';
17
18
  import * as keepalive from '../keepalive/index.js';
18
19
  import { canSelfUpdate, checkForUpdate, compareVersions, currentVersion, updateChecksEnabled, installKind, performUpdate, } from '../updater.js';
19
20
  import { checkForRelayUpdate, installedRelayVersion, performRelayUpdate, } from '../relay-updater.js';
20
- export { detectLocale, listReachableAddresses, MIN_HERDR_VERSION, OFFICIAL_RELAY_URL, isOfficialRelay, selectedMode, canSelfUpdate, checkForUpdate, compareVersions, currentVersion, updateChecksEnabled, installKind, performUpdate, checkForRelayUpdate, installedRelayVersion, performRelayUpdate, runsLocalRelay, bindAddress, configExists, configPath, createDraft, createTranslator, ensureRuntime, extractPairingCode, fieldsForMode, fullStatus, getField, getFieldPlaceholder, herdrVersion, keepalive, loadConfig, pair, readLogTail, regenerateHostIdentity, requiresRestart, restartAll, saveDraft, setField, setRelayPassword, startAll, stateDir, stopAll, };
21
+ export { detectLocale, listReachableAddresses, MIN_HERDR_VERSION, OFFICIAL_RELAY_URL, isOfficialRelay, selectedMode, canSelfUpdate, checkForUpdate, compareVersions, currentVersion, updateChecksEnabled, installKind, performUpdate, checkForRelayUpdate, installedRelayVersion, performRelayUpdate, runsLocalRelay, bindAddress, configExists, configPath, createDraft, createTranslator, ensureRuntime, extractPairingCode, fieldsForMode, fullStatus, getField, getFieldPlaceholder, herdrVersion, keepalive, loadConfig, pair, readLogTail, regenerateHostIdentity, requiresRestart, restartAll, saveDraft, setField, setRelayPassword, startAll, stateDir, stopAll, whileServicesStopped, };
21
22
  /** The command that starts a matching relay, ready to paste on the server. */
22
23
  export function relayStartCommand(config, password) {
23
24
  const publicUrl = config.relay.remoteUrl
@@ -4,7 +4,7 @@ import { Box, Text, useInput } from 'ink';
4
4
  import { theme } from '../theme.js';
5
5
  import { useRelayUpdate } from './useRelayUpdate.js';
6
6
  import { Message, Panel, Row, Selectable } from '../components/common.js';
7
- import { canSelfUpdate, checkForUpdate, configPath, currentVersion, detectLocale, installKind, performUpdate, saveDraft, setField, stateDir, } from '../api.js';
7
+ import { canSelfUpdate, checkForUpdate, configPath, currentVersion, detectLocale, installKind, performUpdate, saveDraft, setField, stateDir, whileServicesStopped, } from '../api.js';
8
8
  /** `https://registry.npmmirror.com` → `registry.npmmirror.com`, for one line of text. */
9
9
  const registryHost = (registry) => registry.replace(/^https?:\/\//, '');
10
10
  export function About({ ctx }) {
@@ -92,12 +92,12 @@ export function About({ ctx }) {
92
92
  };
93
93
  const runUpdate = async (latest) => {
94
94
  setUpdate({ phase: 'updating', latest, attempt: 1 });
95
- const result = await performUpdate({
95
+ const result = await whileServicesStopped(ctx.config, () => performUpdate({
96
96
  registry: checkRef.current.registry,
97
97
  sources: checkRef.current.sources,
98
98
  version: latest,
99
99
  onAttempt: ({ attempt }) => setUpdate({ phase: 'updating', latest, attempt }),
100
- });
100
+ }));
101
101
  if (!result.ok) {
102
102
  const params = { version: latest, installed: result.installed ?? '' };
103
103
  setUpdate({ phase: 'error', messageKey: result.errorKey ?? 'update.errorFailed', params });
@@ -4,8 +4,7 @@ import { Box, Text, useInput } from 'ink';
4
4
  import { theme } from '../theme.js';
5
5
  import { Message, Panel, Row, Selectable, StatusDot } from '../components/common.js';
6
6
  import { ChoiceList } from './Relay.js';
7
- import { keepalive, saveDraft, setField } from '../api.js';
8
- const MANAGERS = ['auto', 'systemd', 'launchd', 'supervisor', 'none'];
7
+ import { KEEPALIVE_MANAGERS, keepalive, saveDraft, setField } from '../api.js';
9
8
  export function Keepalive({ ctx }) {
10
9
  const { t, status, draft, editingId } = ctx;
11
10
  const [selected, setSelected] = useState('install');
@@ -24,14 +23,25 @@ export function Keepalive({ ctx }) {
24
23
  ctx.setEditing('keepaliveManager');
25
24
  return;
26
25
  }
27
- ctx.run(() => {
26
+ ctx.run(async () => {
28
27
  if (id === 'install') {
29
- const result = keepalive.install(ctx.config);
30
- ctx.notify((t) => t('keepalive.installed', { manager: result.manager }), 'success');
28
+ if (keepalive.detectManager(ctx.config.keepalive.manager) === 'windows-service')
29
+ ctx.notify((t) => t('keepalive.serviceWindowHint'), 'info');
30
+ try {
31
+ const result = await keepalive.install(ctx.config);
32
+ ctx.notify((t) => t('keepalive.installed', { manager: result.manager }), 'success');
33
+ }
34
+ catch (error) {
35
+ if (error.code === 'ELEVATION_CANCELLED') {
36
+ ctx.notify((t) => t('keepalive.elevationCancelled'), 'info');
37
+ return;
38
+ }
39
+ throw error;
40
+ }
31
41
  return;
32
42
  }
33
43
  if (id === 'uninstall') {
34
- keepalive.uninstall(ctx.config);
44
+ await keepalive.uninstall(ctx.config);
35
45
  ctx.notify((t) => t('keepalive.uninstalled'), 'success');
36
46
  return;
37
47
  }
@@ -56,7 +66,7 @@ export function Keepalive({ ctx }) {
56
66
  activate(selected);
57
67
  }, { isActive: editingId === null });
58
68
  if (editingId === 'keepaliveManager') {
59
- return (_jsx(Panel, { title: t('field.keepalive'), children: _jsx(ChoiceList, { options: MANAGERS.map((manager) => ({ id: manager, label: manager })), current: draft.keepalive.manager, onPick: (manager) => {
69
+ return (_jsx(Panel, { title: t('field.keepalive'), children: _jsx(ChoiceList, { options: KEEPALIVE_MANAGERS.map((manager) => ({ id: manager, label: manager })), current: draft.keepalive.manager, onPick: (manager) => {
60
70
  const result = setField(draft, 'keepaliveManager', manager);
61
71
  const { errorKey } = result;
62
72
  if (errorKey) {
@@ -81,5 +91,5 @@ export function Keepalive({ ctx }) {
81
91
  : t('common.notInstalled')] })] }) }), current?.unitPath ? (_jsx(Row, { label: t('keepalive.unitPath'), children: _jsx(Text, { color: theme.muted, children: current.unitPath }) })) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: entries.map((entry) => (_jsx(Selectable, { selected: selected === entry.id, onSelect: () => {
82
92
  setSelected(entry.id);
83
93
  activate(entry.id);
84
- }, onHover: () => setSelected(entry.id), children: entry.label }, entry.id))) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [current?.manager === 'systemd' ? (_jsx(Text, { color: current.linger ? theme.muted : theme.warn, children: current.linger ? t('keepalive.lingerEnabled') : t('keepalive.lingerDisabled') })) : null, current?.manager === 'supervisor' ? (_jsx(Text, { color: theme.warn, children: t('keepalive.fallbackNote') })) : null, _jsx(Text, { color: theme.muted, children: t('keepalive.logsHint', { command: status?.logsHint ?? '' }) })] }), _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
94
+ }, onHover: () => setSelected(entry.id), children: entry.label }, entry.id))) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [current?.manager === 'systemd' ? (_jsx(Text, { color: current.linger ? theme.muted : theme.warn, children: current.linger ? t('keepalive.lingerEnabled') : t('keepalive.lingerDisabled') })) : null, current?.manager === 'supervisor' ? (_jsx(Text, { color: theme.warn, children: t('keepalive.fallbackNote') })) : null, current?.manager === 'windows-service' ? (_jsx(Text, { color: theme.warn, children: t('keepalive.serviceNote') })) : null, _jsx(Text, { color: theme.muted, children: t('keepalive.logsHint', { command: status?.logsHint ?? '' }) })] }), _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
85
95
  }
@@ -97,13 +97,18 @@ export function Wizard({ ctx, onDone }) {
97
97
  let keepaliveInstalled = false;
98
98
  if (installKeepalive) {
99
99
  try {
100
- keepalive.install(finalConfig);
100
+ if (keepalive.detectManager(finalConfig.keepalive.manager) === 'windows-service')
101
+ ctx.notify((t) => t('keepalive.serviceWindowHint'), 'info');
102
+ await keepalive.install(finalConfig);
101
103
  keepaliveInstalled = true;
102
104
  }
103
105
  catch (error) {
104
106
  // Keep-alive is a convenience; a container without systemd should not
105
107
  // block a working setup.
106
- ctx.notify((t) => t('keepalive.failed', { message: error.message }), 'error');
108
+ if (error.code === 'ELEVATION_CANCELLED')
109
+ ctx.notify((t) => t('keepalive.elevationCancelled'), 'info');
110
+ else
111
+ ctx.notify((t) => t('keepalive.failed', { message: error.message }), 'error');
107
112
  }
108
113
  }
109
114
  if (startNow && !keepaliveInstalled)
@@ -1,7 +1,7 @@
1
1
  // The relay row on the About tab: check for, install and switch to a newer
2
2
  // relay. Only a machine that runs its own relay has one to update.
3
3
  import { useEffect, useState } from 'react';
4
- import { canSelfUpdate, checkForRelayUpdate, compareVersions, currentVersion, installKind, installedRelayVersion, performRelayUpdate, restartAll, runsLocalRelay, } from '../api.js';
4
+ import { canSelfUpdate, checkForRelayUpdate, compareVersions, currentVersion, installKind, installedRelayVersion, performRelayUpdate, restartAll, runsLocalRelay, whileServicesStopped, } from '../api.js';
5
5
  export function useRelayUpdate(ctx) {
6
6
  const { t } = ctx;
7
7
  const [state, setState] = useState({ phase: 'idle' });
@@ -51,11 +51,11 @@ export function useRelayUpdate(ctx) {
51
51
  };
52
52
  const install = async (latest) => {
53
53
  setState({ phase: 'updating', latest, attempt: 1 });
54
- const result = await performRelayUpdate({
54
+ const result = await whileServicesStopped(ctx.config, () => performRelayUpdate({
55
55
  ...source,
56
56
  relayVersion: latest,
57
57
  onAttempt: ({ attempt }) => setState({ phase: 'updating', latest, attempt }),
58
- });
58
+ }));
59
59
  if (!result.ok) {
60
60
  const params = { version: currentVersion(), installed: result.installed ?? '' };
61
61
  const messageKey = result.errorKey ?? 'update.errorFailed';
@@ -0,0 +1,62 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ /**
4
+ * After the CVE-2024-27980 fix, spawning `.cmd` without a shell returns EINVAL;
5
+ * npm-cli.js lets the current Node process run npm without a shell. `npm.cmd`
6
+ * requires cmd.exe, whose quote rules cannot stop `%` expansion in registry URLs.
7
+ */
8
+ export function npmInvocation({ args, platform = process.platform, env = process.env, execPath = process.execPath, exists = fs.existsSync, }) {
9
+ if (platform !== 'win32')
10
+ return { command: 'npm', args };
11
+ const npmExecPath = env.npm_execpath;
12
+ if (npmExecPath && /npm-cli\.js$/i.test(npmExecPath) && exists(npmExecPath)) {
13
+ return { command: execPath, args: [npmExecPath, ...args] };
14
+ }
15
+ const adjacentNpmCli = path.join(path.dirname(execPath), 'node_modules', 'npm', 'bin', 'npm-cli.js');
16
+ if (exists(adjacentNpmCli))
17
+ return { command: execPath, args: [adjacentNpmCli, ...args] };
18
+ return null;
19
+ }
20
+ export function runNpm(spawnImpl, args, timeoutMs, invocationOptions = {}) {
21
+ return new Promise((resolve) => {
22
+ let child;
23
+ try {
24
+ const invocation = npmInvocation({ args, ...invocationOptions });
25
+ if (!invocation) {
26
+ resolve({
27
+ ok: false,
28
+ spawnFailed: true,
29
+ output: 'npm-cli.js was not found next to this Node.js; run npm install -g herdr-remote yourself.',
30
+ });
31
+ return;
32
+ }
33
+ child = spawnImpl(invocation.command, invocation.args, {
34
+ timeout: timeoutMs,
35
+ windowsHide: true,
36
+ });
37
+ }
38
+ catch (error) {
39
+ resolve({
40
+ ok: false,
41
+ spawnFailed: true,
42
+ output: String(error?.message || error),
43
+ });
44
+ return;
45
+ }
46
+ let output = '';
47
+ let settled = false;
48
+ const finish = (result) => {
49
+ if (!settled) {
50
+ settled = true;
51
+ resolve(result);
52
+ }
53
+ };
54
+ const collect = (chunk) => {
55
+ output += String(chunk);
56
+ };
57
+ child.stdout?.on('data', collect);
58
+ child.stderr?.on('data', collect);
59
+ child.on('error', (error) => finish({ ok: false, spawnFailed: true, output: error.message }));
60
+ child.on('close', (code) => finish({ ok: code === 0, output: output.trim() }));
61
+ });
62
+ }
package/dist/updater.js CHANGED
@@ -9,6 +9,7 @@ import os from 'node:os';
9
9
  import path from 'node:path';
10
10
  import { spawn } from 'node:child_process';
11
11
  import { PACKAGE_ROOT } from './paths.js';
12
+ import { runNpm } from './updater/npm.js';
12
13
  const PACKAGE_NAME = 'herdr-remote';
13
14
  const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
14
15
  /**
@@ -268,37 +269,6 @@ function installedVersionOnDisk() {
268
269
  return null;
269
270
  }
270
271
  }
271
- function runNpm(spawnImpl, args, timeoutMs) {
272
- return new Promise((resolve) => {
273
- let child;
274
- try {
275
- child = spawnImpl('npm', args, { timeout: timeoutMs });
276
- }
277
- catch (error) {
278
- resolve({
279
- ok: false,
280
- spawnFailed: true,
281
- output: String(error?.message || error),
282
- });
283
- return;
284
- }
285
- let output = '';
286
- let settled = false;
287
- const finish = (result) => {
288
- if (!settled) {
289
- settled = true;
290
- resolve(result);
291
- }
292
- };
293
- const collect = (chunk) => {
294
- output += String(chunk);
295
- };
296
- child.stdout?.on('data', collect);
297
- child.stderr?.on('data', collect);
298
- child.on('error', (error) => finish({ ok: false, spawnFailed: true, output: error.message }));
299
- child.on('close', (code) => finish({ ok: code === 0, output: output.trim() }));
300
- });
301
- }
302
272
  const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
303
273
  /**
304
274
  * Install a release over this one.
package/herdr-plugin.toml CHANGED
@@ -1,9 +1,9 @@
1
1
  id = "herdr.remote.web"
2
2
  name = "Herdr Remote Web"
3
- version = "0.2.21"
3
+ version = "0.2.22"
4
4
  min_herdr_version = "0.9.1"
5
5
  description = "Browser access to Herdr workspaces via local or self-hosted relay"
6
- platforms = ["linux", "macos"]
6
+ platforms = ["linux", "macos", "windows"]
7
7
 
8
8
  # No [[build]] steps: this package is installed from npm with its TUI bundle and
9
9
  # web assets already built. Herdr should never compile anything on the user's
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "herdr-remote",
3
- "version": "0.2.21",
3
+ "version": "0.2.22",
4
4
  "description": "Browser access to Herdr workspaces: plugin, host connector, and configuration TUI",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -31,6 +31,7 @@
31
31
  "files": [
32
32
  "bin",
33
33
  "dist",
34
+ "vendor",
34
35
  "herdr-plugin.toml",
35
36
  "config.example.json",
36
37
  "README.zh-CN.md"
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2008-2020 Kohsuke Kawaguchi, Sun Microsystems, Inc., CloudBees, Inc., Oleg Nenashev and other contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,9 @@
1
+ # WinSW
2
+
3
+ - Source: https://github.com/winsw/winsw/releases/download/v2.12.0/WinSW.NET461.exe
4
+ - Version: 2.12.0 (`WinSW.NET461.exe`)
5
+ - SHA-256: `b5066b7bbdfba1293e5d15cda3caaea88fbeab35bd5b38c41c913d492aadfc4f`
6
+ - License: MIT; see [LICENSE](./LICENSE).
7
+ - Purpose: hosts `herdr-remote run --daemon` as a Windows service.
8
+
9
+ To upgrade, replace the executable as a whole and update its SHA-256 here.
Binary file