herdr-remote 0.2.26 → 0.2.28
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 +6 -4
- package/README.zh-CN.md +3 -2
- package/dist/cli.js +27 -2
- package/dist/connector/admin-broker.js +123 -29
- package/dist/connector/admin-shell.js +65 -0
- package/dist/connector/admin-tab.js +90 -0
- package/dist/connector/adminBrokerEndpoint.js +68 -0
- package/dist/connector/elevatedPty.js +31 -35
- package/dist/connector/host-connector.js +8 -1
- package/dist/connector/sessions.js +10 -39
- package/dist/connector/update-report.js +2 -0
- package/dist/i18n/en.js +13 -0
- package/dist/i18n/zh.js +13 -0
- package/dist/keepalive/detached.js +3 -2
- package/dist/lifecycle.js +6 -2
- package/dist/node-pty-loader.js +80 -0
- package/dist/pty-session.js +3 -29
- package/dist/service.js +3 -1
- package/dist/supervisor.js +2 -2
- package/dist/tui/api.js +2 -2
- package/dist/tui/screens/Keepalive.js +1 -1
- package/dist/update-command.js +120 -0
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
- package/dist/connector/adminBrokerPipe.js +0 -16
|
@@ -1,54 +1,46 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { ADMIN_BROKER_MAX_MESSAGE_BYTES, adminBrokerPipePath, sendAdminBrokerMessage, } from './adminBrokerPipe.js';
|
|
1
|
+
import { ADMIN_BROKER_MAX_MESSAGE_BYTES, connectAdminBroker, sendAdminBrokerMessage, } from './adminBrokerEndpoint.js';
|
|
3
2
|
const BROKER_CONNECT_TIMEOUT_MS = 5_000;
|
|
3
|
+
/** A PTY the admin broker runs elevated, driven over its loopback socket. */
|
|
4
4
|
export class ElevatedPty {
|
|
5
|
-
pid = 0;
|
|
6
|
-
cols;
|
|
7
|
-
rows;
|
|
8
5
|
socket = null;
|
|
9
6
|
input = '';
|
|
10
7
|
connected = false;
|
|
11
8
|
started = false;
|
|
12
9
|
finished = false;
|
|
13
10
|
killed = false;
|
|
11
|
+
/** Keystrokes typed while the elevated shell was still starting. */
|
|
12
|
+
early = [];
|
|
14
13
|
dataListener = null;
|
|
15
14
|
exitListener = null;
|
|
16
|
-
readyListener = null;
|
|
17
15
|
errorListener = null;
|
|
18
16
|
options;
|
|
19
|
-
constructor(options
|
|
17
|
+
constructor(options) {
|
|
20
18
|
this.options = options;
|
|
21
|
-
this.cols = cols;
|
|
22
|
-
this.rows = rows;
|
|
23
19
|
}
|
|
24
|
-
onData
|
|
25
|
-
this.dataListener = listener;
|
|
26
|
-
}
|
|
27
|
-
onExit(listener) {
|
|
28
|
-
this.exitListener = listener;
|
|
29
|
-
}
|
|
30
|
-
start({ cols, rows, onData, onExit, onReady, onError }) {
|
|
31
|
-
this.cols = cols;
|
|
32
|
-
this.rows = rows;
|
|
20
|
+
start({ cols, rows, onData, onExit, onError }) {
|
|
33
21
|
this.dataListener = onData;
|
|
34
22
|
this.exitListener = onExit;
|
|
35
|
-
this.
|
|
36
|
-
|
|
37
|
-
|
|
23
|
+
this.errorListener = onError;
|
|
24
|
+
let broker;
|
|
25
|
+
try {
|
|
26
|
+
broker = connectAdminBroker();
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
this.fail(error);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const { socket, token } = broker;
|
|
38
33
|
this.socket = socket;
|
|
39
34
|
socket.setEncoding('utf8');
|
|
40
35
|
socket.setTimeout(BROKER_CONNECT_TIMEOUT_MS, () => {
|
|
41
|
-
socket.destroy(new Error('Administrator terminal broker connection timed out')
|
|
36
|
+
socket.destroy(Object.assign(new Error('Administrator terminal broker connection timed out'), {
|
|
37
|
+
code: 'ETIMEDOUT',
|
|
38
|
+
}));
|
|
42
39
|
});
|
|
43
40
|
socket.on('connect', () => {
|
|
44
41
|
this.connected = true;
|
|
45
42
|
socket.setTimeout(0);
|
|
46
|
-
sendAdminBrokerMessage(socket, {
|
|
47
|
-
type: 'start',
|
|
48
|
-
...this.options,
|
|
49
|
-
cols: this.cols,
|
|
50
|
-
rows: this.rows,
|
|
51
|
-
});
|
|
43
|
+
sendAdminBrokerMessage(socket, { type: 'start', token, ...this.options, cols, rows });
|
|
52
44
|
});
|
|
53
45
|
socket.on('data', (chunk) => this.receive(chunk));
|
|
54
46
|
socket.on('error', (error) => this.fail(error));
|
|
@@ -60,7 +52,13 @@ export class ElevatedPty {
|
|
|
60
52
|
});
|
|
61
53
|
}
|
|
62
54
|
write(data) {
|
|
63
|
-
if (this.
|
|
55
|
+
if (this.finished)
|
|
56
|
+
return;
|
|
57
|
+
if (!this.started) {
|
|
58
|
+
this.early.push(Buffer.from(data));
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
if (this.socket?.writable) {
|
|
64
62
|
sendAdminBrokerMessage(this.socket, {
|
|
65
63
|
type: 'input',
|
|
66
64
|
dataBase64: Buffer.from(data).toString('base64'),
|
|
@@ -68,15 +66,12 @@ export class ElevatedPty {
|
|
|
68
66
|
}
|
|
69
67
|
}
|
|
70
68
|
resize(cols, rows) {
|
|
71
|
-
this.cols = cols;
|
|
72
|
-
this.rows = rows;
|
|
73
69
|
if (this.started && this.socket?.writable) {
|
|
74
70
|
sendAdminBrokerMessage(this.socket, { type: 'resize', cols, rows });
|
|
75
71
|
}
|
|
76
72
|
}
|
|
77
73
|
kill() {
|
|
78
74
|
this.killed = true;
|
|
79
|
-
this.pid = 0;
|
|
80
75
|
if (!this.socket)
|
|
81
76
|
return;
|
|
82
77
|
if (this.connected && this.socket.writable) {
|
|
@@ -109,15 +104,16 @@ export class ElevatedPty {
|
|
|
109
104
|
}
|
|
110
105
|
if (message.type === 'started') {
|
|
111
106
|
this.started = true;
|
|
112
|
-
|
|
113
|
-
this.
|
|
107
|
+
const early = Buffer.concat(this.early);
|
|
108
|
+
this.early = [];
|
|
109
|
+
if (early.length > 0)
|
|
110
|
+
this.write(early);
|
|
114
111
|
}
|
|
115
112
|
else if (message.type === 'data' && typeof message.dataBase64 === 'string') {
|
|
116
113
|
this.dataListener?.(Buffer.from(message.dataBase64, 'base64').toString('utf8'));
|
|
117
114
|
}
|
|
118
115
|
else if (message.type === 'exit') {
|
|
119
116
|
this.finished = true;
|
|
120
|
-
this.pid = 0;
|
|
121
117
|
this.exitListener?.({
|
|
122
118
|
exitCode: Number(message.exitCode) || 0,
|
|
123
119
|
...(typeof message.signal === 'number' ? { signal: message.signal } : {}),
|
|
@@ -13,6 +13,7 @@ import { hostWebSocketUrl, resolveHostRelayUrl } from '../relay-urls.js';
|
|
|
13
13
|
import { resolveSocketPath } from '../socket-discovery.js';
|
|
14
14
|
import { resolveHostPalette } from '../terminal-palette.js';
|
|
15
15
|
import { randomToken } from 'herdr-remote-relay/state';
|
|
16
|
+
import { openAdminTabFor } from './admin-tab.js';
|
|
16
17
|
import { AgentWatch } from './agent-watch.js';
|
|
17
18
|
import { FontServer } from './font-server.js';
|
|
18
19
|
import { acquireHostLock, releaseHostLock } from './lock.js';
|
|
@@ -196,7 +197,7 @@ export class HostConnector {
|
|
|
196
197
|
CAPABILITY.hostHandoff,
|
|
197
198
|
CAPABILITY.idleHeartbeat,
|
|
198
199
|
CAPABILITY.binaryFrameV2,
|
|
199
|
-
...(process.platform === 'win32' ? [CAPABILITY.
|
|
200
|
+
...(process.platform === 'win32' ? [CAPABILITY.adminTabs] : []),
|
|
200
201
|
],
|
|
201
202
|
});
|
|
202
203
|
// The relay sends host_ready with the current browser count. No business
|
|
@@ -283,6 +284,12 @@ export class HostConnector {
|
|
|
283
284
|
return this.startSession(message);
|
|
284
285
|
case 'herdr_start':
|
|
285
286
|
return this.sessions.startHerdrFor(streamOf(message));
|
|
287
|
+
case 'admin_tab_open':
|
|
288
|
+
return openAdminTabFor(streamOf(message), {
|
|
289
|
+
socketPath: this.socketPath,
|
|
290
|
+
language: this.config.ui?.language,
|
|
291
|
+
send: (payload) => sendJson(this.ws, payload),
|
|
292
|
+
});
|
|
286
293
|
case 'session_stop': {
|
|
287
294
|
const streamId = streamOf(message);
|
|
288
295
|
if (streamId)
|
|
@@ -105,10 +105,6 @@ export class Sessions {
|
|
|
105
105
|
if (!streamId)
|
|
106
106
|
return undefined;
|
|
107
107
|
this.stop(streamId);
|
|
108
|
-
if (message.adminTerminal) {
|
|
109
|
-
this.spawn(message);
|
|
110
|
-
return undefined;
|
|
111
|
-
}
|
|
112
108
|
const missingHerdr = this.ensureHerdrCommand();
|
|
113
109
|
if (missingHerdr) {
|
|
114
110
|
process.stderr.write(`herdr-remote host connector: ${missingHerdr}\n`);
|
|
@@ -204,15 +200,11 @@ export class Sessions {
|
|
|
204
200
|
spawn(message) {
|
|
205
201
|
const streamId = streamOf(message);
|
|
206
202
|
const streamIndex = typeof message.streamIndex === 'number' ? message.streamIndex : null;
|
|
207
|
-
const adminTerminal = message.adminTerminal === true;
|
|
208
|
-
// The normal Herdr server keeps its original token, so admin mode needs its own shell.
|
|
209
203
|
const pty = new this.Pty({
|
|
210
|
-
command:
|
|
211
|
-
args:
|
|
204
|
+
command: this.herdrCommand,
|
|
205
|
+
args: this.herdrArgs,
|
|
212
206
|
cwd: this.cwd,
|
|
213
|
-
socketPath:
|
|
214
|
-
adminTerminal,
|
|
215
|
-
fastWindowsPty: adminTerminal,
|
|
207
|
+
socketPath: this.socketPath,
|
|
216
208
|
});
|
|
217
209
|
const session = {
|
|
218
210
|
id: streamId,
|
|
@@ -243,29 +235,6 @@ export class Sessions {
|
|
|
243
235
|
? packStreamFrameV2(FRAME_TYPE_OUTPUT, session.streamIndex, payload)
|
|
244
236
|
: packStreamFrame('output', streamId, payload));
|
|
245
237
|
};
|
|
246
|
-
let readySent = false;
|
|
247
|
-
const reportReady = () => {
|
|
248
|
-
if (readySent || this.byStream.get(streamId) !== session)
|
|
249
|
-
return;
|
|
250
|
-
readySent = true;
|
|
251
|
-
this.link.send({ type: 'session_ready', clientId: streamId });
|
|
252
|
-
};
|
|
253
|
-
const reportStartError = (error) => {
|
|
254
|
-
if (this.byStream.get(streamId) !== session)
|
|
255
|
-
return;
|
|
256
|
-
this.forget(session);
|
|
257
|
-
session.pty.kill();
|
|
258
|
-
const code = error.code;
|
|
259
|
-
const brokerUnavailable = session.pty.adminTerminal &&
|
|
260
|
-
(code === 'ENOENT' || code === 'ECONNREFUSED' || code === 'ETIMEDOUT');
|
|
261
|
-
this.sendError(streamId, brokerUnavailable ? 'admin_broker_unavailable' : 'pty_start_failed', brokerUnavailable
|
|
262
|
-
? 'Run herdr-remote admin-broker install once from an administrator terminal.'
|
|
263
|
-
: error.message);
|
|
264
|
-
this.link.changed();
|
|
265
|
-
};
|
|
266
|
-
this.byStream.set(streamId, session);
|
|
267
|
-
if (streamIndex !== null)
|
|
268
|
-
this.streamIndexToId.set(streamIndex, streamId);
|
|
269
238
|
try {
|
|
270
239
|
pty.start({
|
|
271
240
|
cols: session.cols,
|
|
@@ -291,16 +260,18 @@ export class Sessions {
|
|
|
291
260
|
this.reportExit(session, exitCode);
|
|
292
261
|
this.link.changed();
|
|
293
262
|
},
|
|
294
|
-
onReady: reportReady,
|
|
295
|
-
onError: reportStartError,
|
|
296
263
|
});
|
|
297
|
-
if (!adminTerminal)
|
|
298
|
-
reportReady();
|
|
299
264
|
}
|
|
300
265
|
catch (error) {
|
|
301
|
-
|
|
266
|
+
discardOutput(session);
|
|
267
|
+
this.sendError(streamId, 'pty_start_failed', error.message);
|
|
268
|
+
pty.kill();
|
|
302
269
|
return;
|
|
303
270
|
}
|
|
271
|
+
this.byStream.set(streamId, session);
|
|
272
|
+
if (streamIndex !== null)
|
|
273
|
+
this.streamIndexToId.set(streamIndex, streamId);
|
|
274
|
+
this.link.send({ type: 'session_ready', clientId: streamId });
|
|
304
275
|
this.link.changed();
|
|
305
276
|
}
|
|
306
277
|
/**
|
|
@@ -54,6 +54,8 @@ export class UpdateReport {
|
|
|
54
54
|
updateAvailable: compareVersions(result.latest, running) > 0,
|
|
55
55
|
// Updated on disk, still running the old code: a restart finishes it.
|
|
56
56
|
restartPending: compareVersions(installed, running) > 0,
|
|
57
|
+
// Older connectors leave this out, and their windows keep the npm steps.
|
|
58
|
+
updateCommand: true,
|
|
57
59
|
});
|
|
58
60
|
})
|
|
59
61
|
.catch(() => {
|
package/dist/i18n/en.js
CHANGED
|
@@ -86,6 +86,7 @@ const en = {
|
|
|
86
86
|
'services.logHost': 'Host connector',
|
|
87
87
|
'adminBroker.windowsOnly': 'The administrator terminal broker only runs on Windows.',
|
|
88
88
|
'adminBroker.needsElevation': 'Open the Windows TUI and choose Set up admin terminal access. Approve the one-time Windows prompt under the same account as Herdr Remote.',
|
|
89
|
+
'adminBroker.busy': 'An admin terminal is open; close it before restarting the broker.',
|
|
89
90
|
'adminBroker.title': 'Admin terminal broker',
|
|
90
91
|
'adminBroker.checking': 'Checking…',
|
|
91
92
|
'adminBroker.ready': 'Ready',
|
|
@@ -97,6 +98,9 @@ const en = {
|
|
|
97
98
|
'adminBroker.elevationCancelled': 'Administrator approval was declined; no changes were made.',
|
|
98
99
|
'adminBroker.setupFailed': 'Could not configure the administrator terminal broker.',
|
|
99
100
|
'adminBroker.tuiHint': 'Set this up once. Windows asks for administrator approval; later WebUI admin terminals open without UAC.',
|
|
101
|
+
'adminShell.needsTerminal': 'admin-shell must run in a terminal.',
|
|
102
|
+
'adminShell.brokerUnavailable': 'The administrator terminal broker is not running. Open the herdr-remote TUI, go to Keep-alive, and choose Set up admin terminal access.',
|
|
103
|
+
'adminShell.tabLabel': 'Admin',
|
|
100
104
|
'relay.title': 'Relay settings',
|
|
101
105
|
'relay.settings': 'Settings',
|
|
102
106
|
'relay.locked': 'fixed',
|
|
@@ -241,6 +245,14 @@ const en = {
|
|
|
241
245
|
'update.mirrorBehind': '{registries} still has an older version; installing from {source}',
|
|
242
246
|
'update.cannot.source': 'Source checkout — update with git',
|
|
243
247
|
'update.cannot.linked': 'Linked package — managed by npm link',
|
|
248
|
+
'update.stoppingServices': 'Stopping services while npm replaces their files…',
|
|
249
|
+
'update.restarting': 'Restarting services on the new release…',
|
|
250
|
+
'update.notRunning': 'Services were not running and were left stopped. Start them with: herdr-remote start',
|
|
251
|
+
'update.restartFailed': 'Installed, but the services did not restart: {message}',
|
|
252
|
+
'update.brokerRestarted': 'Admin terminal broker restarted.',
|
|
253
|
+
'update.brokerRestartFailed': 'The admin terminal broker did not start again. Sign out of Windows and back in, or run herdr-remote admin-broker restart.',
|
|
254
|
+
'update.brokerBusy': 'An admin terminal is open, so the broker keeps running the previous release. Close admin terminals, then run herdr-remote admin-broker restart.',
|
|
255
|
+
'update.errorBusy': "A process still holds the herdr-remote folder; an admin terminal broker set up by an older release does. In an administrator PowerShell run: Get-ScheduledTask -TaskName 'HerdrRemoteAdminBroker-*' | Stop-ScheduledTask. Then run herdr-remote update again here, and afterwards herdr-remote admin-broker install as administrator.",
|
|
244
256
|
'relayUpdate.check': 'Relay {version}: check for updates',
|
|
245
257
|
'relayUpdate.checking': 'Relay: checking npm…',
|
|
246
258
|
'relayUpdate.upToDate': 'Relay up to date ({version})',
|
|
@@ -251,6 +263,7 @@ const en = {
|
|
|
251
263
|
'relayUpdate.restart': 'Relay {version} installed, {running} still running (press Enter to restart)',
|
|
252
264
|
'relayUpdate.restarting': 'Restarting herdr-remote…',
|
|
253
265
|
'relayUpdate.restarted': 'herdr-remote restarted; the relay now runs {version}.',
|
|
266
|
+
'relayUpdate.done': 'Relay updated to {version}',
|
|
254
267
|
'relayUpdate.errorNotApplied': 'npm finished, but relay {installed} is still installed',
|
|
255
268
|
'relayUpdate.banner': 'Relay {latest} is out (installed {current}). Press 7 and Enter on the relay row to install.',
|
|
256
269
|
};
|
package/dist/i18n/zh.js
CHANGED
|
@@ -85,6 +85,7 @@ const zh = {
|
|
|
85
85
|
'services.logHost': '主机连接器',
|
|
86
86
|
'adminBroker.windowsOnly': '管理员终端 broker 只支持 Windows。',
|
|
87
87
|
'adminBroker.needsElevation': '请打开 Windows TUI 并选择“设置管理员终端”,使用运行 Herdr Remote 的同一账户批准一次 Windows 提权提示。',
|
|
88
|
+
'adminBroker.busy': '有管理员终端开着,请先关闭再重启 broker。',
|
|
88
89
|
'adminBroker.title': '管理员终端 broker',
|
|
89
90
|
'adminBroker.checking': '正在检查…',
|
|
90
91
|
'adminBroker.ready': '已就绪',
|
|
@@ -96,6 +97,9 @@ const zh = {
|
|
|
96
97
|
'adminBroker.elevationCancelled': '未批准管理员权限,未进行更改。',
|
|
97
98
|
'adminBroker.setupFailed': '管理员终端 broker 设置失败。',
|
|
98
99
|
'adminBroker.tuiHint': '只需设置一次。Windows 会请求管理员授权;之后 WebUI 新建管理员终端不再弹出 UAC。',
|
|
100
|
+
'adminShell.needsTerminal': 'admin-shell 必须在终端中运行。',
|
|
101
|
+
'adminShell.brokerUnavailable': '管理员终端 broker 尚未运行。请打开 herdr-remote TUI,进入「保活」并选择「设置管理员终端」。',
|
|
102
|
+
'adminShell.tabLabel': '管理员',
|
|
99
103
|
'relay.title': 'Relay 设置',
|
|
100
104
|
'relay.settings': '设置',
|
|
101
105
|
'relay.locked': '固定',
|
|
@@ -240,6 +244,14 @@ const zh = {
|
|
|
240
244
|
'update.mirrorBehind': '{registries} 仍是旧版本,将从 {source} 安装',
|
|
241
245
|
'update.cannot.source': '源码目录运行,请使用 git 更新',
|
|
242
246
|
'update.cannot.linked': 'npm link 工作副本,由 npm link 管理',
|
|
247
|
+
'update.stoppingServices': '替换文件期间先停止服务…',
|
|
248
|
+
'update.restarting': '正在用新版本重启服务…',
|
|
249
|
+
'update.notRunning': '服务之前没有运行,保持停止。需要时执行:herdr-remote start',
|
|
250
|
+
'update.restartFailed': '已安装,但服务没能重启:{message}',
|
|
251
|
+
'update.brokerRestarted': '管理员终端 broker 已重启。',
|
|
252
|
+
'update.brokerRestartFailed': '管理员终端 broker 没有重新启动。重新登录 Windows,或运行 herdr-remote admin-broker restart。',
|
|
253
|
+
'update.brokerBusy': '有管理员终端开着,broker 继续运行旧版本。关闭所有管理员终端后运行 herdr-remote admin-broker restart。',
|
|
254
|
+
'update.errorBusy': "仍有进程占用 herdr-remote 的安装目录,旧版本设置的管理员终端 broker 会这样。先在管理员 PowerShell 中执行:Get-ScheduledTask -TaskName 'HerdrRemoteAdminBroker-*' | Stop-ScheduledTask,再回到这里重新运行 herdr-remote update,最后以管理员身份运行 herdr-remote admin-broker install。",
|
|
243
255
|
'relayUpdate.check': 'Relay {version}:检查更新',
|
|
244
256
|
'relayUpdate.checking': 'Relay:查询 npm 中…',
|
|
245
257
|
'relayUpdate.upToDate': 'Relay 已是最新({version})',
|
|
@@ -250,6 +262,7 @@ const zh = {
|
|
|
250
262
|
'relayUpdate.restart': 'Relay {version} 已安装,运行中的仍是 {running}(按 Enter 重启生效)',
|
|
251
263
|
'relayUpdate.restarting': '正在重启 herdr-remote…',
|
|
252
264
|
'relayUpdate.restarted': 'herdr-remote 已重启,relay 现在运行 {version}。',
|
|
265
|
+
'relayUpdate.done': 'Relay 已更新至 {version}',
|
|
253
266
|
'relayUpdate.errorNotApplied': 'npm 已完成,但安装的仍是 relay {installed}',
|
|
254
267
|
'relayUpdate.banner': 'Relay {latest} 已发布(当前 {current}),按 7 进入「语言与关于」,在 Relay 一行按 Enter 安装。',
|
|
255
268
|
};
|
|
@@ -5,7 +5,7 @@ import fs from 'node:fs';
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { ensureDir, readJson, writeJsonAtomic } from 'herdr-remote-relay/state';
|
|
7
7
|
import { pidAlive } from '../lib/process.js';
|
|
8
|
-
import { logPath,
|
|
8
|
+
import { logPath, stateDir } from '../paths.js';
|
|
9
9
|
import { cliEntryPoint, serviceEnvironment } from './environment.js';
|
|
10
10
|
function fallbackPidPath() {
|
|
11
11
|
return path.join(stateDir(), 'supervisor.pid');
|
|
@@ -22,7 +22,8 @@ function start() {
|
|
|
22
22
|
const logFd = fs.openSync(logPath('supervisor'), 'a');
|
|
23
23
|
try {
|
|
24
24
|
const child = spawn(process.execPath, [cliEntryPoint(), 'run', '--daemon'], {
|
|
25
|
-
|
|
25
|
+
// Not PACKAGE_ROOT: npm renames it to update, which Windows refuses for a working directory.
|
|
26
|
+
cwd: stateDir(),
|
|
26
27
|
env: { ...process.env, HERDR_REMOTE_SERVICE: '1', ...serviceEnvironment() },
|
|
27
28
|
detached: true,
|
|
28
29
|
windowsHide: true,
|
package/dist/lifecycle.js
CHANGED
|
@@ -49,8 +49,12 @@ function restartAll(config = loadConfig()) {
|
|
|
49
49
|
}
|
|
50
50
|
return { ...restartServices(), managed: false };
|
|
51
51
|
}
|
|
52
|
+
/** Whether the host connector is up, under a keep-alive manager or detached. */
|
|
53
|
+
function servicesRunning(config = loadConfig()) {
|
|
54
|
+
return Boolean(managerInUse(config)?.active || pidAlive(readRuntime().hostPid));
|
|
55
|
+
}
|
|
52
56
|
/** Stop Windows keep-alive processes while npm replaces their package files. */
|
|
53
|
-
async function whileServicesStopped(config, task, { platform = process.platform, isRunning = () =>
|
|
57
|
+
async function whileServicesStopped(config, task, { platform = process.platform, isRunning = () => servicesRunning(config), stop = stopAll, start = startAll, } = {}) {
|
|
54
58
|
if (platform !== 'win32' || !isRunning())
|
|
55
59
|
return task();
|
|
56
60
|
await stop(config);
|
|
@@ -70,4 +74,4 @@ async function fullStatus(config = loadConfig()) {
|
|
|
70
74
|
logsHint: keepalive.logsHint(config),
|
|
71
75
|
};
|
|
72
76
|
}
|
|
73
|
-
export { startAll, stopAll, restartAll, fullStatus, managerInUse, whileServicesStopped };
|
|
77
|
+
export { startAll, stopAll, restartAll, fullStatus, managerInUse, servicesRunning, whileServicesStopped, };
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Where node-pty is loaded from.
|
|
2
|
+
//
|
|
3
|
+
// npm updates this package by renaming its directory and deleting the old one,
|
|
4
|
+
// and Windows refuses both while a process has a file in it loaded: node-pty's
|
|
5
|
+
// .node modules, or the OpenConsole.exe that ConPTY starts from beside them.
|
|
6
|
+
// The host connector and the admin broker hold those for as long as they run,
|
|
7
|
+
// so on Windows node-pty is loaded from a copy in the state directory, one per
|
|
8
|
+
// build. Copies are never deleted: a process of an older release may still
|
|
9
|
+
// have one loaded, and a build is a few megabytes.
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import { createRequire } from 'node:module';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { stateDir } from './paths.js';
|
|
15
|
+
const require = createRequire(import.meta.url);
|
|
16
|
+
/** What node-pty loads or starts; debug symbols are most of the prebuilds' size. */
|
|
17
|
+
const BINARY_EXTENSIONS = ['.node', '.dll', '.exe'];
|
|
18
|
+
/** The files a running node-pty needs, relative to its package directory. */
|
|
19
|
+
function runtimeFiles(source, platform, arch) {
|
|
20
|
+
const files = ['package.json'];
|
|
21
|
+
const collect = (directory, keep) => {
|
|
22
|
+
const absolute = path.join(source, directory);
|
|
23
|
+
if (!fs.existsSync(absolute))
|
|
24
|
+
return;
|
|
25
|
+
for (const entry of fs.readdirSync(absolute, { recursive: true, withFileTypes: true })) {
|
|
26
|
+
if (entry.isFile() && keep(entry.name))
|
|
27
|
+
files.push(path.relative(source, path.join(entry.parentPath, entry.name)));
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
collect('lib', (name) => name.endsWith('.js') && !name.endsWith('.test.js'));
|
|
31
|
+
const binary = (name) => BINARY_EXTENSIONS.includes(path.extname(name));
|
|
32
|
+
// Where node-pty looks for its modules: a local build, then the prebuilds.
|
|
33
|
+
collect(path.join('build', 'Release'), binary);
|
|
34
|
+
collect(path.join('prebuilds', `${platform}-${arch}`), binary);
|
|
35
|
+
return files;
|
|
36
|
+
}
|
|
37
|
+
/** Copy node-pty's run-time files under `directory`, once per build; returns the copy. */
|
|
38
|
+
export function copyNodePty(source, directory, platform, arch) {
|
|
39
|
+
const files = runtimeFiles(source, platform, arch);
|
|
40
|
+
const hash = createHash('sha256');
|
|
41
|
+
for (const file of files)
|
|
42
|
+
hash.update(`${file}\0`).update(fs.readFileSync(path.join(source, file)));
|
|
43
|
+
const target = path.join(directory, hash.digest('hex').slice(0, 16));
|
|
44
|
+
if (fs.existsSync(target))
|
|
45
|
+
return target;
|
|
46
|
+
// Renamed into place whole, so a copy that exists is complete.
|
|
47
|
+
const staging = `${target}.${process.pid}.tmp`;
|
|
48
|
+
for (const file of files) {
|
|
49
|
+
fs.mkdirSync(path.dirname(path.join(staging, file)), { recursive: true });
|
|
50
|
+
fs.copyFileSync(path.join(source, file), path.join(staging, file));
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
fs.renameSync(staging, target);
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
fs.rmSync(staging, { recursive: true, force: true });
|
|
57
|
+
// Another process put the same build in place first.
|
|
58
|
+
if (!fs.existsSync(target))
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
return target;
|
|
62
|
+
}
|
|
63
|
+
export function loadNodePty({ platform = process.platform, arch = process.arch, source = path.dirname(require.resolve('node-pty/package.json')), directory = path.join(stateDir(), 'node-pty'), warn = (message) => process.stderr.write(`${message}\n`), } = {}) {
|
|
64
|
+
if (platform !== 'win32')
|
|
65
|
+
return require(source);
|
|
66
|
+
try {
|
|
67
|
+
return require(copyNodePty(source, directory, platform, arch));
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
// Terminals still work from the package; only an update has to wait for them to end.
|
|
71
|
+
warn(`herdr-remote: could not copy node-pty out of the package: ${error.message}`);
|
|
72
|
+
return require(source);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
let loaded = null;
|
|
76
|
+
/** node-pty, loaded on first use: only processes that start terminals pay for it. */
|
|
77
|
+
export function nodePty() {
|
|
78
|
+
loaded ??= loadNodePty();
|
|
79
|
+
return loaded;
|
|
80
|
+
}
|
package/dist/pty-session.js
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
|
-
import
|
|
3
|
-
import { ElevatedPty } from './connector/elevatedPty.js';
|
|
2
|
+
import { nodePty } from './node-pty-loader.js';
|
|
4
3
|
class PtySession {
|
|
5
4
|
static DEFAULT_COLS = 100;
|
|
6
5
|
static DEFAULT_ROWS = 30;
|
|
@@ -11,12 +10,11 @@ class PtySession {
|
|
|
11
10
|
cwd;
|
|
12
11
|
socketPath;
|
|
13
12
|
platform;
|
|
14
|
-
adminTerminal;
|
|
15
13
|
fastWindowsPty;
|
|
16
14
|
spawnPty;
|
|
17
15
|
terminal;
|
|
18
16
|
startedAt;
|
|
19
|
-
constructor({ command, args = [], cwd = os.homedir(), socketPath, platform = process.platform, spawn: spawnPty =
|
|
17
|
+
constructor({ command, args = [], cwd = os.homedir(), socketPath, platform = process.platform, spawn: spawnPty = nodePty().spawn, fastWindowsPty = false, } = {}) {
|
|
20
18
|
if (typeof command !== 'string' || command.length === 0)
|
|
21
19
|
throw new TypeError('command must be a non-empty string');
|
|
22
20
|
if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string'))
|
|
@@ -26,7 +24,6 @@ class PtySession {
|
|
|
26
24
|
this.cwd = cwd;
|
|
27
25
|
this.socketPath = socketPath;
|
|
28
26
|
this.platform = platform;
|
|
29
|
-
this.adminTerminal = adminTerminal;
|
|
30
27
|
this.fastWindowsPty = fastWindowsPty;
|
|
31
28
|
this.spawnPty = spawnPty;
|
|
32
29
|
this.terminal = null;
|
|
@@ -50,31 +47,9 @@ class PtySession {
|
|
|
50
47
|
return fallback;
|
|
51
48
|
return Math.min(PtySession.MAX_DIMENSION, Math.max(PtySession.MIN_DIMENSION, numeric));
|
|
52
49
|
}
|
|
53
|
-
start({ cols, rows, onData, onExit
|
|
50
|
+
start({ cols, rows, onData, onExit }) {
|
|
54
51
|
if (this.terminal)
|
|
55
52
|
throw new Error('PTY session already started');
|
|
56
|
-
if (this.adminTerminal) {
|
|
57
|
-
if (this.platform !== 'win32')
|
|
58
|
-
throw new Error('Administrator PTYs are only supported on Windows');
|
|
59
|
-
const terminal = new ElevatedPty({ command: this.command, args: this.args, cwd: this.cwd, socketPath: this.socketPath }, PtySession.clampDimension(cols, PtySession.DEFAULT_COLS), PtySession.clampDimension(rows, PtySession.DEFAULT_ROWS));
|
|
60
|
-
this.terminal = terminal;
|
|
61
|
-
this.startedAt = new Date().toISOString();
|
|
62
|
-
terminal.start({
|
|
63
|
-
cols: terminal.cols,
|
|
64
|
-
rows: terminal.rows,
|
|
65
|
-
onData: onData || (() => { }),
|
|
66
|
-
onExit: (event) => {
|
|
67
|
-
this.terminal = null;
|
|
68
|
-
onExit?.(event);
|
|
69
|
-
},
|
|
70
|
-
onReady,
|
|
71
|
-
onError: (error) => {
|
|
72
|
-
this.terminal = null;
|
|
73
|
-
onError?.(error);
|
|
74
|
-
},
|
|
75
|
-
});
|
|
76
|
-
return this;
|
|
77
|
-
}
|
|
78
53
|
// The Herdr UI needs the bundled ConPTY's Kitty and mouse reporting.
|
|
79
54
|
this.terminal = this.spawnPty(this.command, this.args, {
|
|
80
55
|
name: 'xterm-256color',
|
|
@@ -98,7 +73,6 @@ class PtySession {
|
|
|
98
73
|
onExit(event);
|
|
99
74
|
});
|
|
100
75
|
}
|
|
101
|
-
onReady?.();
|
|
102
76
|
return this;
|
|
103
77
|
}
|
|
104
78
|
write(data) {
|
package/dist/service.js
CHANGED
|
@@ -40,6 +40,7 @@ export function serviceSpecs(config = loadConfig(), state = ensureRuntime()) {
|
|
|
40
40
|
name: 'relay',
|
|
41
41
|
command: process.execPath,
|
|
42
42
|
args: [relayBinPath()],
|
|
43
|
+
cwd: stateDir(),
|
|
43
44
|
env: {
|
|
44
45
|
// Let the shared web UI tell a private workstation relay apart from
|
|
45
46
|
// the operator-facing self-hosted relay. Standalone relay installs
|
|
@@ -71,6 +72,7 @@ export function serviceSpecs(config = loadConfig(), state = ensureRuntime()) {
|
|
|
71
72
|
name: 'host',
|
|
72
73
|
command: process.execPath,
|
|
73
74
|
args: [path.join(PACKAGE_ROOT, 'dist', 'connector', 'main.js')],
|
|
75
|
+
cwd: stateDir(),
|
|
74
76
|
env: {
|
|
75
77
|
// Captured here, where a terminal may still be attached, because the
|
|
76
78
|
// connector itself usually runs detached with no terminal to ask.
|
|
@@ -96,7 +98,7 @@ function spawnDetached(spec) {
|
|
|
96
98
|
const logFd = fs.openSync(logPath(spec.name), 'a');
|
|
97
99
|
try {
|
|
98
100
|
const child = spawn(spec.command, spec.args, {
|
|
99
|
-
cwd:
|
|
101
|
+
cwd: spec.cwd,
|
|
100
102
|
env: { ...baseEnvironment(), ...spec.env },
|
|
101
103
|
detached: true,
|
|
102
104
|
// A detached console program can open a visible window from a console-less Windows parent.
|
package/dist/supervisor.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import { spawn } from 'node:child_process';
|
|
3
3
|
import { loadConfig } from './config.js';
|
|
4
|
-
import { logPath,
|
|
4
|
+
import { logPath, stateDir } from './paths.js';
|
|
5
5
|
import { ensureDir } from 'herdr-remote-relay/state';
|
|
6
6
|
import { ensureRuntime, managedPids, recordManagedPid, updateRuntime, } from './runtime.js';
|
|
7
7
|
import { baseEnvironment, serviceSpecs } from './service.js';
|
|
@@ -131,7 +131,7 @@ class Supervisor {
|
|
|
131
131
|
let child;
|
|
132
132
|
try {
|
|
133
133
|
child = spawn(entry.spec.command, entry.spec.args, {
|
|
134
|
-
cwd:
|
|
134
|
+
cwd: entry.spec.cwd,
|
|
135
135
|
env: { ...baseEnvironment(), ...entry.spec.env },
|
|
136
136
|
windowsHide: true,
|
|
137
137
|
stdio,
|
package/dist/tui/api.js
CHANGED
|
@@ -19,11 +19,11 @@ import * as keepalive from '../keepalive/index.js';
|
|
|
19
19
|
import { canSelfUpdate, checkForUpdate, compareVersions, currentVersion, updateChecksEnabled, installKind, performUpdate, } from '../updater.js';
|
|
20
20
|
import { checkForRelayUpdate, installedRelayVersion, performRelayUpdate, } from '../relay-updater.js';
|
|
21
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, };
|
|
22
|
-
export async function adminBrokerStatus() {
|
|
22
|
+
export async function adminBrokerStatus({ waitForStart = false } = {}) {
|
|
23
23
|
if (process.platform !== 'win32')
|
|
24
24
|
return false;
|
|
25
25
|
const broker = await import('../connector/admin-broker.js');
|
|
26
|
-
return broker.adminBrokerStatus();
|
|
26
|
+
return waitForStart ? broker.waitForAdminBroker() : broker.adminBrokerStatus();
|
|
27
27
|
}
|
|
28
28
|
export async function configureAdminBroker(action, t) {
|
|
29
29
|
const setup = await import('../connector/admin-broker-setup.js');
|
|
@@ -53,7 +53,7 @@ export function Keepalive({ ctx }) {
|
|
|
53
53
|
let ready = false;
|
|
54
54
|
try {
|
|
55
55
|
await configureAdminBroker(action, t);
|
|
56
|
-
ready = action === 'install' && (await adminBrokerStatus());
|
|
56
|
+
ready = action === 'install' && (await adminBrokerStatus({ waitForStart: true }));
|
|
57
57
|
}
|
|
58
58
|
catch (error) {
|
|
59
59
|
if (error.code === 'ELEVATION_CANCELLED') {
|