herdr-remote 0.2.24 → 0.2.26
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 +11 -2
- package/README.zh-CN.md +5 -2
- package/dist/cli.js +20 -0
- package/dist/config.js +1 -0
- package/dist/connector/admin-broker-setup.js +40 -0
- package/dist/connector/admin-broker.js +208 -0
- package/dist/connector/adminBrokerPipe.js +16 -0
- package/dist/connector/elevatedPty.js +145 -0
- package/dist/connector/host-connector.js +10 -1
- package/dist/connector/sessions.js +58 -14
- package/dist/i18n/en.js +15 -1
- package/dist/i18n/zh.js +15 -1
- package/dist/keepalive/index.js +18 -7
- package/dist/keepalive/windows-task.js +191 -0
- package/dist/lifecycle.js +1 -1
- package/dist/pty-session.js +36 -6
- package/dist/tui/api.js +10 -0
- package/dist/tui/screens/Keepalive.js +51 -5
- package/herdr-plugin.toml +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -32,11 +32,19 @@ herdr-remote start | stop | restart
|
|
|
32
32
|
herdr-remote status [--json]
|
|
33
33
|
herdr-remote pair [--json]
|
|
34
34
|
herdr-remote url
|
|
35
|
+
herdr-remote admin-broker install | uninstall | status # Windows
|
|
35
36
|
herdr-remote keepalive install | uninstall | restart | status
|
|
36
37
|
herdr-remote plugin link | unlink | status
|
|
37
38
|
herdr-remote --lang zh|en
|
|
38
39
|
```
|
|
39
40
|
|
|
41
|
+
On Windows, the TUI's **Keep-alive** page can register a per-user Task Scheduler broker with highest
|
|
42
|
+
privileges and start it for the signed-in account. Choose **Set up admin terminal access** and
|
|
43
|
+
approve the one-time Windows prompt under the same account as Herdr Remote; paired WebUI windows
|
|
44
|
+
can then open a standalone elevated PowerShell without another UAC prompt. The CLI
|
|
45
|
+
`admin-broker install` and `admin-broker uninstall` commands are also available for administrators.
|
|
46
|
+
Use `admin-broker status` to check the broker.
|
|
47
|
+
|
|
40
48
|
## Herdr Plugin
|
|
41
49
|
|
|
42
50
|
Register as a native Herdr plugin:
|
|
@@ -57,8 +65,9 @@ herdr-remote plugin link
|
|
|
57
65
|
The `herdr` binary is looked up in `HERDR_BIN_PATH`, then `PATH`, then the usual
|
|
58
66
|
install directories (`~/.local/bin`, `~/.cargo/bin`, `~/bin`, `/opt/homebrew/bin`,
|
|
59
67
|
`/usr/local/bin`, …). `herdr-remote keepalive install` writes the binary it found
|
|
60
|
-
and the current `PATH` into the systemd unit, launchd agent,
|
|
61
|
-
(`windows-
|
|
68
|
+
and the current `PATH` into the systemd unit, launchd agent, Windows logon task
|
|
69
|
+
(`windows-task`), or Windows service (`windows-service`), because managed processes
|
|
70
|
+
do not inherit your shell's environment.
|
|
62
71
|
|
|
63
72
|
On Windows, only `herdr.exe` is searched for, including `%LOCALAPPDATA%\Programs\Herdr\bin`,
|
|
64
73
|
`~\.cargo\bin`, and `~\scoop\shims`.
|
package/README.zh-CN.md
CHANGED
|
@@ -32,11 +32,14 @@ herdr-remote start | stop | restart
|
|
|
32
32
|
herdr-remote status [--json]
|
|
33
33
|
herdr-remote pair [--json]
|
|
34
34
|
herdr-remote url
|
|
35
|
+
herdr-remote admin-broker install | uninstall | status # Windows
|
|
35
36
|
herdr-remote keepalive install | uninstall | restart | status
|
|
36
37
|
herdr-remote plugin link | unlink | status
|
|
37
38
|
herdr-remote --lang zh|en
|
|
38
39
|
```
|
|
39
40
|
|
|
41
|
+
在 Windows 上,可在 TUI 的「保活」页面注册以当前账户最高权限运行的计划任务并启动 broker。选择「设置管理员终端」,使用运行 Herdr Remote 的同一账户批准一次 Windows 提权提示;之后,已配对的 WebUI 窗口可以新建独立的管理员 PowerShell,不再重复弹出 UAC。也可以使用 `admin-broker install` 和 `admin-broker uninstall` 命令管理任务,使用 `admin-broker status` 检查 broker。
|
|
42
|
+
|
|
40
43
|
## Herdr 插件
|
|
41
44
|
|
|
42
45
|
注册为 Herdr 插件:
|
|
@@ -55,9 +58,9 @@ herdr-remote plugin link
|
|
|
55
58
|
## herdr 命令的查找方式
|
|
56
59
|
|
|
57
60
|
依次查找 `HERDR_BIN_PATH`、`PATH`,以及常见安装目录(`~/.local/bin`、`~/.cargo/bin`、
|
|
58
|
-
`~/bin`、`/opt/homebrew/bin`、`/usr/local/bin`
|
|
61
|
+
`~/bin`、`/opt/homebrew/bin`、`/usr/local/bin` 等)。后台管理器不会继承 shell 环境,
|
|
59
62
|
因此 `herdr-remote keepalive install` 会把找到的可执行文件路径和当前 `PATH` 写入
|
|
60
|
-
systemd unit、launchd plist
|
|
63
|
+
systemd unit、launchd plist、Windows 登录任务(`windows-task`)或 Windows 服务(`windows-service`)。
|
|
61
64
|
|
|
62
65
|
Windows 上只查找 `herdr.exe`,还会搜索 `%LOCALAPPDATA%\Programs\Herdr\bin`、
|
|
63
66
|
`~\.cargo\bin` 和 `~\scoop\shims`。
|
package/dist/cli.js
CHANGED
|
@@ -26,6 +26,7 @@ Commands:
|
|
|
26
26
|
pair [--json] Create pairing code
|
|
27
27
|
url Print browser access URL
|
|
28
28
|
run [--daemon] Run services in foreground (keep-alive)
|
|
29
|
+
admin-broker install | uninstall | status (Windows)
|
|
29
30
|
keepalive <action> install | uninstall | restart | status
|
|
30
31
|
plugin <action> link | unlink | status (Herdr plugin)
|
|
31
32
|
|
|
@@ -222,6 +223,25 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
222
223
|
await runForeground({ logToFiles: Boolean(flags.daemon) });
|
|
223
224
|
return;
|
|
224
225
|
}
|
|
226
|
+
case 'admin-broker': {
|
|
227
|
+
const broker = await import('./connector/admin-broker.js');
|
|
228
|
+
const action = positional[1] || 'status';
|
|
229
|
+
if (action === 'install') {
|
|
230
|
+
broker.installAdminBroker(t);
|
|
231
|
+
printJson({ installed: true, available: await broker.adminBrokerStatus() });
|
|
232
|
+
}
|
|
233
|
+
else if (action === 'uninstall') {
|
|
234
|
+
broker.uninstallAdminBroker(t);
|
|
235
|
+
printJson({ installed: false });
|
|
236
|
+
}
|
|
237
|
+
else if (action === 'status') {
|
|
238
|
+
printJson({ available: await broker.adminBrokerStatus() });
|
|
239
|
+
}
|
|
240
|
+
else {
|
|
241
|
+
throw new Error(`unknown administrator broker action: ${action}`);
|
|
242
|
+
}
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
225
245
|
case 'keepalive': {
|
|
226
246
|
const keepalive = await import('./keepalive/index.js');
|
|
227
247
|
const action = positional[1] || 'status';
|
package/dist/config.js
CHANGED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { cliEntryPoint } from '../keepalive/environment.js';
|
|
3
|
+
import { encodePowerShellCommand, escapePowerShellSingleQuoted, } from '../keepalive/windows-service.js';
|
|
4
|
+
function waitForChild(child) {
|
|
5
|
+
return new Promise((resolve, reject) => {
|
|
6
|
+
child.once('error', reject);
|
|
7
|
+
child.once('close', (code) => resolve(code ?? 1));
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
export function renderAdminBrokerElevationScript({ action, nodePath, entryPoint, }) {
|
|
11
|
+
const quote = escapePowerShellSingleQuoted;
|
|
12
|
+
const args = `"${entryPoint}" admin-broker ${action} --elevated`;
|
|
13
|
+
return `$ErrorActionPreference = 'Stop'
|
|
14
|
+
try {
|
|
15
|
+
$process = Start-Process -FilePath '${quote(nodePath)}' -ArgumentList '${quote(args)}' -Verb RunAs -Wait -PassThru -WindowStyle Hidden
|
|
16
|
+
} catch {
|
|
17
|
+
if (($_.Exception.HResult -band 65535) -eq 1223) { exit 1223 }
|
|
18
|
+
exit 1
|
|
19
|
+
}
|
|
20
|
+
exit $process.ExitCode`;
|
|
21
|
+
}
|
|
22
|
+
function elevatedErrorCode(message, code) {
|
|
23
|
+
return Object.assign(new Error(message), { code });
|
|
24
|
+
}
|
|
25
|
+
/** Register the per-user task from the Windows TUI, requesting elevation once. */
|
|
26
|
+
export async function configureAdminBroker(action, t) {
|
|
27
|
+
if (process.platform !== 'win32')
|
|
28
|
+
throw new Error(t('adminBroker.windowsOnly'));
|
|
29
|
+
const script = renderAdminBrokerElevationScript({
|
|
30
|
+
action,
|
|
31
|
+
nodePath: process.execPath,
|
|
32
|
+
entryPoint: cliEntryPoint(),
|
|
33
|
+
});
|
|
34
|
+
const child = spawn('powershell.exe', ['-NoProfile', '-NonInteractive', '-EncodedCommand', encodePowerShellCommand(script)], { windowsHide: true, stdio: 'ignore' });
|
|
35
|
+
const exitCode = await waitForChild(child);
|
|
36
|
+
if (exitCode === 1223)
|
|
37
|
+
throw elevatedErrorCode(t('adminBroker.elevationCancelled'), 'ELEVATION_CANCELLED');
|
|
38
|
+
if (exitCode !== 0)
|
|
39
|
+
throw new Error(t('adminBroker.setupFailed'));
|
|
40
|
+
}
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import net from 'node:net';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { PtySession } from '../pty-session.js';
|
|
6
|
+
import { PACKAGE_ROOT } from '../paths.js';
|
|
7
|
+
import { ADMIN_BROKER_MAX_MESSAGE_BYTES, adminBrokerPipePath, adminBrokerTaskName, sendAdminBrokerMessage, } from './adminBrokerPipe.js';
|
|
8
|
+
const BROKER_PING_TIMEOUT_MS = 1_000;
|
|
9
|
+
function powershellQuote(value) {
|
|
10
|
+
return `'${value.replaceAll("'", "''")}'`;
|
|
11
|
+
}
|
|
12
|
+
function runPowerShell(script, t) {
|
|
13
|
+
try {
|
|
14
|
+
execFileSync('powershell.exe', [
|
|
15
|
+
'-NoProfile',
|
|
16
|
+
'-NonInteractive',
|
|
17
|
+
'-EncodedCommand',
|
|
18
|
+
Buffer.from(script, 'utf16le').toString('base64'),
|
|
19
|
+
], {
|
|
20
|
+
stdio: 'pipe',
|
|
21
|
+
windowsHide: true,
|
|
22
|
+
timeout: 15_000,
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
throw new Error(t('adminBroker.needsElevation'));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function installAdminBroker(t) {
|
|
30
|
+
if (process.platform !== 'win32')
|
|
31
|
+
throw new Error(t('adminBroker.windowsOnly'));
|
|
32
|
+
const brokerPath = fileURLToPath(import.meta.url);
|
|
33
|
+
const brokerCommand = `& ${powershellQuote(process.execPath)} ${powershellQuote(brokerPath)}`;
|
|
34
|
+
const encodedBrokerCommand = Buffer.from(brokerCommand, 'utf16le').toString('base64');
|
|
35
|
+
const powershell = path.join(process.env.SystemRoot || 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
36
|
+
const script = `
|
|
37
|
+
$ErrorActionPreference = 'Stop'
|
|
38
|
+
$taskName = ${powershellQuote(adminBrokerTaskName())}
|
|
39
|
+
$user = [Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
40
|
+
$action = New-ScheduledTaskAction -Execute ${powershellQuote(powershell)} -Argument ${powershellQuote(`-NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ${encodedBrokerCommand}`)} -WorkingDirectory ${powershellQuote(PACKAGE_ROOT)}
|
|
41
|
+
$trigger = New-ScheduledTaskTrigger -AtLogOn -User $user
|
|
42
|
+
$principal = New-ScheduledTaskPrincipal -UserId $user -LogonType Interactive -RunLevel Highest
|
|
43
|
+
$settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -StartWhenAvailable -ExecutionTimeLimit (New-TimeSpan -Seconds 0)
|
|
44
|
+
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
|
45
|
+
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
|
|
46
|
+
Start-ScheduledTask -TaskName $taskName
|
|
47
|
+
`;
|
|
48
|
+
runPowerShell(script, t);
|
|
49
|
+
}
|
|
50
|
+
export function uninstallAdminBroker(t) {
|
|
51
|
+
if (process.platform !== 'win32')
|
|
52
|
+
throw new Error(t('adminBroker.windowsOnly'));
|
|
53
|
+
runPowerShell(`
|
|
54
|
+
$ErrorActionPreference = 'Stop'
|
|
55
|
+
$taskName = ${powershellQuote(adminBrokerTaskName())}
|
|
56
|
+
Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
|
|
57
|
+
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
|
|
58
|
+
`, t);
|
|
59
|
+
}
|
|
60
|
+
export function adminBrokerStatus() {
|
|
61
|
+
if (process.platform !== 'win32')
|
|
62
|
+
return Promise.resolve(false);
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
let settled = false;
|
|
65
|
+
let response = '';
|
|
66
|
+
const socket = net.createConnection(adminBrokerPipePath());
|
|
67
|
+
const finish = (available) => {
|
|
68
|
+
if (settled)
|
|
69
|
+
return;
|
|
70
|
+
settled = true;
|
|
71
|
+
socket.destroy();
|
|
72
|
+
resolve(available);
|
|
73
|
+
};
|
|
74
|
+
socket.setTimeout(BROKER_PING_TIMEOUT_MS, () => finish(false));
|
|
75
|
+
socket.setEncoding('utf8');
|
|
76
|
+
socket.on('connect', () => sendAdminBrokerMessage(socket, { type: 'ping' }));
|
|
77
|
+
socket.on('data', (chunk) => {
|
|
78
|
+
response += chunk;
|
|
79
|
+
if (response.includes('\n'))
|
|
80
|
+
finish(response.includes('"type":"pong"'));
|
|
81
|
+
});
|
|
82
|
+
socket.on('error', () => finish(false));
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
function writeError(socket, error) {
|
|
86
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
87
|
+
sendAdminBrokerMessage(socket, { type: 'error', message });
|
|
88
|
+
socket.end();
|
|
89
|
+
}
|
|
90
|
+
function startBrokerPty(socket, message) {
|
|
91
|
+
if (typeof message.command !== 'string' || !message.command || message.command.length > 4096)
|
|
92
|
+
throw new Error('Invalid administrator terminal command.');
|
|
93
|
+
if (path.basename(message.command).toLowerCase() !== 'powershell.exe')
|
|
94
|
+
throw new Error('The administrator broker only starts PowerShell.');
|
|
95
|
+
if (!Array.isArray(message.args) ||
|
|
96
|
+
message.args.length > 128 ||
|
|
97
|
+
!message.args.every((arg) => typeof arg === 'string'))
|
|
98
|
+
throw new Error('Invalid administrator terminal arguments.');
|
|
99
|
+
if (message.args.length !== 2 ||
|
|
100
|
+
message.args[0] !== '-NoLogo' ||
|
|
101
|
+
message.args[1] !== '-NoProfile')
|
|
102
|
+
throw new Error('The administrator broker only starts an interactive PowerShell prompt.');
|
|
103
|
+
if (typeof message.cwd !== 'string' || message.cwd.length > 4096)
|
|
104
|
+
throw new Error('Invalid administrator terminal working directory.');
|
|
105
|
+
if (message.socketPath !== null && message.socketPath !== undefined)
|
|
106
|
+
throw new Error('An administrator PowerShell session does not use a Herdr socket.');
|
|
107
|
+
const pty = new PtySession({
|
|
108
|
+
command: message.command,
|
|
109
|
+
args: message.args,
|
|
110
|
+
cwd: message.cwd,
|
|
111
|
+
socketPath: message.socketPath,
|
|
112
|
+
platform: 'win32',
|
|
113
|
+
// A plain PowerShell prompt does not need the Herdr UI's Kitty or mouse modes.
|
|
114
|
+
fastWindowsPty: true,
|
|
115
|
+
});
|
|
116
|
+
pty.start({
|
|
117
|
+
cols: PtySession.clampDimension(message.cols, PtySession.DEFAULT_COLS),
|
|
118
|
+
rows: PtySession.clampDimension(message.rows, PtySession.DEFAULT_ROWS),
|
|
119
|
+
onData: (data) => {
|
|
120
|
+
if (socket.writable)
|
|
121
|
+
sendAdminBrokerMessage(socket, {
|
|
122
|
+
type: 'data',
|
|
123
|
+
dataBase64: Buffer.from(data).toString('base64'),
|
|
124
|
+
});
|
|
125
|
+
},
|
|
126
|
+
onExit: ({ exitCode, signal }) => {
|
|
127
|
+
if (socket.writable)
|
|
128
|
+
sendAdminBrokerMessage(socket, { type: 'exit', exitCode, signal });
|
|
129
|
+
socket.end();
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
sendAdminBrokerMessage(socket, { type: 'started', pid: pty.terminal?.pid ?? 0 });
|
|
133
|
+
return pty;
|
|
134
|
+
}
|
|
135
|
+
function runAdminBroker() {
|
|
136
|
+
if (process.platform !== 'win32')
|
|
137
|
+
throw new Error('Administrator PTYs are only supported on Windows.');
|
|
138
|
+
const server = net.createServer((socket) => {
|
|
139
|
+
socket.setEncoding('utf8');
|
|
140
|
+
let pending = '';
|
|
141
|
+
let pty = null;
|
|
142
|
+
let firstMessage = true;
|
|
143
|
+
socket.on('data', (chunk) => {
|
|
144
|
+
pending += chunk;
|
|
145
|
+
if (Buffer.byteLength(pending, 'utf8') > ADMIN_BROKER_MAX_MESSAGE_BYTES) {
|
|
146
|
+
socket.destroy(new Error('Administrator terminal message exceeded the limit.'));
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
while (true) {
|
|
150
|
+
const newline = pending.indexOf('\n');
|
|
151
|
+
if (newline < 0)
|
|
152
|
+
return;
|
|
153
|
+
const line = pending.slice(0, newline);
|
|
154
|
+
pending = pending.slice(newline + 1);
|
|
155
|
+
let message;
|
|
156
|
+
try {
|
|
157
|
+
message = JSON.parse(line);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
writeError(socket, new Error('Invalid administrator terminal message.'));
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
if (firstMessage) {
|
|
164
|
+
firstMessage = false;
|
|
165
|
+
if (message.type === 'ping') {
|
|
166
|
+
sendAdminBrokerMessage(socket, { type: 'pong' });
|
|
167
|
+
socket.end();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (message.type !== 'start') {
|
|
171
|
+
writeError(socket, new Error('Expected an administrator terminal start request.'));
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
pty = startBrokerPty(socket, message);
|
|
176
|
+
}
|
|
177
|
+
catch (error) {
|
|
178
|
+
writeError(socket, error);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (!pty)
|
|
184
|
+
return;
|
|
185
|
+
if (message.type === 'input' && typeof message.dataBase64 === 'string') {
|
|
186
|
+
if (message.dataBase64.length <= ADMIN_BROKER_MAX_MESSAGE_BYTES)
|
|
187
|
+
pty.write(Buffer.from(message.dataBase64, 'base64'));
|
|
188
|
+
}
|
|
189
|
+
else if (message.type === 'resize') {
|
|
190
|
+
pty.resize(message.cols, message.rows);
|
|
191
|
+
}
|
|
192
|
+
else if (message.type === 'kill') {
|
|
193
|
+
pty.kill();
|
|
194
|
+
socket.end();
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
socket.on('close', () => pty?.kill());
|
|
200
|
+
socket.on('error', () => pty?.kill());
|
|
201
|
+
});
|
|
202
|
+
server.listen(adminBrokerPipePath());
|
|
203
|
+
server.on('error', (error) => {
|
|
204
|
+
process.stderr.write(`herdr-remote administrator broker: ${error.message}\n`);
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
if (path.basename(process.argv[1] || '') === 'admin-broker.js')
|
|
208
|
+
runAdminBroker();
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
const ADMIN_BROKER_TASK_NAME = 'HerdrRemoteAdminBroker';
|
|
3
|
+
export const ADMIN_BROKER_MAX_MESSAGE_BYTES = 2 * 1_024 * 1_024;
|
|
4
|
+
function adminBrokerAccountId() {
|
|
5
|
+
const account = `${process.env.USERDOMAIN || ''}\\${process.env.USERNAME || process.env.USERPROFILE || 'default'}`;
|
|
6
|
+
return createHash('sha256').update(account.toLowerCase()).digest('hex').slice(0, 24);
|
|
7
|
+
}
|
|
8
|
+
export function adminBrokerPipePath() {
|
|
9
|
+
return `\\\\.\\pipe\\herdr-remote-admin-${adminBrokerAccountId()}`;
|
|
10
|
+
}
|
|
11
|
+
export function adminBrokerTaskName() {
|
|
12
|
+
return `${ADMIN_BROKER_TASK_NAME}-${adminBrokerAccountId()}`;
|
|
13
|
+
}
|
|
14
|
+
export function sendAdminBrokerMessage(socket, message) {
|
|
15
|
+
socket.write(`${JSON.stringify(message)}\n`);
|
|
16
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
import net from 'node:net';
|
|
2
|
+
import { ADMIN_BROKER_MAX_MESSAGE_BYTES, adminBrokerPipePath, sendAdminBrokerMessage, } from './adminBrokerPipe.js';
|
|
3
|
+
const BROKER_CONNECT_TIMEOUT_MS = 5_000;
|
|
4
|
+
export class ElevatedPty {
|
|
5
|
+
pid = 0;
|
|
6
|
+
cols;
|
|
7
|
+
rows;
|
|
8
|
+
socket = null;
|
|
9
|
+
input = '';
|
|
10
|
+
connected = false;
|
|
11
|
+
started = false;
|
|
12
|
+
finished = false;
|
|
13
|
+
killed = false;
|
|
14
|
+
dataListener = null;
|
|
15
|
+
exitListener = null;
|
|
16
|
+
readyListener = null;
|
|
17
|
+
errorListener = null;
|
|
18
|
+
options;
|
|
19
|
+
constructor(options, cols, rows) {
|
|
20
|
+
this.options = options;
|
|
21
|
+
this.cols = cols;
|
|
22
|
+
this.rows = rows;
|
|
23
|
+
}
|
|
24
|
+
onData(listener) {
|
|
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;
|
|
33
|
+
this.dataListener = onData;
|
|
34
|
+
this.exitListener = onExit;
|
|
35
|
+
this.readyListener = onReady || null;
|
|
36
|
+
this.errorListener = onError || null;
|
|
37
|
+
const socket = net.createConnection(adminBrokerPipePath());
|
|
38
|
+
this.socket = socket;
|
|
39
|
+
socket.setEncoding('utf8');
|
|
40
|
+
socket.setTimeout(BROKER_CONNECT_TIMEOUT_MS, () => {
|
|
41
|
+
socket.destroy(new Error('Administrator terminal broker connection timed out'));
|
|
42
|
+
});
|
|
43
|
+
socket.on('connect', () => {
|
|
44
|
+
this.connected = true;
|
|
45
|
+
socket.setTimeout(0);
|
|
46
|
+
sendAdminBrokerMessage(socket, {
|
|
47
|
+
type: 'start',
|
|
48
|
+
...this.options,
|
|
49
|
+
cols: this.cols,
|
|
50
|
+
rows: this.rows,
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
socket.on('data', (chunk) => this.receive(chunk));
|
|
54
|
+
socket.on('error', (error) => this.fail(error));
|
|
55
|
+
socket.on('close', () => {
|
|
56
|
+
if (this.started && !this.finished && !this.killed) {
|
|
57
|
+
this.finished = true;
|
|
58
|
+
this.exitListener?.({ exitCode: 1 });
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
write(data) {
|
|
63
|
+
if (this.started && !this.finished && this.socket?.writable) {
|
|
64
|
+
sendAdminBrokerMessage(this.socket, {
|
|
65
|
+
type: 'input',
|
|
66
|
+
dataBase64: Buffer.from(data).toString('base64'),
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
resize(cols, rows) {
|
|
71
|
+
this.cols = cols;
|
|
72
|
+
this.rows = rows;
|
|
73
|
+
if (this.started && this.socket?.writable) {
|
|
74
|
+
sendAdminBrokerMessage(this.socket, { type: 'resize', cols, rows });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
kill() {
|
|
78
|
+
this.killed = true;
|
|
79
|
+
this.pid = 0;
|
|
80
|
+
if (!this.socket)
|
|
81
|
+
return;
|
|
82
|
+
if (this.connected && this.socket.writable) {
|
|
83
|
+
sendAdminBrokerMessage(this.socket, { type: 'kill' });
|
|
84
|
+
this.socket.end();
|
|
85
|
+
}
|
|
86
|
+
else {
|
|
87
|
+
this.socket.destroy();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
receive(chunk) {
|
|
91
|
+
this.input += chunk;
|
|
92
|
+
if (Buffer.byteLength(this.input, 'utf8') > ADMIN_BROKER_MAX_MESSAGE_BYTES) {
|
|
93
|
+
this.socket?.destroy(new Error('Administrator terminal broker message exceeded the limit'));
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
while (true) {
|
|
97
|
+
const newline = this.input.indexOf('\n');
|
|
98
|
+
if (newline < 0)
|
|
99
|
+
return;
|
|
100
|
+
const line = this.input.slice(0, newline);
|
|
101
|
+
this.input = this.input.slice(newline + 1);
|
|
102
|
+
let message;
|
|
103
|
+
try {
|
|
104
|
+
message = JSON.parse(line);
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
this.socket?.destroy(new Error('Administrator terminal broker sent invalid JSON'));
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
if (message.type === 'started') {
|
|
111
|
+
this.started = true;
|
|
112
|
+
this.pid = Number(message.pid) || 0;
|
|
113
|
+
this.readyListener?.();
|
|
114
|
+
}
|
|
115
|
+
else if (message.type === 'data' && typeof message.dataBase64 === 'string') {
|
|
116
|
+
this.dataListener?.(Buffer.from(message.dataBase64, 'base64').toString('utf8'));
|
|
117
|
+
}
|
|
118
|
+
else if (message.type === 'exit') {
|
|
119
|
+
this.finished = true;
|
|
120
|
+
this.pid = 0;
|
|
121
|
+
this.exitListener?.({
|
|
122
|
+
exitCode: Number(message.exitCode) || 0,
|
|
123
|
+
...(typeof message.signal === 'number' ? { signal: message.signal } : {}),
|
|
124
|
+
});
|
|
125
|
+
this.socket?.end();
|
|
126
|
+
}
|
|
127
|
+
else if (message.type === 'error') {
|
|
128
|
+
this.fail(new Error(typeof message.message === 'string' ? message.message : 'Admin PTY failed'));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
fail(error) {
|
|
133
|
+
if (this.started) {
|
|
134
|
+
if (!this.finished && !this.killed) {
|
|
135
|
+
this.finished = true;
|
|
136
|
+
this.exitListener?.({ exitCode: 1 });
|
|
137
|
+
}
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
if (this.finished || this.killed)
|
|
141
|
+
return;
|
|
142
|
+
this.finished = true;
|
|
143
|
+
this.errorListener?.(error);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
@@ -185,10 +185,19 @@ export class HostConnector {
|
|
|
185
185
|
password: this.relayPassword || null,
|
|
186
186
|
hostname: os.hostname(),
|
|
187
187
|
platform: process.platform,
|
|
188
|
+
...(process.platform === 'win32' &&
|
|
189
|
+
(process.env.MSYSTEM || /(?:^|[\\/])(?:bash|sh)(?:\.exe)?$/i.test(process.env.SHELL || ''))
|
|
190
|
+
? { shellProfile: 'git-bash' }
|
|
191
|
+
: {}),
|
|
188
192
|
arch: process.arch,
|
|
189
193
|
terminalPalette: this.terminalPalette || null,
|
|
190
194
|
terminalFont: this.font.publicFont(),
|
|
191
|
-
capabilities: [
|
|
195
|
+
capabilities: [
|
|
196
|
+
CAPABILITY.hostHandoff,
|
|
197
|
+
CAPABILITY.idleHeartbeat,
|
|
198
|
+
CAPABILITY.binaryFrameV2,
|
|
199
|
+
...(process.platform === 'win32' ? [CAPABILITY.adminSessions] : []),
|
|
200
|
+
],
|
|
192
201
|
});
|
|
193
202
|
// The relay sends host_ready with the current browser count. No business
|
|
194
203
|
// heartbeat is started until that message says somebody is watching.
|
|
@@ -11,6 +11,8 @@ import { streamOf } from './wire.js';
|
|
|
11
11
|
* session the user actually used and then left.
|
|
12
12
|
*/
|
|
13
13
|
const FAST_FAILURE_MS = 1500;
|
|
14
|
+
/** Coalesce Windows ConPTY's many small output chunks before they reach the relay. */
|
|
15
|
+
const WINDOWS_OUTPUT_BATCH_MS = 4;
|
|
14
16
|
/** How many broken starts in a row before we stop calling them exits. */
|
|
15
17
|
export const FAST_FAILURE_LIMIT = 3;
|
|
16
18
|
export class Sessions {
|
|
@@ -103,6 +105,10 @@ export class Sessions {
|
|
|
103
105
|
if (!streamId)
|
|
104
106
|
return undefined;
|
|
105
107
|
this.stop(streamId);
|
|
108
|
+
if (message.adminTerminal) {
|
|
109
|
+
this.spawn(message);
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
106
112
|
const missingHerdr = this.ensureHerdrCommand();
|
|
107
113
|
if (missingHerdr) {
|
|
108
114
|
process.stderr.write(`herdr-remote host connector: ${missingHerdr}\n`);
|
|
@@ -198,11 +204,15 @@ export class Sessions {
|
|
|
198
204
|
spawn(message) {
|
|
199
205
|
const streamId = streamOf(message);
|
|
200
206
|
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.
|
|
201
209
|
const pty = new this.Pty({
|
|
202
|
-
command: this.herdrCommand,
|
|
203
|
-
args: this.herdrArgs,
|
|
210
|
+
command: adminTerminal ? 'powershell.exe' : this.herdrCommand,
|
|
211
|
+
args: adminTerminal ? ['-NoLogo', '-NoProfile'] : this.herdrArgs,
|
|
204
212
|
cwd: this.cwd,
|
|
205
|
-
socketPath: this.socketPath,
|
|
213
|
+
socketPath: adminTerminal ? null : this.socketPath,
|
|
214
|
+
adminTerminal,
|
|
215
|
+
fastWindowsPty: adminTerminal,
|
|
206
216
|
});
|
|
207
217
|
const session = {
|
|
208
218
|
id: streamId,
|
|
@@ -214,12 +224,17 @@ export class Sessions {
|
|
|
214
224
|
startedAtMs: Date.now(),
|
|
215
225
|
pendingOutput: [],
|
|
216
226
|
flushImmediate: null,
|
|
227
|
+
flushTimeout: null,
|
|
217
228
|
};
|
|
218
229
|
const flushOutput = () => {
|
|
219
230
|
if (session.flushImmediate) {
|
|
220
231
|
clearImmediate(session.flushImmediate);
|
|
221
232
|
session.flushImmediate = null;
|
|
222
233
|
}
|
|
234
|
+
if (session.flushTimeout) {
|
|
235
|
+
clearTimeout(session.flushTimeout);
|
|
236
|
+
session.flushTimeout = null;
|
|
237
|
+
}
|
|
223
238
|
if (session.pendingOutput.length === 0)
|
|
224
239
|
return;
|
|
225
240
|
const payload = Buffer.concat(session.pendingOutput);
|
|
@@ -228,6 +243,29 @@ export class Sessions {
|
|
|
228
243
|
? packStreamFrameV2(FRAME_TYPE_OUTPUT, session.streamIndex, payload)
|
|
229
244
|
: packStreamFrame('output', streamId, payload));
|
|
230
245
|
};
|
|
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);
|
|
231
269
|
try {
|
|
232
270
|
pty.start({
|
|
233
271
|
cols: session.cols,
|
|
@@ -237,10 +275,14 @@ export class Sessions {
|
|
|
237
275
|
if (chunk.length === 0)
|
|
238
276
|
return;
|
|
239
277
|
session.pendingOutput.push(chunk);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
278
|
+
if (!session.flushImmediate && !session.flushTimeout) {
|
|
279
|
+
if (process.platform === 'win32') {
|
|
280
|
+
session.flushTimeout = setTimeout(flushOutput, WINDOWS_OUTPUT_BATCH_MS);
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
session.flushImmediate = setImmediate(flushOutput);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
244
286
|
},
|
|
245
287
|
onExit: ({ exitCode }) => {
|
|
246
288
|
if (this.byStream.get(streamId) !== session)
|
|
@@ -249,18 +291,16 @@ export class Sessions {
|
|
|
249
291
|
this.reportExit(session, exitCode);
|
|
250
292
|
this.link.changed();
|
|
251
293
|
},
|
|
294
|
+
onReady: reportReady,
|
|
295
|
+
onError: reportStartError,
|
|
252
296
|
});
|
|
297
|
+
if (!adminTerminal)
|
|
298
|
+
reportReady();
|
|
253
299
|
}
|
|
254
300
|
catch (error) {
|
|
255
|
-
|
|
256
|
-
this.sendError(streamId, 'pty_start_failed', error.message);
|
|
257
|
-
pty.kill();
|
|
301
|
+
reportStartError(error);
|
|
258
302
|
return;
|
|
259
303
|
}
|
|
260
|
-
this.byStream.set(streamId, session);
|
|
261
|
-
if (streamIndex !== null)
|
|
262
|
-
this.streamIndexToId.set(streamIndex, streamId);
|
|
263
|
-
this.link.send({ type: 'session_ready', clientId: streamId });
|
|
264
304
|
this.link.changed();
|
|
265
305
|
}
|
|
266
306
|
/**
|
|
@@ -362,5 +402,9 @@ function discardOutput(session) {
|
|
|
362
402
|
clearImmediate(session.flushImmediate);
|
|
363
403
|
session.flushImmediate = null;
|
|
364
404
|
}
|
|
405
|
+
if (session.flushTimeout) {
|
|
406
|
+
clearTimeout(session.flushTimeout);
|
|
407
|
+
session.flushTimeout = null;
|
|
408
|
+
}
|
|
365
409
|
session.pendingOutput = [];
|
|
366
410
|
}
|
package/dist/i18n/en.js
CHANGED
|
@@ -84,6 +84,19 @@ const en = {
|
|
|
84
84
|
'services.logEmpty': 'No logs.',
|
|
85
85
|
'services.logRelay': 'Relay',
|
|
86
86
|
'services.logHost': 'Host connector',
|
|
87
|
+
'adminBroker.windowsOnly': 'The administrator terminal broker only runs on Windows.',
|
|
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.title': 'Admin terminal broker',
|
|
90
|
+
'adminBroker.checking': 'Checking…',
|
|
91
|
+
'adminBroker.ready': 'Ready',
|
|
92
|
+
'adminBroker.notReady': 'Not configured or stopped',
|
|
93
|
+
'adminBroker.install': 'Set up admin terminal access',
|
|
94
|
+
'adminBroker.uninstall': 'Remove admin terminal access',
|
|
95
|
+
'adminBroker.installDone': 'Admin terminal broker setup task registered.',
|
|
96
|
+
'adminBroker.uninstallDone': 'Admin terminal broker setup task removed.',
|
|
97
|
+
'adminBroker.elevationCancelled': 'Administrator approval was declined; no changes were made.',
|
|
98
|
+
'adminBroker.setupFailed': 'Could not configure the administrator terminal broker.',
|
|
99
|
+
'adminBroker.tuiHint': 'Set this up once. Windows asks for administrator approval; later WebUI admin terminals open without UAC.',
|
|
87
100
|
'relay.title': 'Relay settings',
|
|
88
101
|
'relay.settings': 'Settings',
|
|
89
102
|
'relay.locked': 'fixed',
|
|
@@ -131,7 +144,8 @@ const en = {
|
|
|
131
144
|
'keepalive.enableLinger': 'Enable start at boot',
|
|
132
145
|
'keepalive.lingerDone': 'Start at boot enabled for {username}.',
|
|
133
146
|
'keepalive.fallbackNote': 'No system service manager found; background process will not survive reboot.',
|
|
134
|
-
'keepalive.
|
|
147
|
+
'keepalive.windowsTaskNote': 'Starts after you sign in as your account. It needs no password or administrator rights and does not run before sign-in.',
|
|
148
|
+
'keepalive.serviceNote': 'Runs as a Windows service under your account and starts before sign-in. Installing or removing it asks for administrator rights, and Windows may require your account password.',
|
|
135
149
|
'keepalive.serviceWindowHint': 'Finish the installation in the administrator window that opens.',
|
|
136
150
|
'keepalive.elevationCancelled': 'Administrator rights were not granted; nothing was installed.',
|
|
137
151
|
'keepalive.passwordPrompt': 'Windows account password for {account} (not a PIN; press Enter if there is no password):',
|
package/dist/i18n/zh.js
CHANGED
|
@@ -83,6 +83,19 @@ const zh = {
|
|
|
83
83
|
'services.logEmpty': '暂无日志。',
|
|
84
84
|
'services.logRelay': 'Relay',
|
|
85
85
|
'services.logHost': '主机连接器',
|
|
86
|
+
'adminBroker.windowsOnly': '管理员终端 broker 只支持 Windows。',
|
|
87
|
+
'adminBroker.needsElevation': '请打开 Windows TUI 并选择“设置管理员终端”,使用运行 Herdr Remote 的同一账户批准一次 Windows 提权提示。',
|
|
88
|
+
'adminBroker.title': '管理员终端 broker',
|
|
89
|
+
'adminBroker.checking': '正在检查…',
|
|
90
|
+
'adminBroker.ready': '已就绪',
|
|
91
|
+
'adminBroker.notReady': '尚未设置或未运行',
|
|
92
|
+
'adminBroker.install': '设置管理员终端',
|
|
93
|
+
'adminBroker.uninstall': '移除管理员终端设置',
|
|
94
|
+
'adminBroker.installDone': '管理员终端 broker 任务已注册。',
|
|
95
|
+
'adminBroker.uninstallDone': '管理员终端 broker 任务已移除。',
|
|
96
|
+
'adminBroker.elevationCancelled': '未批准管理员权限,未进行更改。',
|
|
97
|
+
'adminBroker.setupFailed': '管理员终端 broker 设置失败。',
|
|
98
|
+
'adminBroker.tuiHint': '只需设置一次。Windows 会请求管理员授权;之后 WebUI 新建管理员终端不再弹出 UAC。',
|
|
86
99
|
'relay.title': 'Relay 设置',
|
|
87
100
|
'relay.settings': '设置',
|
|
88
101
|
'relay.locked': '固定',
|
|
@@ -130,7 +143,8 @@ const zh = {
|
|
|
130
143
|
'keepalive.enableLinger': '开启开机自启',
|
|
131
144
|
'keepalive.lingerDone': '已为 {username} 开启开机自启。',
|
|
132
145
|
'keepalive.fallbackNote': '无系统服务管理器,使用后台守护进程,重启后不自动恢复。',
|
|
133
|
-
'keepalive.
|
|
146
|
+
'keepalive.windowsTaskNote': '在你的账户登录后启动,无需密码或管理员权限;登录前不会运行。',
|
|
147
|
+
'keepalive.serviceNote': '以你的账户作为 Windows 服务运行,在登录前启动。安装或移除需要管理员权限,Windows 也可能要求账户密码。',
|
|
134
148
|
'keepalive.serviceWindowHint': '请在随后打开的管理员窗口中完成安装。',
|
|
135
149
|
'keepalive.elevationCancelled': '未授予管理员权限,未安装服务。',
|
|
136
150
|
'keepalive.passwordPrompt': '请输入 {account} 的 Windows 账户密码(不是 PIN;账户无密码时直接按 Enter):',
|
package/dist/keepalive/index.js
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
//
|
|
3
3
|
// "Start the services" and "keep the services running" are different problems:
|
|
4
4
|
// a detached spawn dies with the first crash and never comes back. This module
|
|
5
|
-
// hands supervision to the platform's
|
|
6
|
-
//
|
|
7
|
-
// of our own supervisor where neither does.
|
|
5
|
+
// hands supervision to the platform's manager where one exists (systemd --user,
|
|
6
|
+
// launchd, or Task Scheduler) and falls back to a detached supervisor elsewhere.
|
|
8
7
|
import { spawnSync } from 'node:child_process';
|
|
9
8
|
import fs from 'node:fs';
|
|
10
9
|
import os from 'node:os';
|
|
@@ -14,6 +13,7 @@ import { detached } from './detached.js';
|
|
|
14
13
|
import { launchd } from './launchd.js';
|
|
15
14
|
import { systemd } from './systemd.js';
|
|
16
15
|
import { windowsService } from './windows-service.js';
|
|
16
|
+
import { windowsTask } from './windows-task.js';
|
|
17
17
|
export { serviceEnvironment, servicePath } from './environment.js';
|
|
18
18
|
export { escapeXml, renderLaunchdPlist } from './launchd.js';
|
|
19
19
|
export { renderSystemdUnit, systemdEnvironmentLine } from './systemd.js';
|
|
@@ -41,7 +41,7 @@ export function detectManager(preference = 'auto', platform = process.platform)
|
|
|
41
41
|
if (preference && preference !== 'auto')
|
|
42
42
|
return preference;
|
|
43
43
|
if (platform === 'win32')
|
|
44
|
-
return 'windows-
|
|
44
|
+
return 'windows-task';
|
|
45
45
|
if (platform === 'linux') {
|
|
46
46
|
// `systemctl --user` needs a user bus; containers and bare TTY logins often
|
|
47
47
|
// have systemd installed but no session bus, where it would fail at runtime.
|
|
@@ -60,16 +60,27 @@ function managerOf(config) {
|
|
|
60
60
|
return detectManager(config.keepalive?.manager || 'auto');
|
|
61
61
|
}
|
|
62
62
|
/** The backend for `config`. With keep-alive off, uninstalling still cleans up the fallback. */
|
|
63
|
-
function
|
|
64
|
-
const manager = managerOf(config);
|
|
63
|
+
function configuredBackend(manager) {
|
|
65
64
|
if (manager === 'systemd')
|
|
66
65
|
return systemd;
|
|
67
66
|
if (manager === 'launchd')
|
|
68
67
|
return launchd;
|
|
68
|
+
if (manager === 'windows-task')
|
|
69
|
+
return windowsTask;
|
|
69
70
|
if (manager === 'windows-service')
|
|
70
71
|
return windowsService;
|
|
71
72
|
return detached;
|
|
72
73
|
}
|
|
74
|
+
function backendOf(config) {
|
|
75
|
+
const manager = managerOf(config);
|
|
76
|
+
if (manager === 'windows-task') {
|
|
77
|
+
if (windowsTask.status().installed)
|
|
78
|
+
return windowsTask;
|
|
79
|
+
if (windowsService.status().installed)
|
|
80
|
+
return windowsService;
|
|
81
|
+
}
|
|
82
|
+
return configuredBackend(manager);
|
|
83
|
+
}
|
|
73
84
|
export function status(config = loadConfig()) {
|
|
74
85
|
if (managerOf(config) === 'none')
|
|
75
86
|
return { manager: 'none', installed: false, active: false, enabled: false };
|
|
@@ -78,7 +89,7 @@ export function status(config = loadConfig()) {
|
|
|
78
89
|
export async function install(config = loadConfig()) {
|
|
79
90
|
if (managerOf(config) === 'none')
|
|
80
91
|
throw new Error('keep-alive is disabled in the configuration');
|
|
81
|
-
const backend =
|
|
92
|
+
const backend = configuredBackend(managerOf(config));
|
|
82
93
|
return { manager: backend.name, ...(await backend.install()) };
|
|
83
94
|
}
|
|
84
95
|
export async function uninstall(config = loadConfig()) {
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { configDir, stateDir } from '../paths.js';
|
|
5
|
+
import { detachedLogsHint } from './detached.js';
|
|
6
|
+
import { cliEntryPoint, serviceEnvironment } from './environment.js';
|
|
7
|
+
import { currentServiceAccount, encodePowerShellCommand, escapePowerShellSingleQuoted, serviceId, windowsService, } from './windows-service.js';
|
|
8
|
+
const TASK_START_TIMEOUT_MS = 15_000;
|
|
9
|
+
const TASK_STOP_TIMEOUT_MS = 15_000;
|
|
10
|
+
const TASK_POLL_INTERVAL_MS = 250;
|
|
11
|
+
export function windowsTaskName(username = currentServiceAccount().user) {
|
|
12
|
+
return serviceId(username);
|
|
13
|
+
}
|
|
14
|
+
export function renderWindowsTaskLaunchScript(options) {
|
|
15
|
+
const environment = {
|
|
16
|
+
...options.environment,
|
|
17
|
+
HERDR_REMOTE_CONFIG_DIR: options.configDirectory,
|
|
18
|
+
HERDR_REMOTE_STATE_DIR: options.stateDirectory,
|
|
19
|
+
HERDR_REMOTE_SERVICE: '1',
|
|
20
|
+
};
|
|
21
|
+
const environmentLines = Object.entries(environment).map(([key, value]) => `$env:${key} = '${escapePowerShellSingleQuoted(value)}'`);
|
|
22
|
+
return `$ErrorActionPreference = 'Stop'
|
|
23
|
+
${environmentLines.join('\n')}
|
|
24
|
+
Set-Location -LiteralPath '${escapePowerShellSingleQuoted(options.workingDirectory)}'
|
|
25
|
+
& '${escapePowerShellSingleQuoted(options.nodePath)}' '${escapePowerShellSingleQuoted(options.entryPoint)}' run --daemon
|
|
26
|
+
exit $LASTEXITCODE`;
|
|
27
|
+
}
|
|
28
|
+
export function renderWindowsTaskRegistrationScript(options) {
|
|
29
|
+
const taskName = escapePowerShellSingleQuoted(options.taskName);
|
|
30
|
+
const account = escapePowerShellSingleQuoted(options.account);
|
|
31
|
+
const powershellPath = escapePowerShellSingleQuoted(options.powershellPath);
|
|
32
|
+
const workingDirectory = escapePowerShellSingleQuoted(options.workingDirectory);
|
|
33
|
+
const launchArguments = `-NoLogo -NoProfile -NonInteractive -WindowStyle Hidden -EncodedCommand ${encodePowerShellCommand(options.launchScript)}`;
|
|
34
|
+
return `$ErrorActionPreference = 'Stop'
|
|
35
|
+
$action = New-ScheduledTaskAction -Execute '${powershellPath}' -Argument '${launchArguments}' -WorkingDirectory '${workingDirectory}'
|
|
36
|
+
$trigger = New-ScheduledTaskTrigger -AtLogOn -User '${account}'
|
|
37
|
+
# Interactive logon uses the signed-in token and avoids storing account credentials.
|
|
38
|
+
$principal = New-ScheduledTaskPrincipal -UserId '${account}' -LogonType Interactive -RunLevel Limited
|
|
39
|
+
$settings = New-ScheduledTaskSettingsSet -StartWhenAvailable -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) -ExecutionTimeLimit ([TimeSpan]::Zero) -MultipleInstances IgnoreNew
|
|
40
|
+
Register-ScheduledTask -TaskName '${taskName}' -Description 'Herdr Remote keep-alive' -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force | Out-Null
|
|
41
|
+
Enable-ScheduledTask -TaskName '${taskName}' | Out-Null
|
|
42
|
+
Start-ScheduledTask -TaskName '${taskName}'`;
|
|
43
|
+
}
|
|
44
|
+
export function parseWindowsTaskState(output) {
|
|
45
|
+
try {
|
|
46
|
+
const state = JSON.parse(output.replace(/^\uFEFF/, '').trim()).State;
|
|
47
|
+
return state === 'Ready' || state === 'Queued' || state === 'Running' || state === 'Disabled'
|
|
48
|
+
? state
|
|
49
|
+
: 'Absent';
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
// Task status is best-effort; malformed scheduler output means no known task.
|
|
53
|
+
return 'Absent';
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function windowsPowerShellPath() {
|
|
57
|
+
const root = process.env.SystemRoot || process.env.WINDIR || 'C:\\Windows';
|
|
58
|
+
return path.join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
|
|
59
|
+
}
|
|
60
|
+
function taskStateScript(taskName) {
|
|
61
|
+
const safeName = escapePowerShellSingleQuoted(taskName);
|
|
62
|
+
return `$ErrorActionPreference = 'Stop'
|
|
63
|
+
$task = Get-ScheduledTask -TaskName '${safeName}' -ErrorAction SilentlyContinue
|
|
64
|
+
if ($null -eq $task) { '{"State":"Absent"}'; exit 0 }
|
|
65
|
+
[pscustomobject]@{ State = [string]$task.State } | ConvertTo-Json -Compress`;
|
|
66
|
+
}
|
|
67
|
+
function invokePowerShell(script, label) {
|
|
68
|
+
const result = spawnSync(windowsPowerShellPath(), [
|
|
69
|
+
'-NoLogo',
|
|
70
|
+
'-NoProfile',
|
|
71
|
+
'-NonInteractive',
|
|
72
|
+
'-EncodedCommand',
|
|
73
|
+
encodePowerShellCommand(script),
|
|
74
|
+
], { encoding: 'utf8', windowsHide: true });
|
|
75
|
+
if (result.error || result.status !== 0) {
|
|
76
|
+
const output = String(result.stderr || result.stdout || '').trim();
|
|
77
|
+
throw new Error(`${label} failed (${String(result.status)}): ${output}`);
|
|
78
|
+
}
|
|
79
|
+
return String(result.stdout || '').trim();
|
|
80
|
+
}
|
|
81
|
+
function taskState() {
|
|
82
|
+
const result = spawnSync(windowsPowerShellPath(), [
|
|
83
|
+
'-NoProfile',
|
|
84
|
+
'-NoLogo',
|
|
85
|
+
'-NonInteractive',
|
|
86
|
+
'-EncodedCommand',
|
|
87
|
+
encodePowerShellCommand(taskStateScript(windowsTaskName())),
|
|
88
|
+
], { encoding: 'utf8', windowsHide: true });
|
|
89
|
+
if (result.error || result.status !== 0) {
|
|
90
|
+
// A status query must not make unrelated CLI commands fail on an unavailable scheduler.
|
|
91
|
+
return 'Absent';
|
|
92
|
+
}
|
|
93
|
+
return parseWindowsTaskState(String(result.stdout || ''));
|
|
94
|
+
}
|
|
95
|
+
function accountName() {
|
|
96
|
+
const { domain, user } = currentServiceAccount();
|
|
97
|
+
return domain ? `${domain}\\${user}` : user;
|
|
98
|
+
}
|
|
99
|
+
function stopTaskScript(taskName) {
|
|
100
|
+
const safeName = escapePowerShellSingleQuoted(taskName);
|
|
101
|
+
return `$ErrorActionPreference = 'Stop'
|
|
102
|
+
$task = Get-ScheduledTask -TaskName '${safeName}' -ErrorAction SilentlyContinue
|
|
103
|
+
if ($null -eq $task -or ($task.State -ne 'Running' -and $task.State -ne 'Queued')) { exit 0 }
|
|
104
|
+
Stop-ScheduledTask -TaskName '${safeName}'
|
|
105
|
+
$deadline = [DateTime]::UtcNow.AddMilliseconds(${TASK_STOP_TIMEOUT_MS})
|
|
106
|
+
do {
|
|
107
|
+
Start-Sleep -Milliseconds ${TASK_POLL_INTERVAL_MS}
|
|
108
|
+
$task = Get-ScheduledTask -TaskName '${safeName}' -ErrorAction SilentlyContinue
|
|
109
|
+
} while ($task -and ($task.State -eq 'Running' -or $task.State -eq 'Queued') -and [DateTime]::UtcNow -lt $deadline)
|
|
110
|
+
if ($task -and ($task.State -eq 'Running' -or $task.State -eq 'Queued')) { throw 'Scheduled task did not stop in time.' }`;
|
|
111
|
+
}
|
|
112
|
+
function stopTask() {
|
|
113
|
+
const state = taskState();
|
|
114
|
+
if (state === 'Running' || state === 'Queued')
|
|
115
|
+
invokePowerShell(stopTaskScript(windowsTaskName()), 'Stopping the Windows scheduled task');
|
|
116
|
+
}
|
|
117
|
+
function launchOptions() {
|
|
118
|
+
const workingDirectory = stateDir();
|
|
119
|
+
return {
|
|
120
|
+
nodePath: process.execPath,
|
|
121
|
+
entryPoint: cliEntryPoint(),
|
|
122
|
+
workingDirectory,
|
|
123
|
+
configDirectory: configDir(),
|
|
124
|
+
stateDirectory: workingDirectory,
|
|
125
|
+
environment: serviceEnvironment(),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function status() {
|
|
129
|
+
const state = taskState();
|
|
130
|
+
const installed = state !== 'Absent';
|
|
131
|
+
return {
|
|
132
|
+
manager: 'windows-task',
|
|
133
|
+
installed,
|
|
134
|
+
active: state === 'Running',
|
|
135
|
+
enabled: installed && state !== 'Disabled',
|
|
136
|
+
state: state.toLowerCase(),
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
async function install() {
|
|
140
|
+
if (taskState() !== 'Absent')
|
|
141
|
+
stopTask();
|
|
142
|
+
// Remove the earlier service before starting a second supervisor for this user.
|
|
143
|
+
if (windowsService.status().installed)
|
|
144
|
+
await windowsService.uninstall();
|
|
145
|
+
const options = launchOptions();
|
|
146
|
+
await fs.promises.mkdir(options.workingDirectory, { recursive: true });
|
|
147
|
+
const launchScript = renderWindowsTaskLaunchScript(options);
|
|
148
|
+
const script = renderWindowsTaskRegistrationScript({
|
|
149
|
+
taskName: windowsTaskName(),
|
|
150
|
+
account: accountName(),
|
|
151
|
+
powershellPath: windowsPowerShellPath(),
|
|
152
|
+
workingDirectory: options.workingDirectory,
|
|
153
|
+
launchScript,
|
|
154
|
+
});
|
|
155
|
+
invokePowerShell(script, 'Installing the Windows scheduled task');
|
|
156
|
+
const deadline = Date.now() + TASK_START_TIMEOUT_MS;
|
|
157
|
+
let state = taskState();
|
|
158
|
+
while (state !== 'Running' && state !== 'Absent' && Date.now() < deadline) {
|
|
159
|
+
await new Promise((resolve) => setTimeout(resolve, TASK_POLL_INTERVAL_MS));
|
|
160
|
+
state = taskState();
|
|
161
|
+
}
|
|
162
|
+
if (state !== 'Running')
|
|
163
|
+
throw new Error(`Windows scheduled task did not start (state: ${state.toLowerCase()}).`);
|
|
164
|
+
return { ok: true, taskName: windowsTaskName(), state };
|
|
165
|
+
}
|
|
166
|
+
async function uninstall() {
|
|
167
|
+
const name = windowsTaskName();
|
|
168
|
+
if (taskState() !== 'Absent') {
|
|
169
|
+
stopTask();
|
|
170
|
+
const safeName = escapePowerShellSingleQuoted(name);
|
|
171
|
+
invokePowerShell(`Unregister-ScheduledTask -TaskName '${safeName}' -Confirm:$false -ErrorAction Stop`, 'Removing the Windows scheduled task');
|
|
172
|
+
}
|
|
173
|
+
if (windowsService.status().installed)
|
|
174
|
+
await windowsService.uninstall();
|
|
175
|
+
return { ok: true, taskName: name };
|
|
176
|
+
}
|
|
177
|
+
function restart() {
|
|
178
|
+
stopTask();
|
|
179
|
+
const safeName = escapePowerShellSingleQuoted(windowsTaskName());
|
|
180
|
+
invokePowerShell(`Start-ScheduledTask -TaskName '${safeName}'`, 'Starting the Windows scheduled task');
|
|
181
|
+
return { ok: true };
|
|
182
|
+
}
|
|
183
|
+
export const windowsTask = {
|
|
184
|
+
name: 'windows-task',
|
|
185
|
+
status,
|
|
186
|
+
install,
|
|
187
|
+
uninstall,
|
|
188
|
+
restart,
|
|
189
|
+
stop: stopTask,
|
|
190
|
+
logsHint: () => detachedLogsHint('win32'),
|
|
191
|
+
};
|
package/dist/lifecycle.js
CHANGED
|
@@ -49,7 +49,7 @@ function restartAll(config = loadConfig()) {
|
|
|
49
49
|
}
|
|
50
50
|
return { ...restartServices(), managed: false };
|
|
51
51
|
}
|
|
52
|
-
/**
|
|
52
|
+
/** Stop Windows keep-alive processes while npm replaces their package files. */
|
|
53
53
|
async function whileServicesStopped(config, task, { platform = process.platform, isRunning = () => Boolean(managerInUse(config)?.active || pidAlive(readRuntime().hostPid)), stop = stopAll, start = startAll, } = {}) {
|
|
54
54
|
if (platform !== 'win32' || !isRunning())
|
|
55
55
|
return task();
|
package/dist/pty-session.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import os from 'node:os';
|
|
2
2
|
import pty from 'node-pty';
|
|
3
|
+
import { ElevatedPty } from './connector/elevatedPty.js';
|
|
3
4
|
class PtySession {
|
|
4
5
|
static DEFAULT_COLS = 100;
|
|
5
6
|
static DEFAULT_ROWS = 30;
|
|
@@ -10,10 +11,12 @@ class PtySession {
|
|
|
10
11
|
cwd;
|
|
11
12
|
socketPath;
|
|
12
13
|
platform;
|
|
14
|
+
adminTerminal;
|
|
15
|
+
fastWindowsPty;
|
|
13
16
|
spawnPty;
|
|
14
17
|
terminal;
|
|
15
18
|
startedAt;
|
|
16
|
-
constructor({ command, args = [], cwd = os.homedir(), socketPath, platform = process.platform, spawn: spawnPty = pty.spawn, } = {}) {
|
|
19
|
+
constructor({ command, args = [], cwd = os.homedir(), socketPath, platform = process.platform, spawn: spawnPty = pty.spawn, adminTerminal = false, fastWindowsPty = false, } = {}) {
|
|
17
20
|
if (typeof command !== 'string' || command.length === 0)
|
|
18
21
|
throw new TypeError('command must be a non-empty string');
|
|
19
22
|
if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string'))
|
|
@@ -23,6 +26,8 @@ class PtySession {
|
|
|
23
26
|
this.cwd = cwd;
|
|
24
27
|
this.socketPath = socketPath;
|
|
25
28
|
this.platform = platform;
|
|
29
|
+
this.adminTerminal = adminTerminal;
|
|
30
|
+
this.fastWindowsPty = fastWindowsPty;
|
|
26
31
|
this.spawnPty = spawnPty;
|
|
27
32
|
this.terminal = null;
|
|
28
33
|
this.startedAt = null;
|
|
@@ -45,19 +50,43 @@ class PtySession {
|
|
|
45
50
|
return fallback;
|
|
46
51
|
return Math.min(PtySession.MAX_DIMENSION, Math.max(PtySession.MIN_DIMENSION, numeric));
|
|
47
52
|
}
|
|
48
|
-
start({ cols, rows, onData, onExit }) {
|
|
53
|
+
start({ cols, rows, onData, onExit, onReady, onError }) {
|
|
49
54
|
if (this.terminal)
|
|
50
55
|
throw new Error('PTY session already started');
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
+
// The Herdr UI needs the bundled ConPTY's Kitty and mouse reporting.
|
|
54
79
|
this.terminal = this.spawnPty(this.command, this.args, {
|
|
55
80
|
name: 'xterm-256color',
|
|
56
81
|
cols: PtySession.clampDimension(cols, PtySession.DEFAULT_COLS),
|
|
57
82
|
rows: PtySession.clampDimension(rows, PtySession.DEFAULT_ROWS),
|
|
58
83
|
cwd: this.cwd,
|
|
59
84
|
env: PtySession.childEnv(this.socketPath),
|
|
60
|
-
...(this.platform === 'win32'
|
|
85
|
+
...(this.platform === 'win32'
|
|
86
|
+
? this.fastWindowsPty
|
|
87
|
+
? { useConpty: false }
|
|
88
|
+
: { useConptyDll: true }
|
|
89
|
+
: {}),
|
|
61
90
|
});
|
|
62
91
|
this.startedAt = new Date().toISOString();
|
|
63
92
|
const terminal = this.terminal;
|
|
@@ -69,6 +98,7 @@ class PtySession {
|
|
|
69
98
|
onExit(event);
|
|
70
99
|
});
|
|
71
100
|
}
|
|
101
|
+
onReady?.();
|
|
72
102
|
return this;
|
|
73
103
|
}
|
|
74
104
|
write(data) {
|
package/dist/tui/api.js
CHANGED
|
@@ -19,6 +19,16 @@ 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() {
|
|
23
|
+
if (process.platform !== 'win32')
|
|
24
|
+
return false;
|
|
25
|
+
const broker = await import('../connector/admin-broker.js');
|
|
26
|
+
return broker.adminBrokerStatus();
|
|
27
|
+
}
|
|
28
|
+
export async function configureAdminBroker(action, t) {
|
|
29
|
+
const setup = await import('../connector/admin-broker-setup.js');
|
|
30
|
+
return setup.configureAdminBroker(action, t);
|
|
31
|
+
}
|
|
22
32
|
/** The command that starts a matching relay, ready to paste on the server. */
|
|
23
33
|
export function relayStartCommand(config, password) {
|
|
24
34
|
const publicUrl = config.relay.remoteUrl
|
|
@@ -1,13 +1,31 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { useState } from 'react';
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useEffect, useState } from 'react';
|
|
3
3
|
import { Box, Text, useInput } from 'ink';
|
|
4
4
|
import { theme } from '../theme.js';
|
|
5
5
|
import { Message, Panel, Row, Selectable, StatusDot } from '../components/common.js';
|
|
6
6
|
import { ChoiceList } from './Relay.js';
|
|
7
|
-
import { KEEPALIVE_MANAGERS, keepalive, saveDraft, setField } from '../api.js';
|
|
7
|
+
import { KEEPALIVE_MANAGERS, adminBrokerStatus, configureAdminBroker, keepalive, saveDraft, setField, } from '../api.js';
|
|
8
8
|
export function Keepalive({ ctx }) {
|
|
9
9
|
const { t, status, draft, editingId } = ctx;
|
|
10
10
|
const [selected, setSelected] = useState('install');
|
|
11
|
+
const [brokerReady, setBrokerReady] = useState(null);
|
|
12
|
+
useEffect(() => {
|
|
13
|
+
if (process.platform !== 'win32')
|
|
14
|
+
return undefined;
|
|
15
|
+
let cancelled = false;
|
|
16
|
+
adminBrokerStatus()
|
|
17
|
+
.then((available) => {
|
|
18
|
+
if (!cancelled)
|
|
19
|
+
setBrokerReady(available);
|
|
20
|
+
})
|
|
21
|
+
.catch(() => {
|
|
22
|
+
if (!cancelled)
|
|
23
|
+
setBrokerReady(false);
|
|
24
|
+
});
|
|
25
|
+
return () => {
|
|
26
|
+
cancelled = true;
|
|
27
|
+
};
|
|
28
|
+
}, []);
|
|
11
29
|
const current = status?.keepalive;
|
|
12
30
|
const entries = [
|
|
13
31
|
{ id: 'install', label: t('keepalive.install') },
|
|
@@ -17,12 +35,38 @@ export function Keepalive({ ctx }) {
|
|
|
17
35
|
...(process.platform === 'linux' && current?.manager === 'systemd' && !current?.linger
|
|
18
36
|
? [{ id: 'linger', label: t('keepalive.enableLinger') }]
|
|
19
37
|
: []),
|
|
38
|
+
...(process.platform === 'win32'
|
|
39
|
+
? [
|
|
40
|
+
{ id: 'adminBrokerInstall', label: t('adminBroker.install') },
|
|
41
|
+
{ id: 'adminBrokerUninstall', label: t('adminBroker.uninstall') },
|
|
42
|
+
]
|
|
43
|
+
: []),
|
|
20
44
|
];
|
|
21
45
|
const activate = (id) => {
|
|
22
46
|
if (id === 'manager') {
|
|
23
47
|
ctx.setEditing('keepaliveManager');
|
|
24
48
|
return;
|
|
25
49
|
}
|
|
50
|
+
if (id === 'adminBrokerInstall' || id === 'adminBrokerUninstall') {
|
|
51
|
+
const action = id === 'adminBrokerInstall' ? 'install' : 'uninstall';
|
|
52
|
+
ctx.run(async () => {
|
|
53
|
+
let ready = false;
|
|
54
|
+
try {
|
|
55
|
+
await configureAdminBroker(action, t);
|
|
56
|
+
ready = action === 'install' && (await adminBrokerStatus());
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
if (error.code === 'ELEVATION_CANCELLED') {
|
|
60
|
+
ctx.notify((t) => t('adminBroker.elevationCancelled'), 'info');
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
setBrokerReady(ready);
|
|
66
|
+
ctx.notify((t) => t(`adminBroker.${action}Done`), 'success');
|
|
67
|
+
});
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
26
70
|
ctx.run(async () => {
|
|
27
71
|
if (id === 'install') {
|
|
28
72
|
if (keepalive.detectManager(ctx.config.keepalive.manager) === 'windows-service')
|
|
@@ -88,8 +132,10 @@ export function Keepalive({ ctx }) {
|
|
|
88
132
|
? t('common.running')
|
|
89
133
|
: current?.installed
|
|
90
134
|
? t('common.installed')
|
|
91
|
-
: t('common.notInstalled')] })] }) }),
|
|
135
|
+
: t('common.notInstalled')] })] }) }), process.platform === 'win32' ? (_jsxs(_Fragment, { children: [_jsx(Row, { label: t('adminBroker.title'), children: _jsx(Text, { color: brokerReady ? theme.ok : theme.warn, children: brokerReady === null
|
|
136
|
+
? t('adminBroker.checking')
|
|
137
|
+
: t(brokerReady ? 'adminBroker.ready' : 'adminBroker.notReady') }) }), _jsx(Text, { color: theme.muted, children: t('adminBroker.tuiHint') })] })) : null, current?.unitPath ? (_jsx(Row, { label: t('keepalive.unitPath'), children: _jsx(Text, { color: theme.muted, children: current.unitPath }) })) : null, _jsx(Box, { marginTop: 1, flexDirection: "column", children: entries.map((entry) => (_jsx(Selectable, { selected: selected === entry.id, onSelect: () => {
|
|
92
138
|
setSelected(entry.id);
|
|
93
139
|
activate(entry.id);
|
|
94
|
-
}, onHover: () => setSelected(entry.id), children: entry.label }, entry.id))) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [current?.manager === 'systemd' ? (_jsx(Text, { color: current.linger ? theme.muted : theme.warn, children: current.linger ? t('keepalive.lingerEnabled') : t('keepalive.lingerDisabled') })) : null, current?.manager === 'supervisor' ? (_jsx(Text, { color: theme.warn, children: t('keepalive.fallbackNote') })) : null, current?.manager === 'windows-service' ? (_jsx(Text, { color: theme.warn, children: t('keepalive.serviceNote') })) : null, _jsx(Text, { color: theme.muted, children: t('keepalive.logsHint', { command: status?.logsHint ?? '' }) })] }), _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
|
|
140
|
+
}, onHover: () => setSelected(entry.id), children: entry.label }, entry.id))) }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [current?.manager === 'systemd' ? (_jsx(Text, { color: current.linger ? theme.muted : theme.warn, children: current.linger ? t('keepalive.lingerEnabled') : t('keepalive.lingerDisabled') })) : null, current?.manager === 'supervisor' ? (_jsx(Text, { color: theme.warn, children: t('keepalive.fallbackNote') })) : null, current?.manager === 'windows-task' ? (_jsx(Text, { color: theme.muted, children: t('keepalive.windowsTaskNote') })) : null, current?.manager === 'windows-service' ? (_jsx(Text, { color: theme.warn, children: t('keepalive.serviceNote') })) : null, _jsx(Text, { color: theme.muted, children: t('keepalive.logsHint', { command: status?.logsHint ?? '' }) })] }), _jsx(Message, { text: ctx.message?.text ?? null, level: ctx.message?.level ?? 'info' })] }));
|
|
95
141
|
}
|
package/herdr-plugin.toml
CHANGED