herdr-remote 0.2.20 → 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.
- package/README.md +12 -2
- package/README.zh-CN.md +11 -1
- package/dist/cli.js +23 -4
- package/dist/config.js +8 -1
- package/dist/herdr-api.js +5 -3
- package/dist/herdr-command.js +32 -15
- package/dist/herdr-plugin.js +5 -1
- package/dist/herdr-server.js +3 -2
- package/dist/i18n/en.js +20 -0
- package/dist/i18n/zh.js +20 -0
- package/dist/keepalive/detached.js +13 -3
- package/dist/keepalive/index.js +16 -8
- package/dist/keepalive/launchd.js +4 -4
- package/dist/keepalive/systemd.js +8 -4
- package/dist/keepalive/windows-service-setup.js +129 -0
- package/dist/keepalive/windows-service.js +272 -0
- package/dist/lifecycle.js +15 -1
- package/dist/net-interfaces.js +15 -6
- package/dist/paths.js +16 -4
- package/dist/pty-session.js +10 -2
- package/dist/relay-updater.js +81 -0
- package/dist/service.js +2 -0
- package/dist/socket-discovery.js +15 -3
- package/dist/supervisor.js +1 -0
- package/dist/terminal-font/deps.js +11 -1
- package/dist/terminal-font/files.js +13 -10
- package/dist/terminal-font/readers.js +60 -3
- package/dist/terminal-font/terminals.js +3 -0
- package/dist/terminal-palette.js +78 -18
- package/dist/tui/App.js +34 -8
- package/dist/tui/api.js +6 -4
- package/dist/tui/screens/About.js +30 -15
- package/dist/tui/screens/Herdr.js +5 -4
- package/dist/tui/screens/Keepalive.js +25 -14
- package/dist/tui/screens/Pair.js +1 -1
- package/dist/tui/screens/Relay.js +9 -8
- package/dist/tui/screens/Services.js +4 -4
- package/dist/tui/screens/Wizard.js +11 -5
- package/dist/tui/screens/useRelayUpdate.js +122 -0
- package/dist/updater/npm.js +62 -0
- package/dist/updater.js +8 -37
- package/herdr-plugin.toml +2 -2
- package/package.json +2 -1
- package/vendor/winsw/LICENSE +21 -0
- package/vendor/winsw/README.md +9 -0
- package/vendor/winsw/WinSW.NET461.exe +0 -0
package/dist/socket-discovery.js
CHANGED
|
@@ -5,12 +5,22 @@ function defaultSocketPath(env = process.env, platform = process.platform) {
|
|
|
5
5
|
if (env.HERDR_SOCKET_PATH)
|
|
6
6
|
return env.HERDR_SOCKET_PATH;
|
|
7
7
|
if (platform === 'win32') {
|
|
8
|
-
const
|
|
9
|
-
|
|
8
|
+
const configHome = env.XDG_CONFIG_HOME ||
|
|
9
|
+
env.APPDATA ||
|
|
10
|
+
(env.USERPROFILE && path.join(env.USERPROFILE, 'AppData', 'Roaming')) ||
|
|
11
|
+
path.join(os.homedir(), 'AppData', 'Roaming');
|
|
12
|
+
return path.join(configHome, 'herdr', 'herdr.sock');
|
|
10
13
|
}
|
|
11
14
|
const configHome = env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
12
15
|
return path.join(configHome, 'herdr', 'herdr.sock');
|
|
13
16
|
}
|
|
17
|
+
/**
|
|
18
|
+
* On Windows, Herdr uses this path itself as the GenericNamespaced pipe name;
|
|
19
|
+
* the file only contains the `pid:nanos` marker.
|
|
20
|
+
*/
|
|
21
|
+
function herdrEndpoint(socketPath, platform = process.platform) {
|
|
22
|
+
return platform === 'win32' ? `\\\\.\\pipe\\${socketPath}` : socketPath;
|
|
23
|
+
}
|
|
14
24
|
function resolveSocketPath(configuredPath = null, env = process.env) {
|
|
15
25
|
return configuredPath || env.HERDR_SOCKET_PATH || defaultSocketPath(env);
|
|
16
26
|
}
|
|
@@ -20,9 +30,11 @@ function inspectSocket(socketPath, options = {}) {
|
|
|
20
30
|
}
|
|
21
31
|
try {
|
|
22
32
|
const stat = fs.statSync(socketPath);
|
|
33
|
+
// Herdr leaves an ordinary marker file on Windows and connects through the pipe named by its path.
|
|
23
34
|
if (process.platform !== 'win32' && !stat.isSocket()) {
|
|
24
35
|
return { ok: false, reason: 'path is not a Unix socket', path: socketPath };
|
|
25
36
|
}
|
|
37
|
+
// Windows has no per-process uid to compare, so ownership checks naturally skip there.
|
|
26
38
|
if (options.requireOwner !== false &&
|
|
27
39
|
typeof process.getuid === 'function' &&
|
|
28
40
|
stat.uid !== process.getuid()) {
|
|
@@ -45,4 +57,4 @@ function inspectSocket(socketPath, options = {}) {
|
|
|
45
57
|
};
|
|
46
58
|
}
|
|
47
59
|
}
|
|
48
|
-
export { defaultSocketPath, resolveSocketPath, inspectSocket };
|
|
60
|
+
export { defaultSocketPath, herdrEndpoint, resolveSocketPath, inspectSocket };
|
package/dist/supervisor.js
CHANGED
|
@@ -19,7 +19,11 @@ export const GENERIC_FAMILIES = new Set([
|
|
|
19
19
|
]);
|
|
20
20
|
export function defaultRun(command, args) {
|
|
21
21
|
try {
|
|
22
|
-
const result = spawnSync(command, args, {
|
|
22
|
+
const result = spawnSync(command, args, {
|
|
23
|
+
encoding: 'utf8',
|
|
24
|
+
timeout: COMMAND_TIMEOUT_MS,
|
|
25
|
+
windowsHide: true,
|
|
26
|
+
});
|
|
23
27
|
return { status: result.status, stdout: result.stdout || '' };
|
|
24
28
|
}
|
|
25
29
|
catch {
|
|
@@ -60,6 +64,12 @@ export function configHome(deps) {
|
|
|
60
64
|
export function dataHome(deps) {
|
|
61
65
|
return deps.env.XDG_DATA_HOME || path.join(deps.home, '.local', 'share');
|
|
62
66
|
}
|
|
67
|
+
export function localAppData(deps) {
|
|
68
|
+
return deps.env.LOCALAPPDATA || path.join(deps.home, 'AppData', 'Local');
|
|
69
|
+
}
|
|
70
|
+
export function roamingAppData(deps) {
|
|
71
|
+
return deps.env.APPDATA || path.join(deps.home, 'AppData', 'Roaming');
|
|
72
|
+
}
|
|
63
73
|
export function expandHome(filePath, deps) {
|
|
64
74
|
return filePath.startsWith('~/') ? path.join(deps.home, filePath.slice(2)) : filePath;
|
|
65
75
|
}
|
|
@@ -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
|
|
121
|
-
const roots =
|
|
122
|
-
path.join(deps.home, 'Library', 'Fonts'),
|
|
123
|
-
'
|
|
124
|
-
|
|
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
|
|
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
|
|
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
|
-
:
|
|
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
|
-
|
|
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';
|
package/dist/terminal-palette.js
CHANGED
|
@@ -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], {
|
|
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 (
|
|
164
|
-
const color = ask(
|
|
165
|
-
if (
|
|
166
|
-
|
|
167
|
-
|
|
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/App.js
CHANGED
|
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
3
3
|
import { Box, Text, useApp, useInput } from 'ink';
|
|
4
4
|
import { useMouse, useMouseTarget } from './mouse/index.js';
|
|
5
5
|
import { theme } from './theme.js';
|
|
6
|
-
import { checkForUpdate, createDraft, createTranslator, detectLocale, ensureRuntime, fullStatus, loadConfig, requiresRestart, updateChecksEnabled, } from './api.js';
|
|
6
|
+
import { checkForRelayUpdate, checkForUpdate, createDraft, createTranslator, detectLocale, ensureRuntime, fullStatus, loadConfig, requiresRestart, runsLocalRelay, updateChecksEnabled, } from './api.js';
|
|
7
7
|
import { Overview } from './screens/Overview.js';
|
|
8
8
|
import { PairScreen } from './screens/Pair.js';
|
|
9
9
|
import { Services } from './screens/Services.js';
|
|
@@ -30,7 +30,7 @@ function Tab({ label, active, onSelect, }) {
|
|
|
30
30
|
function Footer({ hints }) {
|
|
31
31
|
return (_jsx(Box, { marginTop: 1, paddingX: 1, children: _jsx(Text, { color: theme.muted, children: hints.join(' · ') }) }));
|
|
32
32
|
}
|
|
33
|
-
export function App({ initialLanguage, needsWizard, updateChecker = updateChecksEnabled() ? checkForUpdate : null, }) {
|
|
33
|
+
export function App({ initialLanguage, needsWizard, updateChecker = updateChecksEnabled() ? checkForUpdate : null, relayUpdateChecker = updateChecksEnabled() ? checkForRelayUpdate : null, }) {
|
|
34
34
|
const { exit } = useApp();
|
|
35
35
|
const mouse = useMouse();
|
|
36
36
|
const [config, setConfig] = useState(() => loadConfig());
|
|
@@ -38,11 +38,13 @@ export function App({ initialLanguage, needsWizard, updateChecker = updateChecks
|
|
|
38
38
|
const [runtime, setRuntime] = useState(() => ensureRuntime());
|
|
39
39
|
const [status, setStatus] = useState(null);
|
|
40
40
|
const [tab, setTab] = useState('overview');
|
|
41
|
-
const [
|
|
41
|
+
const [notice, setNotice] = useState(null);
|
|
42
42
|
const [busy, setBusy] = useState(false);
|
|
43
43
|
const [editingId, setEditingId] = useState(null);
|
|
44
44
|
const [wizardDone, setWizardDone] = useState(!needsWizard);
|
|
45
45
|
const [updateCheck, setUpdateCheck] = useState(null);
|
|
46
|
+
const [relayUpdateCheck, setRelayUpdateCheck] = useState(null);
|
|
47
|
+
const localRelay = runsLocalRelay(config);
|
|
46
48
|
// Every time the TUI opens: that is when someone is here to act on it.
|
|
47
49
|
// Failure says nothing; the About tab can still be asked by hand.
|
|
48
50
|
useEffect(() => {
|
|
@@ -59,13 +61,32 @@ export function App({ initialLanguage, needsWizard, updateChecker = updateChecks
|
|
|
59
61
|
cancelled = true;
|
|
60
62
|
};
|
|
61
63
|
}, [updateChecker]);
|
|
64
|
+
// A relay somewhere else is somebody else's to update.
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!relayUpdateChecker || !localRelay)
|
|
67
|
+
return undefined;
|
|
68
|
+
let cancelled = false;
|
|
69
|
+
relayUpdateChecker()
|
|
70
|
+
.then((result) => {
|
|
71
|
+
if (!cancelled && result?.ok)
|
|
72
|
+
setRelayUpdateCheck(result);
|
|
73
|
+
})
|
|
74
|
+
.catch(() => { });
|
|
75
|
+
return () => {
|
|
76
|
+
cancelled = true;
|
|
77
|
+
};
|
|
78
|
+
}, [relayUpdateChecker, localRelay]);
|
|
62
79
|
const locale = useMemo(() => detectLocale({ preference: initialLanguage ?? draft.ui.language }), [initialLanguage, draft.ui.language]);
|
|
63
80
|
const t = useMemo(() => createTranslator(locale), [locale]);
|
|
64
81
|
const dirty = useMemo(() => JSON.stringify(config) !== JSON.stringify(draft), [config, draft]);
|
|
65
82
|
const restartPending = useMemo(() => dirty && requiresRestart(config, draft), [config, draft, dirty]);
|
|
66
|
-
const notify = useCallback((
|
|
67
|
-
|
|
83
|
+
const notify = useCallback((next, level = 'info') => {
|
|
84
|
+
setNotice({ notice: next, level });
|
|
68
85
|
}, []);
|
|
86
|
+
const message = notice && {
|
|
87
|
+
text: typeof notice.notice === 'string' ? notice.notice : notice.notice(t),
|
|
88
|
+
level: notice.level,
|
|
89
|
+
};
|
|
69
90
|
const refresh = useCallback(() => {
|
|
70
91
|
fullStatus(loadConfig())
|
|
71
92
|
.then((next) => setStatus(next))
|
|
@@ -132,6 +153,8 @@ export function App({ initialLanguage, needsWizard, updateChecker = updateChecks
|
|
|
132
153
|
message,
|
|
133
154
|
updateCheck,
|
|
134
155
|
setUpdateCheck,
|
|
156
|
+
relayUpdateCheck,
|
|
157
|
+
setRelayUpdateCheck,
|
|
135
158
|
};
|
|
136
159
|
useInput((input, key) => {
|
|
137
160
|
if (key.ctrl && input === 'c') {
|
|
@@ -143,13 +166,13 @@ export function App({ initialLanguage, needsWizard, updateChecker = updateChecks
|
|
|
143
166
|
return;
|
|
144
167
|
}
|
|
145
168
|
if (input === 'r') {
|
|
146
|
-
|
|
169
|
+
setNotice(null);
|
|
147
170
|
refresh();
|
|
148
171
|
return;
|
|
149
172
|
}
|
|
150
173
|
if (input === 'm') {
|
|
151
174
|
if (!mouse.supported) {
|
|
152
|
-
notify(t('hint.mouseUnsupported'), 'info');
|
|
175
|
+
notify((t) => t('hint.mouseUnsupported'), 'info');
|
|
153
176
|
return;
|
|
154
177
|
}
|
|
155
178
|
mouseChoice.current = !mouse.enabled;
|
|
@@ -191,5 +214,8 @@ export function App({ initialLanguage, needsWizard, updateChecker = updateChecks
|
|
|
191
214
|
: t('hint.mouseUnsupported'),
|
|
192
215
|
t('hint.quit'),
|
|
193
216
|
].filter(Boolean);
|
|
194
|
-
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: t('app.name') }), _jsx(Text, { color: theme.muted, children: ` ${t('app.tagline')}` })] }), updateCheck?.updateAvailable ? (_jsx(Text, { color: theme.warn, children: t('update.banner', { latest: updateCheck.latest ?? '', current: updateCheck.current }) })) : null
|
|
217
|
+
return (_jsxs(Box, { flexDirection: "column", paddingX: 1, paddingY: 1, children: [_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Box, { children: [_jsx(Text, { bold: true, children: t('app.name') }), _jsx(Text, { color: theme.muted, children: ` ${t('app.tagline')}` })] }), updateCheck?.updateAvailable ? (_jsx(Text, { color: theme.warn, children: t('update.banner', { latest: updateCheck.latest ?? '', current: updateCheck.current }) })) : null, localRelay && relayUpdateCheck?.updateAvailable ? (_jsx(Text, { color: theme.warn, children: t('relayUpdate.banner', {
|
|
218
|
+
latest: relayUpdateCheck.latest ?? '',
|
|
219
|
+
current: relayUpdateCheck.current,
|
|
220
|
+
}) })) : null] }), _jsx(Box, { marginBottom: 1, children: TABS.map((entry, entryIndex) => (_jsx(Tab, { label: `${entryIndex + 1} ${t(entry.labelKey)}`, active: entry.id === tab, onSelect: () => setTab(entry.id) }, entry.id))) }), tab === 'overview' ? _jsx(Overview, { ctx: context }) : null, tab === 'pair' ? _jsx(PairScreen, { ctx: context }) : null, tab === 'services' ? _jsx(Services, { ctx: context }) : null, tab === 'relay' ? _jsx(RelayScreen, { ctx: context }) : null, tab === 'keepalive' ? _jsx(Keepalive, { ctx: context }) : null, tab === 'herdr' ? _jsx(HerdrScreen, { ctx: context }) : null, tab === 'about' ? _jsx(About, { ctx: context }) : null, restartPending ? (_jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.warn, children: t('hint.restartRequired') }) })) : null, _jsx(Footer, { hints: hints })] }));
|
|
195
221
|
}
|
package/dist/tui/api.js
CHANGED
|
@@ -4,8 +4,9 @@
|
|
|
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
|
-
import { bindAddress, resolveAdminOrigin } from '../relay-urls.js';
|
|
9
|
+
import { bindAddress, resolveAdminOrigin, runsLocalRelay } from '../relay-urls.js';
|
|
9
10
|
import { MIN_HERDR_VERSION, herdrVersion } from '../herdr-command.js';
|
|
10
11
|
import { createTranslator, detectLocale } from '../i18n/index.js';
|
|
11
12
|
import { listReachableAddresses } from '../net-interfaces.js';
|
|
@@ -13,10 +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
|
-
import { canSelfUpdate, checkForUpdate, currentVersion, updateChecksEnabled, installKind, performUpdate, } from '../updater.js';
|
|
19
|
-
|
|
19
|
+
import { canSelfUpdate, checkForUpdate, compareVersions, currentVersion, updateChecksEnabled, installKind, performUpdate, } from '../updater.js';
|
|
20
|
+
import { checkForRelayUpdate, installedRelayVersion, performRelayUpdate, } from '../relay-updater.js';
|
|
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, };
|
|
20
22
|
/** The command that starts a matching relay, ready to paste on the server. */
|
|
21
23
|
export function relayStartCommand(config, password) {
|
|
22
24
|
const publicUrl = config.relay.remoteUrl
|
|
@@ -2,8 +2,9 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
2
2
|
import { useEffect, useState } from 'react';
|
|
3
3
|
import { Box, Text, useInput } from 'ink';
|
|
4
4
|
import { theme } from '../theme.js';
|
|
5
|
+
import { useRelayUpdate } from './useRelayUpdate.js';
|
|
5
6
|
import { Message, Panel, Row, Selectable } from '../components/common.js';
|
|
6
|
-
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';
|
|
7
8
|
/** `https://registry.npmmirror.com` → `registry.npmmirror.com`, for one line of text. */
|
|
8
9
|
const registryHost = (registry) => registry.replace(/^https?:\/\//, '');
|
|
9
10
|
export function About({ ctx }) {
|
|
@@ -42,7 +43,12 @@ export function About({ ctx }) {
|
|
|
42
43
|
return t('update.check');
|
|
43
44
|
}
|
|
44
45
|
})();
|
|
45
|
-
const
|
|
46
|
+
const relay = useRelayUpdate(ctx);
|
|
47
|
+
const options = [
|
|
48
|
+
...languageOptions,
|
|
49
|
+
{ id: 'update', label: updateLabel },
|
|
50
|
+
...(relay.visible ? [{ id: 'relay-update', label: relay.label }] : []),
|
|
51
|
+
];
|
|
46
52
|
/** Show a check's answer, whether this screen asked or the TUI did on opening. */
|
|
47
53
|
const applyCheck = (result) => {
|
|
48
54
|
checkRef.current = { registry: result.registry ?? '', sources: result.sources ?? [] };
|
|
@@ -50,12 +56,12 @@ export function About({ ctx }) {
|
|
|
50
56
|
// answer. It is still asked, and said to be behind.
|
|
51
57
|
const behind = result.behind ?? [];
|
|
52
58
|
setSourceNote(result.updateAvailable && behind.length > 0 && result.registry
|
|
53
|
-
?
|
|
59
|
+
? {
|
|
54
60
|
registries: behind
|
|
55
61
|
.map((entry) => `${registryHost(entry.registry)} ${entry.version ?? ''}`.trim())
|
|
56
62
|
.join(', '),
|
|
57
63
|
source: registryHost(result.registry),
|
|
58
|
-
}
|
|
64
|
+
}
|
|
59
65
|
: null);
|
|
60
66
|
setUpdate(result.updateAvailable
|
|
61
67
|
? { phase: 'available', latest: result.latest }
|
|
@@ -76,8 +82,9 @@ export function About({ ctx }) {
|
|
|
76
82
|
// Which registries were tried, and what each of them said. Without this
|
|
77
83
|
// the row reads "could not reach npm registry" on a machine where
|
|
78
84
|
// `npm install` works perfectly, and there is nothing to act on.
|
|
79
|
-
|
|
80
|
-
|
|
85
|
+
const { message } = result;
|
|
86
|
+
if (message)
|
|
87
|
+
ctx.notify((t) => t('update.errorNetworkDetail', { message }), 'error');
|
|
81
88
|
return;
|
|
82
89
|
}
|
|
83
90
|
ctx.setUpdateCheck(result);
|
|
@@ -85,17 +92,17 @@ export function About({ ctx }) {
|
|
|
85
92
|
};
|
|
86
93
|
const runUpdate = async (latest) => {
|
|
87
94
|
setUpdate({ phase: 'updating', latest, attempt: 1 });
|
|
88
|
-
const result = await performUpdate({
|
|
95
|
+
const result = await whileServicesStopped(ctx.config, () => performUpdate({
|
|
89
96
|
registry: checkRef.current.registry,
|
|
90
97
|
sources: checkRef.current.sources,
|
|
91
98
|
version: latest,
|
|
92
99
|
onAttempt: ({ attempt }) => setUpdate({ phase: 'updating', latest, attempt }),
|
|
93
|
-
});
|
|
100
|
+
}));
|
|
94
101
|
if (!result.ok) {
|
|
95
102
|
const params = { version: latest, installed: result.installed ?? '' };
|
|
96
103
|
setUpdate({ phase: 'error', messageKey: result.errorKey ?? 'update.errorFailed', params });
|
|
97
104
|
// npm's own words: "update failed" alone is what left this unfixable.
|
|
98
|
-
ctx.notify(result.summary
|
|
105
|
+
ctx.notify((t) => result.summary
|
|
99
106
|
? t('update.errorFailedDetail', { message: result.summary })
|
|
100
107
|
: t(result.errorKey ?? 'update.errorFailed', params), 'error');
|
|
101
108
|
return;
|
|
@@ -104,7 +111,7 @@ export function About({ ctx }) {
|
|
|
104
111
|
setSourceNote(null);
|
|
105
112
|
// Installed: the line under the title has nothing left to announce.
|
|
106
113
|
ctx.setUpdateCheck(null);
|
|
107
|
-
ctx.notify(t('update.restartHint'), 'success');
|
|
114
|
+
ctx.notify((t) => t('update.restartHint'), 'success');
|
|
108
115
|
};
|
|
109
116
|
/**
|
|
110
117
|
* One row, two meanings: check first, then install what the check found.
|
|
@@ -126,21 +133,26 @@ export function About({ ctx }) {
|
|
|
126
133
|
};
|
|
127
134
|
const choose = (language) => {
|
|
128
135
|
const result = setField(draft, 'language', language);
|
|
129
|
-
|
|
130
|
-
|
|
136
|
+
const { errorKey } = result;
|
|
137
|
+
if (errorKey) {
|
|
138
|
+
ctx.notify((t) => t(errorKey), 'error');
|
|
131
139
|
return;
|
|
132
140
|
}
|
|
133
141
|
ctx.updateDraft(result.draft);
|
|
134
142
|
try {
|
|
135
143
|
saveDraft(result.draft);
|
|
136
144
|
ctx.reloadConfig();
|
|
137
|
-
ctx.notify(t('common.saved', { path: configPath() }), 'success');
|
|
145
|
+
ctx.notify((t) => t('common.saved', { path: configPath() }), 'success');
|
|
138
146
|
}
|
|
139
147
|
catch (error) {
|
|
140
|
-
ctx.notify(t('error.saveFailed', { message: error.message }), 'error');
|
|
148
|
+
ctx.notify((t) => t('error.saveFailed', { message: error.message }), 'error');
|
|
141
149
|
}
|
|
142
150
|
};
|
|
143
151
|
const activate = (id) => {
|
|
152
|
+
if (id === 'relay-update') {
|
|
153
|
+
relay.activate();
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
144
156
|
if (id === 'update') {
|
|
145
157
|
activateUpdate();
|
|
146
158
|
return;
|
|
@@ -162,5 +174,8 @@ export function About({ ctx }) {
|
|
|
162
174
|
}, onHover: () => setSelected(option.id), children: `${option.label}${draft.ui.language === option.id ? ' ✓' : ''}` }, option.id))) }), _jsx(Text, { color: theme.muted, children: t('update.title') }), _jsxs(Box, { flexDirection: "column", marginTop: 1, marginBottom: 1, children: [_jsx(Selectable, { selected: selected === 'update', onSelect: () => {
|
|
163
175
|
setSelected('update');
|
|
164
176
|
activateUpdate();
|
|
165
|
-
}, onHover: () => setSelected('update'), children: updateLabel }), sourceNote ? _jsx(Text, { color: theme.muted, children: ` ${
|
|
177
|
+
}, onHover: () => setSelected('update'), children: updateLabel }), sourceNote ? (_jsx(Text, { color: theme.muted, children: ` ${t('update.mirrorBehind', sourceNote)}` })) : null, relay.visible ? (_jsx(Selectable, { selected: selected === 'relay-update', onSelect: () => {
|
|
178
|
+
setSelected('relay-update');
|
|
179
|
+
relay.activate();
|
|
180
|
+
}, onHover: () => setSelected('relay-update'), children: relay.label })) : null] }), _jsx(Row, { label: t('about.version'), children: _jsx(Text, { color: theme.muted, children: currentVersion() }) }), relay.visible ? (_jsx(Row, { label: t('about.relayPackage'), children: _jsx(Text, { color: theme.muted, children: relay.installed ?? '?' }) })) : null, _jsx(Row, { label: t('about.configPath'), children: _jsx(Text, { color: theme.muted, children: configPath() }) }), _jsx(Row, { label: t('about.statePath'), children: _jsx(Text, { color: theme.muted, children: stateDir() }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { color: theme.muted, children: t('about.docs') }) }), _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
|
|
166
181
|
}
|