herdr-remote 0.2.31 → 0.2.33
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/dist/connector/agent-watch.js +16 -3
- package/dist/connector/herdr-prefix-keys.js +28 -0
- package/dist/connector/host-connector.js +6 -20
- package/dist/connector/host-hello.js +32 -0
- package/dist/connector/lock.js +11 -4
- package/dist/herdr-api.js +7 -1
- package/dist/herdr-command.js +7 -2
- package/dist/i18n/en.js +1 -1
- package/dist/i18n/zh.js +1 -1
- package/dist/keepalive/detached.js +18 -10
- package/dist/lib/process.js +120 -0
- package/dist/lifecycle.js +5 -3
- package/dist/pty-session.js +27 -2
- package/dist/runtime.js +25 -24
- package/dist/service.js +10 -7
- package/dist/socket-discovery.js +16 -6
- package/dist/supervisor.js +8 -5
- package/dist/terminal-font/formats.js +26 -6
- package/dist/terminal-font/terminals.js +19 -3
- package/herdr-plugin.toml +2 -2
- package/package.json +1 -1
|
@@ -19,7 +19,10 @@ const AGENT_STATUS_SUBSCRIPTIONS = [
|
|
|
19
19
|
*
|
|
20
20
|
* A long-lived subscription provides fast refreshes on pane and workspace
|
|
21
21
|
* focus changes; the five-second poll still repairs missed events and a
|
|
22
|
-
* restarted Herdr server. Both paths only read the side-channel snapshot
|
|
22
|
+
* restarted Herdr server. Both paths only read the side-channel snapshot, and
|
|
23
|
+
* events only invalidate it: Herdr's snapshots and events share no sequence,
|
|
24
|
+
* so each (re)started subscription and each event mid-read asks for a fresh
|
|
25
|
+
* read rather than patching the last one.
|
|
23
26
|
*/
|
|
24
27
|
export class AgentWatch {
|
|
25
28
|
socketPath;
|
|
@@ -30,6 +33,7 @@ export class AgentWatch {
|
|
|
30
33
|
debounceTimer = null;
|
|
31
34
|
subscription = null;
|
|
32
35
|
pending = false;
|
|
36
|
+
stale = false;
|
|
33
37
|
lastSummary = null;
|
|
34
38
|
constructor(options, send) {
|
|
35
39
|
this.socketPath = options.socketPath;
|
|
@@ -43,7 +47,7 @@ export class AgentWatch {
|
|
|
43
47
|
this.pollTimer = setInterval(() => this.read(), AGENT_STATUS_POLL_MS);
|
|
44
48
|
this.pollTimer.unref?.();
|
|
45
49
|
try {
|
|
46
|
-
this.subscription = this.subscribe(this.socketPath, AGENT_STATUS_SUBSCRIPTIONS, () => this.scheduleRead());
|
|
50
|
+
this.subscription = this.subscribe(this.socketPath, AGENT_STATUS_SUBSCRIPTIONS, () => this.scheduleRead(), { onStarted: () => this.scheduleRead() });
|
|
47
51
|
}
|
|
48
52
|
catch {
|
|
49
53
|
// A missing or restarting Herdr server leaves the regular poll in place.
|
|
@@ -63,6 +67,7 @@ export class AgentWatch {
|
|
|
63
67
|
this.subscription.close();
|
|
64
68
|
this.subscription = null;
|
|
65
69
|
}
|
|
70
|
+
this.stale = false;
|
|
66
71
|
this.lastSummary = null;
|
|
67
72
|
}
|
|
68
73
|
scheduleRead() {
|
|
@@ -84,9 +89,14 @@ export class AgentWatch {
|
|
|
84
89
|
* Herdr's server not running is an ordinary state, not news.
|
|
85
90
|
*/
|
|
86
91
|
async read() {
|
|
87
|
-
if (
|
|
92
|
+
if (!this.pollTimer)
|
|
93
|
+
return;
|
|
94
|
+
if (this.pending) {
|
|
95
|
+
this.stale = true;
|
|
88
96
|
return;
|
|
97
|
+
}
|
|
89
98
|
this.pending = true;
|
|
99
|
+
this.stale = false;
|
|
90
100
|
let snapshot;
|
|
91
101
|
try {
|
|
92
102
|
const result = await this.request(this.socketPath, 'session.snapshot', {});
|
|
@@ -97,6 +107,9 @@ export class AgentWatch {
|
|
|
97
107
|
}
|
|
98
108
|
finally {
|
|
99
109
|
this.pending = false;
|
|
110
|
+
// Something changed while this answer was in flight; it may predate it.
|
|
111
|
+
if (this.stale)
|
|
112
|
+
this.scheduleRead();
|
|
100
113
|
}
|
|
101
114
|
// Stopped while the answer was in flight; the browser it was for is gone.
|
|
102
115
|
if (!this.pollTimer)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// The keys that put this workstation's Herdr into prefix mode. A browser has
|
|
2
|
+
// to tell a Herdr command from typed text, and only Herdr's own config file
|
|
3
|
+
// says which key starts one, so the host reads it and reports it in its hello.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import { sanitizeHerdrPrefixKeys } from 'herdr-remote-relay/protocol';
|
|
6
|
+
import { herdrConfigPath } from '../socket-discovery.js';
|
|
7
|
+
import { parseTomlSubset } from '../terminal-font/formats.js';
|
|
8
|
+
/** What Herdr uses when `keys.prefix` is absent or names nothing it can read. */
|
|
9
|
+
const DEFAULT_PREFIX_KEYS = ['ctrl+b'];
|
|
10
|
+
/**
|
|
11
|
+
* `keys.prefix` from the Herdr config the connector's Herdr reads: one key or
|
|
12
|
+
* a list, each a combo like `ctrl+space`. Always at least one key, because
|
|
13
|
+
* Herdr itself falls back to `ctrl+b` when the file, the key or every entry
|
|
14
|
+
* is unusable.
|
|
15
|
+
*/
|
|
16
|
+
export function readHerdrPrefixKeys(env = process.env, platform = process.platform) {
|
|
17
|
+
let text;
|
|
18
|
+
try {
|
|
19
|
+
text = fs.readFileSync(herdrConfigPath(env, platform), 'utf8');
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
// No readable config file means Herdr runs on its defaults.
|
|
23
|
+
return [...DEFAULT_PREFIX_KEYS];
|
|
24
|
+
}
|
|
25
|
+
const configured = parseTomlSubset(text)['keys.prefix'];
|
|
26
|
+
const entries = typeof configured === 'string' ? [configured] : configured;
|
|
27
|
+
return sanitizeHerdrPrefixKeys(entries) ?? [...DEFAULT_PREFIX_KEYS];
|
|
28
|
+
}
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// message to the part of the connector that owns it.
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import {
|
|
6
|
+
import { unpackStreamFrame } from 'herdr-remote-relay/protocol';
|
|
7
7
|
import { WebSocket } from 'ws';
|
|
8
8
|
import { loadConfig } from '../config.js';
|
|
9
9
|
import { EXIT_AUTH_FAILED, EXIT_REPLACED } from '../exit-codes.js';
|
|
@@ -16,6 +16,7 @@ import { randomToken } from 'herdr-remote-relay/state';
|
|
|
16
16
|
import { openAdminTabFor } from './admin-tab.js';
|
|
17
17
|
import { AgentWatch } from './agent-watch.js';
|
|
18
18
|
import { FontServer } from './font-server.js';
|
|
19
|
+
import { hostHello } from './host-hello.js';
|
|
19
20
|
import { acquireHostLock, releaseHostLock } from './lock.js';
|
|
20
21
|
import { Sessions } from './sessions.js';
|
|
21
22
|
import { UpdateReport } from './update-report.js';
|
|
@@ -178,29 +179,14 @@ export class HostConnector {
|
|
|
178
179
|
}
|
|
179
180
|
ws.isAlive = true;
|
|
180
181
|
this.authFailure = false;
|
|
181
|
-
sendJson(ws, {
|
|
182
|
-
type: 'host_hello',
|
|
183
|
-
protocol: PROTOCOL_VERSION,
|
|
182
|
+
sendJson(ws, hostHello({
|
|
184
183
|
hostId: this.hostId,
|
|
185
184
|
token: this.hostToken,
|
|
186
|
-
password: this.relayPassword
|
|
187
|
-
hostname: os.hostname(),
|
|
188
|
-
platform: process.platform,
|
|
189
|
-
...(process.platform === 'win32' &&
|
|
190
|
-
(process.env.MSYSTEM || /(?:^|[\\/])(?:bash|sh)(?:\.exe)?$/i.test(process.env.SHELL || ''))
|
|
191
|
-
? { shellProfile: 'git-bash' }
|
|
192
|
-
: {}),
|
|
193
|
-
arch: process.arch,
|
|
185
|
+
password: this.relayPassword,
|
|
194
186
|
version: this.updates.runningVersion,
|
|
195
|
-
terminalPalette: this.terminalPalette
|
|
187
|
+
terminalPalette: this.terminalPalette,
|
|
196
188
|
terminalFont: this.font.publicFont(),
|
|
197
|
-
|
|
198
|
-
CAPABILITY.hostHandoff,
|
|
199
|
-
CAPABILITY.idleHeartbeat,
|
|
200
|
-
CAPABILITY.binaryFrameV2,
|
|
201
|
-
...(process.platform === 'win32' ? [CAPABILITY.adminTabs] : []),
|
|
202
|
-
],
|
|
203
|
-
});
|
|
189
|
+
}));
|
|
204
190
|
// The relay sends host_ready with the current browser count. No business
|
|
205
191
|
// heartbeat is started until that message says somebody is watching.
|
|
206
192
|
});
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The first message a workstation sends the relay: who it is, what it runs,
|
|
2
|
+
// and the facts about its terminal and Herdr that a browser cannot learn itself.
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { CAPABILITY, PROTOCOL_VERSION } from 'herdr-remote-relay/protocol';
|
|
5
|
+
import { readHerdrPrefixKeys } from './herdr-prefix-keys.js';
|
|
6
|
+
/** Built afresh for each connection, so a reconnect reports the config as it is now. */
|
|
7
|
+
export function hostHello(identity) {
|
|
8
|
+
return {
|
|
9
|
+
type: 'host_hello',
|
|
10
|
+
protocol: PROTOCOL_VERSION,
|
|
11
|
+
hostId: identity.hostId,
|
|
12
|
+
token: identity.token,
|
|
13
|
+
password: identity.password || null,
|
|
14
|
+
hostname: os.hostname(),
|
|
15
|
+
platform: process.platform,
|
|
16
|
+
...(process.platform === 'win32' &&
|
|
17
|
+
(process.env.MSYSTEM || /(?:^|[\\/])(?:bash|sh)(?:\.exe)?$/i.test(process.env.SHELL || ''))
|
|
18
|
+
? { shellProfile: 'git-bash' }
|
|
19
|
+
: {}),
|
|
20
|
+
arch: process.arch,
|
|
21
|
+
version: identity.version,
|
|
22
|
+
terminalPalette: identity.terminalPalette || null,
|
|
23
|
+
terminalFont: identity.terminalFont,
|
|
24
|
+
herdrPrefixKeys: readHerdrPrefixKeys(),
|
|
25
|
+
capabilities: [
|
|
26
|
+
CAPABILITY.hostHandoff,
|
|
27
|
+
CAPABILITY.idleHeartbeat,
|
|
28
|
+
CAPABILITY.binaryFrameV2,
|
|
29
|
+
...(process.platform === 'win32' ? [CAPABILITY.adminTabs] : []),
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
}
|
package/dist/connector/lock.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import fs from 'node:fs';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { ensureDir } from 'herdr-remote-relay/state';
|
|
5
|
-
import {
|
|
5
|
+
import { processStart, stillRunning } from '../lib/process.js';
|
|
6
6
|
function readOwner(lockPath) {
|
|
7
7
|
try {
|
|
8
8
|
return JSON.parse(fs.readFileSync(lockPath, 'utf8'));
|
|
@@ -19,21 +19,28 @@ function lockError(message, code) {
|
|
|
19
19
|
}
|
|
20
20
|
/**
|
|
21
21
|
* Take the lock, or throw `HOST_ALREADY_RUNNING` when a live process holds it.
|
|
22
|
-
* A lock left by a dead process is removed and taken over
|
|
22
|
+
* A lock left by a dead process is removed and taken over — including one whose
|
|
23
|
+
* pid a reboot handed to an unrelated process, which would otherwise keep every
|
|
24
|
+
* later connector standing down.
|
|
23
25
|
*/
|
|
24
26
|
export function acquireHostLock(lockPath, hostId) {
|
|
25
27
|
ensureDir(path.dirname(lockPath));
|
|
26
28
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
27
29
|
try {
|
|
28
30
|
const fd = fs.openSync(lockPath, 'wx', 0o600);
|
|
29
|
-
fs.writeFileSync(fd, `${JSON.stringify({
|
|
31
|
+
fs.writeFileSync(fd, `${JSON.stringify({
|
|
32
|
+
pid: process.pid,
|
|
33
|
+
processStart: processStart(process.pid),
|
|
34
|
+
hostId,
|
|
35
|
+
startedAt: new Date().toISOString(),
|
|
36
|
+
})}\n`);
|
|
30
37
|
return fd;
|
|
31
38
|
}
|
|
32
39
|
catch (error) {
|
|
33
40
|
if (error.code !== 'EEXIST')
|
|
34
41
|
throw error;
|
|
35
42
|
const owner = readOwner(lockPath);
|
|
36
|
-
if (owner &&
|
|
43
|
+
if (owner && stillRunning([owner]).length > 0) {
|
|
37
44
|
throw lockError(`another host connector is already running (pid ${owner.pid})`, 'HOST_ALREADY_RUNNING');
|
|
38
45
|
}
|
|
39
46
|
try {
|
package/dist/herdr-api.js
CHANGED
|
@@ -122,9 +122,14 @@ function requestHerdr(socketPath, method, params = {}, options = {}) {
|
|
|
122
122
|
* They share a socket, but are separate NDJSON messages. A subscription never
|
|
123
123
|
* starts or writes to a terminal, so an unavailable socket is only a reason to
|
|
124
124
|
* retry this side channel.
|
|
125
|
+
*
|
|
126
|
+
* Events are not replayed across connections, and from Herdr 0.9.2 a reader
|
|
127
|
+
* that falls behind gets an `events_lost` error carrying the request id and a
|
|
128
|
+
* closed socket. Both end in a reconnect, and `onStarted` fires on every
|
|
129
|
+
* acknowledgement so the caller can re-read what it may have missed.
|
|
125
130
|
*/
|
|
126
131
|
function subscribeHerdr(socketPath, subscriptions, onEvent, options = {}) {
|
|
127
|
-
const { connect = net.connect, retryBaseMs = SUBSCRIBE_RETRY_BASE_MS, retryMaxMs = SUBSCRIBE_RETRY_MAX_MS, } = options;
|
|
132
|
+
const { connect = net.connect, retryBaseMs = SUBSCRIBE_RETRY_BASE_MS, retryMaxMs = SUBSCRIBE_RETRY_MAX_MS, onStarted, } = options;
|
|
128
133
|
let socket = null;
|
|
129
134
|
let reconnectTimer = null;
|
|
130
135
|
let handshakeTimer = null;
|
|
@@ -203,6 +208,7 @@ function subscribeHerdr(socketPath, subscriptions, onEvent, options = {}) {
|
|
|
203
208
|
}
|
|
204
209
|
acknowledged = true;
|
|
205
210
|
retryCount = 0;
|
|
211
|
+
onStarted?.();
|
|
206
212
|
}
|
|
207
213
|
else if (acknowledged &&
|
|
208
214
|
typeof message?.event === 'string' &&
|
package/dist/herdr-command.js
CHANGED
|
@@ -29,8 +29,13 @@ const COMMAND_NAME = 'herdr';
|
|
|
29
29
|
* machine in the background stops resizing somebody else's focused pane, and a
|
|
30
30
|
* large paste no longer disconnects the client — and pasting is how an image
|
|
31
31
|
* reaches an agent from a phone.
|
|
32
|
+
*
|
|
33
|
+
* 0.9.3 because a browser types through escape sequences: 0.9.2 stopped a tap
|
|
34
|
+
* on a pane from sending a stray Escape to a working agent and stopped mouse
|
|
35
|
+
* reports split across reads from leaking into the shell, and it also broke
|
|
36
|
+
* Escape followed by a key — Alt+B, Alt+F, Alt+Backspace — which 0.9.3 mends.
|
|
32
37
|
*/
|
|
33
|
-
const MIN_HERDR_VERSION = '0.9.
|
|
38
|
+
const MIN_HERDR_VERSION = '0.9.3';
|
|
34
39
|
/** Where a user-level install lands, in the order we trust it. */
|
|
35
40
|
const FALLBACK_DIRECTORIES = [
|
|
36
41
|
'~/.local/bin',
|
|
@@ -224,7 +229,7 @@ function herdrVersion({ command, timeout = 10_000, ...lookup } = {}) {
|
|
|
224
229
|
/** The one line that tells a user why an older Herdr is a problem here. */
|
|
225
230
|
function herdrOutdatedMessage(version) {
|
|
226
231
|
return (`Herdr ${version} is older than ${MIN_HERDR_VERSION}, which herdr-remote is written against. ` +
|
|
227
|
-
'Run "herdr update" — window titles,
|
|
232
|
+
'Run "herdr update" — window titles, large pastes, taps on a pane and Alt shortcuts all ' +
|
|
228
233
|
'misbehave in browser windows before that release.');
|
|
229
234
|
}
|
|
230
235
|
/** The one message a user needs to fix a missing install themselves. */
|
package/dist/i18n/en.js
CHANGED
|
@@ -163,7 +163,7 @@ const en = {
|
|
|
163
163
|
'herdr.autoStartHint': 'Off: Herdr starts only when you run it, or when you confirm it from a browser. On: herdr-remote also starts it every time it starts, at boot included. Takes effect the next time herdr-remote starts.',
|
|
164
164
|
'herdr.cliMissing': 'Command herdr not found. Set HERDR_BIN_PATH to its full path.',
|
|
165
165
|
'herdr.version': 'Herdr version',
|
|
166
|
-
'herdr.versionOutdated': 'Herdr {version} is older than {minimum}. Run "herdr update": window titles,
|
|
166
|
+
'herdr.versionOutdated': 'Herdr {version} is older than {minimum}. Run "herdr update": window titles, large pastes, taps on a pane and Alt shortcuts misbehave in browser windows before that release.',
|
|
167
167
|
'about.title': 'Language & about',
|
|
168
168
|
'about.language': 'Interface language',
|
|
169
169
|
'about.languageAuto': 'Follow system ({detected})',
|
package/dist/i18n/zh.js
CHANGED
|
@@ -162,7 +162,7 @@ const zh = {
|
|
|
162
162
|
'herdr.autoStartHint': '关:Herdr 只在你手动运行,或在浏览器里确认后才启动。开:herdr-remote 每次启动(包括开机)时一并启动 Herdr。下次 herdr-remote 启动时生效。',
|
|
163
163
|
'herdr.cliMissing': '未找到 herdr 命令,可将 HERDR_BIN_PATH 设为其完整路径。',
|
|
164
164
|
'herdr.version': 'Herdr 版本',
|
|
165
|
-
'herdr.versionOutdated': 'Herdr {version} 低于 {minimum},请运行 “herdr update
|
|
165
|
+
'herdr.versionOutdated': 'Herdr {version} 低于 {minimum},请运行 “herdr update”:在该版本之前,浏览器窗口的标题、大段粘贴、点按窗格与 Alt 快捷键都会出问题。',
|
|
166
166
|
'about.title': '语言与关于',
|
|
167
167
|
'about.language': '界面语言',
|
|
168
168
|
'about.languageAuto': '跟随系统({detected})',
|
|
@@ -4,19 +4,23 @@ import { spawn } from 'node:child_process';
|
|
|
4
4
|
import fs from 'node:fs';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { ensureDir, readJson, writeJsonAtomic } from 'herdr-remote-relay/state';
|
|
7
|
-
import {
|
|
7
|
+
import { processStart, stillRunning } from '../lib/process.js';
|
|
8
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');
|
|
12
12
|
}
|
|
13
|
-
|
|
13
|
+
/**
|
|
14
|
+
* The supervisor the pid file names, if it is still that process. The file
|
|
15
|
+
* outlives a reboot; its pid alone would then name something unrelated.
|
|
16
|
+
*/
|
|
17
|
+
function runningPid() {
|
|
14
18
|
const record = readJson(fallbackPidPath(), {});
|
|
15
|
-
return
|
|
19
|
+
return stillRunning([record]).length > 0 ? record.pid : null;
|
|
16
20
|
}
|
|
17
21
|
function start() {
|
|
18
|
-
const running =
|
|
19
|
-
if (
|
|
22
|
+
const running = runningPid();
|
|
23
|
+
if (running !== null)
|
|
20
24
|
return { ok: true, alreadyRunning: true, pid: running };
|
|
21
25
|
ensureDir(stateDir());
|
|
22
26
|
const logFd = fs.openSync(logPath('supervisor'), 'a');
|
|
@@ -30,7 +34,11 @@ function start() {
|
|
|
30
34
|
stdio: ['ignore', logFd, logFd],
|
|
31
35
|
});
|
|
32
36
|
child.unref();
|
|
33
|
-
writeJsonAtomic(fallbackPidPath(), {
|
|
37
|
+
writeJsonAtomic(fallbackPidPath(), {
|
|
38
|
+
pid: child.pid,
|
|
39
|
+
processStart: child.pid ? processStart(child.pid) : null,
|
|
40
|
+
startedAt: new Date().toISOString(),
|
|
41
|
+
});
|
|
34
42
|
return { ok: true, pid: child.pid, note: 'this fallback does not survive a reboot' };
|
|
35
43
|
}
|
|
36
44
|
finally {
|
|
@@ -38,8 +46,8 @@ function start() {
|
|
|
38
46
|
}
|
|
39
47
|
}
|
|
40
48
|
function stopAndForget() {
|
|
41
|
-
const pid =
|
|
42
|
-
if (pid !== null
|
|
49
|
+
const pid = runningPid();
|
|
50
|
+
if (pid !== null) {
|
|
43
51
|
try {
|
|
44
52
|
process.kill(pid, 'SIGTERM');
|
|
45
53
|
}
|
|
@@ -58,8 +66,8 @@ export function detachedLogsHint(platform = process.platform, logFile = logPath(
|
|
|
58
66
|
export const detached = {
|
|
59
67
|
name: 'supervisor',
|
|
60
68
|
status() {
|
|
61
|
-
const pid =
|
|
62
|
-
const alive =
|
|
69
|
+
const pid = runningPid();
|
|
70
|
+
const alive = pid !== null;
|
|
63
71
|
return {
|
|
64
72
|
manager: 'supervisor',
|
|
65
73
|
installed: alive,
|
package/dist/lib/process.js
CHANGED
|
@@ -1,3 +1,10 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
const PROCESS_QUERY_TIMEOUT_MS = 10_000;
|
|
4
|
+
// Records written before start tokens existed carry only a pid. Until none are
|
|
5
|
+
// left, such a record is trusted only while its process runs one of our entry
|
|
6
|
+
// points: the relay, the host connector or `herdr-remote run`.
|
|
7
|
+
const OUR_ENTRY_POINT = /herdr-remote-relay\.js|connector[\\/]main\.js|herdr-remote\.js"?\s+run\b/;
|
|
1
8
|
/** Whether `pid` names a running process, including one owned by another user. */
|
|
2
9
|
export function pidAlive(pid) {
|
|
3
10
|
if (!Number.isInteger(pid) || pid <= 0)
|
|
@@ -10,3 +17,116 @@ export function pidAlive(pid) {
|
|
|
10
17
|
return error.code === 'EPERM';
|
|
11
18
|
}
|
|
12
19
|
}
|
|
20
|
+
function runCommand(command, args) {
|
|
21
|
+
const result = spawnSync(command, args, {
|
|
22
|
+
encoding: 'utf8',
|
|
23
|
+
// The same instant must print the same text whenever and wherever it is asked.
|
|
24
|
+
env: { ...process.env, LC_ALL: 'C', TZ: 'UTC' },
|
|
25
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
26
|
+
timeout: PROCESS_QUERY_TIMEOUT_MS,
|
|
27
|
+
windowsHide: true,
|
|
28
|
+
});
|
|
29
|
+
// The exit code is not trusted: ps and PowerShell fail when any one pid is
|
|
30
|
+
// gone, yet still print the rest. A command that could not run prints nothing.
|
|
31
|
+
return typeof result.stdout === 'string' ? result.stdout : '';
|
|
32
|
+
}
|
|
33
|
+
/** `<pid> <value>` lines, as ps and our PowerShell queries print them. */
|
|
34
|
+
function parsePidLines(output) {
|
|
35
|
+
const details = new Map();
|
|
36
|
+
for (const line of output.split(/\r?\n/)) {
|
|
37
|
+
const match = /^\s*(\d+)\s+(\S.*?)\s*$/.exec(line);
|
|
38
|
+
if (match)
|
|
39
|
+
details.set(Number(match[1]), match[2]);
|
|
40
|
+
}
|
|
41
|
+
return details;
|
|
42
|
+
}
|
|
43
|
+
function linuxDetails(pids, detail, run) {
|
|
44
|
+
let bootId = '';
|
|
45
|
+
if (detail === 'start') {
|
|
46
|
+
try {
|
|
47
|
+
// Start times count clock ticks since boot, so they only identify a
|
|
48
|
+
// process together with the boot they were counted in.
|
|
49
|
+
bootId = fs.readFileSync('/proc/sys/kernel/random/boot_id', 'utf8').trim();
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// A /proc without boot ids (some sandboxes) still has ps.
|
|
53
|
+
return psDetails(pids, detail, run);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const details = new Map();
|
|
57
|
+
for (const pid of pids) {
|
|
58
|
+
let text;
|
|
59
|
+
try {
|
|
60
|
+
text = fs.readFileSync(`/proc/${pid}/${detail === 'start' ? 'stat' : 'cmdline'}`, 'utf8');
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Not running, or hidden from us: either way it is not a process we can vouch for.
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (detail === 'command') {
|
|
67
|
+
details.set(pid, text.replaceAll('\0', ' ').trim());
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// The command name before this may itself contain spaces and parentheses;
|
|
71
|
+
// after it, field 3 (state) comes first and field 22 is the start time.
|
|
72
|
+
const fields = text.slice(text.lastIndexOf(')') + 2).split(' ');
|
|
73
|
+
if (fields[19])
|
|
74
|
+
details.set(pid, `${bootId}:${fields[19]}`);
|
|
75
|
+
}
|
|
76
|
+
return details;
|
|
77
|
+
}
|
|
78
|
+
function psDetails(pids, detail, run) {
|
|
79
|
+
const column = detail === 'start' ? 'lstart' : 'command';
|
|
80
|
+
return parsePidLines(run('ps', ['-o', `pid=,${column}=`, '-p', pids.join(',')]));
|
|
81
|
+
}
|
|
82
|
+
function windowsDetails(pids, detail, run) {
|
|
83
|
+
const script = detail === 'start'
|
|
84
|
+
? // StartTime is empty for processes we may not inspect; those are not ours anyway.
|
|
85
|
+
`Get-Process -Id ${pids.join(',')} -ErrorAction SilentlyContinue | Where-Object StartTime | ForEach-Object { "$($_.Id) $($_.StartTime.ToFileTimeUtc())" }`
|
|
86
|
+
: `Get-CimInstance Win32_Process -Filter "${pids.map((pid) => `ProcessId=${pid}`).join(' OR ')}" | ForEach-Object { "$($_.ProcessId) $($_.CommandLine)" }`;
|
|
87
|
+
return parsePidLines(run('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', script]));
|
|
88
|
+
}
|
|
89
|
+
function processDetails(pids, detail, platform, run) {
|
|
90
|
+
// Dead pids are the common case after a clean stop, and ruling them out here
|
|
91
|
+
// saves spawning ps or PowerShell just to hear so.
|
|
92
|
+
const candidates = [...new Set(pids)].filter(pidAlive);
|
|
93
|
+
if (candidates.length === 0)
|
|
94
|
+
return new Map();
|
|
95
|
+
if (platform === 'linux')
|
|
96
|
+
return linuxDetails(candidates, detail, run);
|
|
97
|
+
if (platform === 'win32')
|
|
98
|
+
return windowsDetails(candidates, detail, run);
|
|
99
|
+
return psDetails(candidates, detail, run);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* When each running process in `pids` started, as opaque tokens; pids that are
|
|
103
|
+
* not running, or that the system will not describe, are left out.
|
|
104
|
+
*
|
|
105
|
+
* A pid alone does not identify a process: the number goes to another process
|
|
106
|
+
* once this one exits, and after a reboot the same boot-time services land on
|
|
107
|
+
* the same numbers again. On Linux the number of a thread in another process
|
|
108
|
+
* even passes `pidAlive`. Recorded next to its pid, this token only ever
|
|
109
|
+
* matches the process it was read from.
|
|
110
|
+
*/
|
|
111
|
+
export function processStarts(pids, platform = process.platform, run = runCommand) {
|
|
112
|
+
return processDetails(pids, 'start', platform, run);
|
|
113
|
+
}
|
|
114
|
+
/** The command line of each running process in `pids`, which the system is willing to show. */
|
|
115
|
+
export function processCommands(pids, platform = process.platform, run = runCommand) {
|
|
116
|
+
return processDetails(pids, 'command', platform, run);
|
|
117
|
+
}
|
|
118
|
+
export function processStart(pid, platform = process.platform) {
|
|
119
|
+
return processStarts([pid], platform).get(pid) ?? null;
|
|
120
|
+
}
|
|
121
|
+
/** The records that still name the very process they were taken from. */
|
|
122
|
+
export function stillRunning(records) {
|
|
123
|
+
const pidsOf = (list) => list.map((record) => record.pid).filter((pid) => Number.isInteger(pid));
|
|
124
|
+
const legacy = records.filter((record) => typeof record.processStart !== 'string');
|
|
125
|
+
const starts = processStarts(pidsOf(records.filter((record) => !legacy.includes(record))));
|
|
126
|
+
const commands = legacy.length > 0 ? processCommands(pidsOf(legacy)) : new Map();
|
|
127
|
+
return records.filter((record) => legacy.includes(record)
|
|
128
|
+
? // We only ever write start tokens, so a legacy record naming our own pid
|
|
129
|
+
// was left by an earlier process that happened to have it.
|
|
130
|
+
record.pid !== process.pid && OUR_ENTRY_POINT.test(commands.get(record.pid) ?? '')
|
|
131
|
+
: starts.get(record.pid) === record.processStart);
|
|
132
|
+
}
|
package/dist/lifecycle.js
CHANGED
|
@@ -6,8 +6,7 @@
|
|
|
6
6
|
// here so the managed and unmanaged paths behave the same from the outside.
|
|
7
7
|
import { loadConfig } from './config.js';
|
|
8
8
|
import * as keepalive from './keepalive/index.js';
|
|
9
|
-
import {
|
|
10
|
-
import { readRuntime } from './runtime.js';
|
|
9
|
+
import { managedPids, readRuntime } from './runtime.js';
|
|
11
10
|
import { restartServices, startServices, statusServices, stopServices } from './service.js';
|
|
12
11
|
function managerInUse(config) {
|
|
13
12
|
const status = keepalive.status(config);
|
|
@@ -51,7 +50,10 @@ function restartAll(config = loadConfig()) {
|
|
|
51
50
|
}
|
|
52
51
|
/** Whether the host connector is up, under a keep-alive manager or detached. */
|
|
53
52
|
function servicesRunning(config = loadConfig()) {
|
|
54
|
-
|
|
53
|
+
if (managerInUse(config)?.active)
|
|
54
|
+
return true;
|
|
55
|
+
const state = readRuntime();
|
|
56
|
+
return managedPids(state).some(({ pid }) => pid === state.hostPid);
|
|
55
57
|
}
|
|
56
58
|
/** Stop Windows keep-alive processes while npm replaces their package files. */
|
|
57
59
|
async function whileServicesStopped(config, task, { platform = process.platform, isRunning = () => servicesRunning(config), stop = stopAll, start = startAll, } = {}) {
|
package/dist/pty-session.js
CHANGED
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import { nodePty } from './node-pty-loader.js';
|
|
3
|
+
/**
|
|
4
|
+
* Variables that name the terminal this process was started from. The Herdr
|
|
5
|
+
* client's outer terminal is the browser's xterm.js, and from Herdr 0.9.2 the
|
|
6
|
+
* client reads these to pick image transports: one that believes it runs in
|
|
7
|
+
* Ghostty, kitty or WezTerm hands images over as local file paths no browser
|
|
8
|
+
* can open. Herdr strips the same list from its own panes.
|
|
9
|
+
*/
|
|
10
|
+
const OUTER_TERMINAL_ENV = [
|
|
11
|
+
'TERM_PROGRAM',
|
|
12
|
+
'TERM_PROGRAM_VERSION',
|
|
13
|
+
'ITERM_SESSION_ID',
|
|
14
|
+
'LC_TERMINAL',
|
|
15
|
+
'LC_TERMINAL_VERSION',
|
|
16
|
+
'WEZTERM_PANE',
|
|
17
|
+
'KITTY_WINDOW_ID',
|
|
18
|
+
'WT_SESSION',
|
|
19
|
+
'TMUX',
|
|
20
|
+
'TMUX_PANE',
|
|
21
|
+
'STY',
|
|
22
|
+
'ZELLIJ',
|
|
23
|
+
'ZELLIJ_SESSION_NAME',
|
|
24
|
+
'ZELLIJ_PANE_ID',
|
|
25
|
+
];
|
|
3
26
|
class PtySession {
|
|
4
27
|
static DEFAULT_COLS = 100;
|
|
5
28
|
static DEFAULT_ROWS = 30;
|
|
@@ -29,12 +52,14 @@ class PtySession {
|
|
|
29
52
|
this.terminal = null;
|
|
30
53
|
this.startedAt = null;
|
|
31
54
|
}
|
|
32
|
-
static childEnv(socketPath) {
|
|
33
|
-
const env = { ...
|
|
55
|
+
static childEnv(socketPath, source = process.env) {
|
|
56
|
+
const env = { ...source };
|
|
34
57
|
for (const key of Object.keys(env)) {
|
|
35
58
|
if (key.startsWith('HERDR_'))
|
|
36
59
|
delete env[key];
|
|
37
60
|
}
|
|
61
|
+
for (const key of OUTER_TERMINAL_ENV)
|
|
62
|
+
delete env[key];
|
|
38
63
|
if (socketPath)
|
|
39
64
|
env.HERDR_SOCKET_PATH = socketPath;
|
|
40
65
|
env.TERM = 'xterm-256color';
|
package/dist/runtime.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// for it, and facts other modules remember there (terminal palette and font).
|
|
3
3
|
// Every read and write of that file goes through here.
|
|
4
4
|
import { ensureDir, randomToken, readJson, writeJsonAtomic } from 'herdr-remote-relay/state';
|
|
5
|
-
import {
|
|
5
|
+
import { processStart, stillRunning } from './lib/process.js';
|
|
6
6
|
import { configDir, runtimeStatePath, stateDir } from './paths.js';
|
|
7
7
|
const RUNTIME_VERSION = 2;
|
|
8
8
|
export function readRuntime() {
|
|
@@ -73,34 +73,35 @@ export function regenerateHostIdentity() {
|
|
|
73
73
|
* (minus dead entries) precisely so no writer can lose another writer's pids.
|
|
74
74
|
*/
|
|
75
75
|
export function recordManagedPid(state, name, pid) {
|
|
76
|
-
const existing = Array.isArray(state.managedPids) ? state.managedPids : [];
|
|
77
|
-
const kept = existing.filter((entry) => entry
|
|
78
|
-
// Only track something that is actually running
|
|
79
|
-
//
|
|
80
|
-
// process
|
|
81
|
-
|
|
82
|
-
|
|
76
|
+
const existing = Array.isArray(state.managedPids) ? state.managedPids.filter(Boolean) : [];
|
|
77
|
+
const kept = stillRunning(existing.filter((entry) => entry.pid !== pid));
|
|
78
|
+
// Only track something that is actually running, and together with its start
|
|
79
|
+
// token: the ledger outlives a reboot, and a bare pid it kept would then name
|
|
80
|
+
// whichever unrelated process got that number next — which we would signal.
|
|
81
|
+
const start = Number.isInteger(pid) ? processStart(pid) : null;
|
|
82
|
+
if (start) {
|
|
83
|
+
kept.push({
|
|
84
|
+
name,
|
|
85
|
+
pid: pid,
|
|
86
|
+
processStart: start,
|
|
87
|
+
startedAt: new Date().toISOString(),
|
|
88
|
+
});
|
|
83
89
|
}
|
|
84
90
|
state.managedPids = kept;
|
|
85
91
|
return state;
|
|
86
92
|
}
|
|
87
|
-
/**
|
|
93
|
+
/**
|
|
94
|
+
* Every process we started that is still running, and still that process.
|
|
95
|
+
*
|
|
96
|
+
* Only the ledger counts. `supervisorPid`, `relayPid` and `hostPid` are bare
|
|
97
|
+
* numbers that survive a reboot, and trusting them would signal whichever
|
|
98
|
+
* unrelated process inherited one.
|
|
99
|
+
*/
|
|
88
100
|
export function managedPids(state = readRuntime()) {
|
|
89
|
-
const
|
|
90
|
-
// Supervisor first and never overwritten: `stopServices` relies on the order
|
|
91
|
-
// to signal it before its children, and one pid can appear under several
|
|
92
|
-
// fields.
|
|
93
|
-
for (const [name, pid] of [
|
|
94
|
-
['supervisor', state.supervisorPid],
|
|
95
|
-
['host', state.hostPid],
|
|
96
|
-
['relay', state.relayPid],
|
|
97
|
-
]) {
|
|
98
|
-
if (pidAlive(pid) && !pids.has(pid))
|
|
99
|
-
pids.set(pid, name);
|
|
100
|
-
}
|
|
101
|
+
const unique = new Map();
|
|
101
102
|
for (const entry of state.managedPids || []) {
|
|
102
|
-
if (entry &&
|
|
103
|
-
|
|
103
|
+
if (entry && !unique.has(entry.pid))
|
|
104
|
+
unique.set(entry.pid, entry);
|
|
104
105
|
}
|
|
105
|
-
return [...
|
|
106
|
+
return stillRunning([...unique.values()]);
|
|
106
107
|
}
|
package/dist/service.js
CHANGED
|
@@ -7,7 +7,6 @@ import path from 'node:path';
|
|
|
7
7
|
import { ensureDir } from 'herdr-remote-relay/state';
|
|
8
8
|
import { loadConfig } from './config.js';
|
|
9
9
|
import { findHerdrCommand, herdrVersion, MIN_HERDR_VERSION, resolveHerdrCommand, } from './herdr-command.js';
|
|
10
|
-
import { pidAlive } from './lib/process.js';
|
|
11
10
|
import { preferredLanAddress } from './net-interfaces.js';
|
|
12
11
|
import { logPath, PACKAGE_ROOT, stateDir } from './paths.js';
|
|
13
12
|
import { healthUrl, hostHeaders, requestJson, waitForHost, waitForRelay } from './relay-client.js';
|
|
@@ -119,16 +118,18 @@ export function startServices() {
|
|
|
119
118
|
const state = ensureRuntime();
|
|
120
119
|
const next = { ...state };
|
|
121
120
|
const specs = serviceSpecs(config, state);
|
|
121
|
+
const running = managedPids(next);
|
|
122
|
+
const isRunning = (pid) => running.some((entry) => entry.pid === pid);
|
|
122
123
|
for (const spec of specs) {
|
|
123
124
|
const field = PID_FIELDS[spec.name];
|
|
124
|
-
if (
|
|
125
|
+
if (isRunning(next[field]))
|
|
125
126
|
continue;
|
|
126
127
|
next[field] = spawnDetached(spec) ?? null;
|
|
127
128
|
recordManagedPid(next, spec.name, next[field]);
|
|
128
129
|
}
|
|
129
130
|
// A relay that is no longer part of the plan (switched to remote mode) must
|
|
130
131
|
// not be left running on the old port.
|
|
131
|
-
if (!specs.some((spec) => spec.name === 'relay') &&
|
|
132
|
+
if (!specs.some((spec) => spec.name === 'relay') && isRunning(next.relayPid)) {
|
|
132
133
|
try {
|
|
133
134
|
process.kill(next.relayPid, 'SIGTERM');
|
|
134
135
|
}
|
|
@@ -140,20 +141,21 @@ export function startServices() {
|
|
|
140
141
|
next.startedAt = next.startedAt || new Date().toISOString();
|
|
141
142
|
next.mode = config.relay.mode;
|
|
142
143
|
writeRuntime(next);
|
|
144
|
+
const live = managedPids(next);
|
|
143
145
|
return {
|
|
144
146
|
ok: true,
|
|
145
147
|
mode: config.relay.mode,
|
|
146
148
|
relay: {
|
|
147
149
|
local: runsLocalRelay(config),
|
|
148
150
|
pid: next.relayPid || null,
|
|
149
|
-
alive:
|
|
151
|
+
alive: live.some(({ pid }) => pid === next.relayPid),
|
|
150
152
|
bind: runsLocalRelay(config) ? bindAddress(config) : null,
|
|
151
153
|
port: config.relay.port,
|
|
152
154
|
remoteUrl: config.relay.remoteUrl || null,
|
|
153
155
|
},
|
|
154
156
|
host: {
|
|
155
157
|
pid: next.hostPid || null,
|
|
156
|
-
alive:
|
|
158
|
+
alive: live.some(({ pid }) => pid === next.hostPid),
|
|
157
159
|
socketPath: resolveSocketPath(config.herdr.socketPath),
|
|
158
160
|
},
|
|
159
161
|
publicUrl: resolvePublicUrl(config, preferredLanAddress()),
|
|
@@ -190,6 +192,7 @@ export function restartServices() {
|
|
|
190
192
|
export async function statusServices() {
|
|
191
193
|
const config = loadConfig();
|
|
192
194
|
const state = readRuntime();
|
|
195
|
+
const live = managedPids(state);
|
|
193
196
|
const lanAddress = preferredLanAddress();
|
|
194
197
|
let health;
|
|
195
198
|
try {
|
|
@@ -213,7 +216,7 @@ export async function statusServices() {
|
|
|
213
216
|
relay: {
|
|
214
217
|
local: runsLocalRelay(config),
|
|
215
218
|
pid: state.relayPid || null,
|
|
216
|
-
alive: runsLocalRelay(config) ?
|
|
219
|
+
alive: runsLocalRelay(config) ? live.some(({ pid }) => pid === state.relayPid) : null,
|
|
217
220
|
bind: runsLocalRelay(config) ? bindAddress(config) : null,
|
|
218
221
|
port: config.relay.port,
|
|
219
222
|
remoteUrl: config.relay.remoteUrl || null,
|
|
@@ -221,7 +224,7 @@ export async function statusServices() {
|
|
|
221
224
|
},
|
|
222
225
|
host: {
|
|
223
226
|
pid: state.hostPid || null,
|
|
224
|
-
alive:
|
|
227
|
+
alive: live.some(({ pid }) => pid === state.hostPid),
|
|
225
228
|
hostId: state.hostId || null,
|
|
226
229
|
socketPath,
|
|
227
230
|
socketExists: Boolean(socketPath) && fs.existsSync(socketPath),
|
package/dist/socket-discovery.js
CHANGED
|
@@ -1,18 +1,28 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
/**
|
|
5
|
+
* Herdr's own config directory, resolved as Herdr does (`XDG_CONFIG_HOME`
|
|
6
|
+
* first on every platform, then the platform's home for it), so the socket
|
|
7
|
+
* and `config.toml` are found wherever Herdr put them.
|
|
8
|
+
*/
|
|
9
|
+
function herdrConfigDir(env = process.env, platform = process.platform) {
|
|
7
10
|
if (platform === 'win32') {
|
|
8
11
|
const configHome = env.XDG_CONFIG_HOME ||
|
|
9
12
|
env.APPDATA ||
|
|
10
13
|
(env.USERPROFILE && path.join(env.USERPROFILE, 'AppData', 'Roaming')) ||
|
|
11
14
|
path.join(os.homedir(), 'AppData', 'Roaming');
|
|
12
|
-
return path.join(configHome, 'herdr'
|
|
15
|
+
return path.join(configHome, 'herdr');
|
|
13
16
|
}
|
|
14
17
|
const configHome = env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
|
15
|
-
return path.join(configHome, 'herdr'
|
|
18
|
+
return path.join(configHome, 'herdr');
|
|
19
|
+
}
|
|
20
|
+
function defaultSocketPath(env = process.env, platform = process.platform) {
|
|
21
|
+
return env.HERDR_SOCKET_PATH || path.join(herdrConfigDir(env, platform), 'herdr.sock');
|
|
22
|
+
}
|
|
23
|
+
/** The `config.toml` Herdr reads; `HERDR_CONFIG_PATH` names it outright. */
|
|
24
|
+
function herdrConfigPath(env = process.env, platform = process.platform) {
|
|
25
|
+
return env.HERDR_CONFIG_PATH || path.join(herdrConfigDir(env, platform), 'config.toml');
|
|
16
26
|
}
|
|
17
27
|
/**
|
|
18
28
|
* On Windows, Herdr uses this path itself as the GenericNamespaced pipe name;
|
|
@@ -57,4 +67,4 @@ function inspectSocket(socketPath, options = {}) {
|
|
|
57
67
|
};
|
|
58
68
|
}
|
|
59
69
|
}
|
|
60
|
-
export { defaultSocketPath, herdrEndpoint, resolveSocketPath, inspectSocket };
|
|
70
|
+
export { defaultSocketPath, herdrConfigPath, herdrEndpoint, resolveSocketPath, inspectSocket };
|
package/dist/supervisor.js
CHANGED
|
@@ -6,7 +6,7 @@ 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';
|
|
8
8
|
import { EXIT_REPLACED, EXIT_AUTH_FAILED } from './exit-codes.js';
|
|
9
|
-
import { pidAlive } from './lib/process.js';
|
|
9
|
+
import { pidAlive, stillRunning } from './lib/process.js';
|
|
10
10
|
const MIN_BACKOFF_MS = 500;
|
|
11
11
|
const MAX_BACKOFF_MS = 30_000;
|
|
12
12
|
// A child that stayed up this long is considered healthy, so the next crash
|
|
@@ -50,6 +50,10 @@ class Supervisor {
|
|
|
50
50
|
* spawned died with EADDRINUSE and retried forever, while the orphaned host
|
|
51
51
|
* connector and ours both claimed the same host id on the relay and kicked
|
|
52
52
|
* each other off — which is what browsers saw as an endless reconnect.
|
|
53
|
+
*
|
|
54
|
+
* Only ledger entries whose start token still matches are touched: the
|
|
55
|
+
* ledger survives a reboot, and its pids then belong to whatever the new
|
|
56
|
+
* boot started.
|
|
53
57
|
*/
|
|
54
58
|
async reclaimStrays({ timeoutMs = 5000 } = {}) {
|
|
55
59
|
const strays = managedPids().filter((entry) => entry.pid !== process.pid);
|
|
@@ -79,9 +83,8 @@ class Supervisor {
|
|
|
79
83
|
setTimeout(resolve, 100);
|
|
80
84
|
});
|
|
81
85
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
continue;
|
|
86
|
+
// Checked again before the harder signal, in case the number moved on meanwhile.
|
|
87
|
+
for (const { pid, name } of stillRunning(strays)) {
|
|
85
88
|
this.emit({
|
|
86
89
|
type: 'reclaim',
|
|
87
90
|
name,
|
|
@@ -284,7 +287,7 @@ class Supervisor {
|
|
|
284
287
|
current.relayPid = null;
|
|
285
288
|
current.hostPid = null;
|
|
286
289
|
current.startedAt = null;
|
|
287
|
-
current.managedPids = (current.managedPids || []).filter(
|
|
290
|
+
current.managedPids = stillRunning((current.managedPids || []).filter(Boolean));
|
|
288
291
|
});
|
|
289
292
|
}
|
|
290
293
|
catch {
|
|
@@ -193,10 +193,28 @@ export function parseJsonc(text) {
|
|
|
193
193
|
return null;
|
|
194
194
|
}
|
|
195
195
|
}
|
|
196
|
+
/** `line` up to a `#` that starts a comment, not one inside a string. */
|
|
197
|
+
function withoutTomlComment(line) {
|
|
198
|
+
let quote = '';
|
|
199
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
200
|
+
const char = line[i];
|
|
201
|
+
if (quote) {
|
|
202
|
+
if (char === '\\' && quote === '"')
|
|
203
|
+
i += 1;
|
|
204
|
+
else if (char === quote)
|
|
205
|
+
quote = '';
|
|
206
|
+
}
|
|
207
|
+
else if (char === '"' || char === "'")
|
|
208
|
+
quote = char;
|
|
209
|
+
else if (char === '#')
|
|
210
|
+
return line.slice(0, i);
|
|
211
|
+
}
|
|
212
|
+
return line;
|
|
213
|
+
}
|
|
196
214
|
/**
|
|
197
|
-
* Just enough TOML for Alacritty's font keys
|
|
198
|
-
* numbers, inline tables and (possibly
|
|
199
|
-
* flat `dotted.key → value` map.
|
|
215
|
+
* Just enough TOML for Alacritty's font keys and Herdr's `keys.prefix`:
|
|
216
|
+
* tables, dotted keys, strings, numbers, inline tables and (possibly
|
|
217
|
+
* multi-line) string arrays. Returns a flat `dotted.key → value` map.
|
|
200
218
|
*/
|
|
201
219
|
export function parseTomlSubset(text) {
|
|
202
220
|
const values = {};
|
|
@@ -253,11 +271,13 @@ export function parseTomlSubset(text) {
|
|
|
253
271
|
if (equals === -1)
|
|
254
272
|
continue;
|
|
255
273
|
let rawValue = line.slice(equals + 1);
|
|
256
|
-
|
|
257
|
-
|
|
274
|
+
if (rawValue.trim().startsWith('[')) {
|
|
275
|
+
// A comment inside an array may hold quotes or a `]` of its own.
|
|
276
|
+
rawValue = withoutTomlComment(rawValue);
|
|
277
|
+
// An array may span lines; gather it before assigning.
|
|
258
278
|
while (index + 1 < lines.length && !rawValue.includes(']')) {
|
|
259
279
|
index += 1;
|
|
260
|
-
rawValue += ` ${lines[index].trim()}`;
|
|
280
|
+
rawValue += ` ${withoutTomlComment(lines[index]).trim()}`;
|
|
261
281
|
}
|
|
262
282
|
}
|
|
263
283
|
assign(table, line.slice(0, equals), rawValue);
|
|
@@ -19,6 +19,15 @@ const TERMINAL_PROCESSES = [
|
|
|
19
19
|
[/^xterm$/, 'xterm'],
|
|
20
20
|
[/^(urxvt|urxvtd|rxvt)$/, 'urxvt'],
|
|
21
21
|
];
|
|
22
|
+
/** macOS app bundle ids, which every process an app starts inherits. */
|
|
23
|
+
const BUNDLE_TERMINALS = {
|
|
24
|
+
'com.apple.Terminal': 'apple-terminal',
|
|
25
|
+
'com.googlecode.iterm2': 'iterm2',
|
|
26
|
+
'com.microsoft.VSCode': 'vscode',
|
|
27
|
+
'com.mitchellh.ghostty': 'ghostty',
|
|
28
|
+
'com.github.wez.wezterm': 'wezterm',
|
|
29
|
+
'net.kovidgoyal.kitty': 'kitty',
|
|
30
|
+
};
|
|
22
31
|
/**
|
|
23
32
|
* The emulator a process runs under, from its own ancestry first.
|
|
24
33
|
*
|
|
@@ -37,10 +46,15 @@ export function identifyTerminal({ env = process.env, ancestry = [], } = {}) {
|
|
|
37
46
|
return id;
|
|
38
47
|
}
|
|
39
48
|
}
|
|
49
|
+
// From Herdr 0.9.2 a pane says TERM_PROGRAM=herdr and loses the terminal's
|
|
50
|
+
// session markers, so a start from inside Herdr falls through to markers
|
|
51
|
+
// Herdr leaves alone: macOS's app bundle id, ITERM_PROFILE, KITTY_PID,
|
|
52
|
+
// WT_PROFILE_ID.
|
|
40
53
|
const program = env.TERM_PROGRAM || '';
|
|
54
|
+
const bundle = env.__CFBundleIdentifier || '';
|
|
41
55
|
if (program === 'vscode')
|
|
42
56
|
return 'vscode';
|
|
43
|
-
if (program === 'iTerm.app' || env.ITERM_SESSION_ID)
|
|
57
|
+
if (program === 'iTerm.app' || env.ITERM_SESSION_ID || env.ITERM_PROFILE)
|
|
44
58
|
return 'iterm2';
|
|
45
59
|
if (program === 'Apple_Terminal')
|
|
46
60
|
return 'apple-terminal';
|
|
@@ -48,7 +62,7 @@ export function identifyTerminal({ env = process.env, ancestry = [], } = {}) {
|
|
|
48
62
|
return 'wezterm';
|
|
49
63
|
if (program === 'ghostty' || env.GHOSTTY_RESOURCES_DIR)
|
|
50
64
|
return 'ghostty';
|
|
51
|
-
if (env.KITTY_WINDOW_ID || env.TERM === 'xterm-kitty')
|
|
65
|
+
if (env.KITTY_WINDOW_ID || env.KITTY_PID || env.TERM === 'xterm-kitty')
|
|
52
66
|
return 'kitty';
|
|
53
67
|
if (env.ALACRITTY_SOCKET || env.ALACRITTY_WINDOW_ID || env.ALACRITTY_LOG)
|
|
54
68
|
return 'alacritty';
|
|
@@ -64,8 +78,10 @@ export function identifyTerminal({ env = process.env, ancestry = [], } = {}) {
|
|
|
64
78
|
return 'gnome-terminal';
|
|
65
79
|
if (/^rxvt-unicode/.test(env.TERM || ''))
|
|
66
80
|
return 'urxvt';
|
|
81
|
+
if (Object.hasOwn(BUNDLE_TERMINALS, bundle))
|
|
82
|
+
return BUNDLE_TERMINALS[bundle];
|
|
67
83
|
// WT_SESSION is inherited by nested terminals, so specific terminal markers win first.
|
|
68
|
-
if (env.WT_SESSION)
|
|
84
|
+
if (env.WT_SESSION || env.WT_PROFILE_ID)
|
|
69
85
|
return 'windows-terminal';
|
|
70
86
|
// Last: a terminal started from xterm inherits this too.
|
|
71
87
|
if (env.XTERM_VERSION)
|
package/herdr-plugin.toml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
id = "herdr.remote.web"
|
|
2
2
|
name = "Herdr Remote Web"
|
|
3
|
-
version = "0.2.
|
|
4
|
-
min_herdr_version = "0.9.
|
|
3
|
+
version = "0.2.33"
|
|
4
|
+
min_herdr_version = "0.9.3"
|
|
5
5
|
description = "Browser access to Herdr workspaces via local or self-hosted relay"
|
|
6
6
|
platforms = ["linux", "macos", "windows"]
|
|
7
7
|
|