handmux 0.17.7 → 0.18.0
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 +18 -2
- package/README.zh-CN.md +18 -2
- package/bin/handmux.js +25 -1
- package/package.json +6 -5
- package/public/assets/index-CvNuQ-bC.js +330 -0
- package/public/assets/index-DjJf1FX9.css +32 -0
- package/public/index.html +2 -2
- package/src/agents/claude.js +1 -0
- package/src/agents/codex.js +1 -0
- package/src/agents/scanUtils.js +3 -4
- package/src/claudeEvents.js +23 -6
- package/src/cli/i18n/en.js +87 -2
- package/src/cli/i18n/zh.js +87 -2
- package/src/cli/options.js +23 -5
- package/src/cli/prompt.js +1 -1
- package/src/cli/setupModel.js +11 -2
- package/src/cli/setupWizard.js +19 -18
- package/src/cli/shortcutEditor.js +288 -0
- package/src/cli/supervisor.js +1 -0
- package/src/cli/workspaceCmd.js +300 -0
- package/src/config.js +3 -0
- package/src/httpApi.js +6 -1
- package/src/internalPreviewGateway.js +487 -0
- package/src/keyNames.js +10 -0
- package/src/orphans.js +3 -2
- package/src/routes/sessions.js +13 -1
- package/src/routes/system.js +13 -2
- package/src/routes/terminal.js +3 -13
- package/src/routes/workspace.js +267 -0
- package/src/server.js +46 -5
- package/src/shortcutConfig.js +75 -0
- package/src/tmux/commands.js +17 -17
- package/src/tmux/format.js +45 -0
- package/src/workspace/agentRunner.js +185 -0
- package/src/workspace/atomicJson.js +32 -0
- package/src/workspace/capture.js +87 -0
- package/src/workspace/checkpointer.js +179 -0
- package/src/workspace/environment.js +75 -0
- package/src/workspace/lock.js +128 -0
- package/src/workspace/mapping.js +124 -0
- package/src/workspace/operations.js +252 -0
- package/src/workspace/paths.js +16 -0
- package/src/workspace/planner.js +247 -0
- package/src/workspace/restore.js +279 -0
- package/src/workspace/runtime.js +314 -0
- package/src/workspace/schema.js +136 -0
- package/src/workspace/store.js +382 -0
- package/src/workspace/tmuxAdapter.js +554 -0
- package/public/assets/index-CiaHwIWj.css +0 -32
- package/public/assets/index-cjqG9l7v.js +0 -330
package/src/cli/i18n/zh.js
CHANGED
|
@@ -12,6 +12,41 @@ export default {
|
|
|
12
12
|
'config.loaded': '配置:{path}',
|
|
13
13
|
'config.none': '(无 —— 使用 flag + 默认值)',
|
|
14
14
|
|
|
15
|
+
// 快捷项编辑器
|
|
16
|
+
'shortcuts.title': '共享常用快捷项',
|
|
17
|
+
'shortcuts.command': '命令模式',
|
|
18
|
+
'shortcuts.chat': '聊天模式',
|
|
19
|
+
'shortcuts.count': '{n} 项',
|
|
20
|
+
'shortcuts.save': '保存并退出',
|
|
21
|
+
'shortcuts.exit': '放弃修改',
|
|
22
|
+
'shortcuts.addKey': '添加按键',
|
|
23
|
+
'shortcuts.addText': '添加文字',
|
|
24
|
+
'shortcuts.back': '返回',
|
|
25
|
+
'shortcuts.edit': '编辑',
|
|
26
|
+
'shortcuts.move': '移动位置',
|
|
27
|
+
'shortcuts.addPositionPrompt': '放到哪里',
|
|
28
|
+
'shortcuts.movePrompt': '选择新位置(当前:第 {n} 位)',
|
|
29
|
+
'shortcuts.positionFirst': '最前(第 1 位)',
|
|
30
|
+
'shortcuts.positionAfter': '放在 {item} 后(第 {n} 位)',
|
|
31
|
+
'shortcuts.positionLast': '最后(第 {n} 位)',
|
|
32
|
+
'shortcuts.delete': '删除',
|
|
33
|
+
'shortcuts.type': '快捷项类型',
|
|
34
|
+
'shortcuts.key': '按键',
|
|
35
|
+
'shortcuts.text': '文字',
|
|
36
|
+
'shortcuts.textEnter': '文字 · 发送后回车',
|
|
37
|
+
'shortcuts.textOnly': '文字 · 只输入',
|
|
38
|
+
'shortcuts.textPrompt': '输入文字内容',
|
|
39
|
+
'shortcuts.badText': '请输入一行非空文字',
|
|
40
|
+
'shortcuts.enter': '文字后敲 Enter?',
|
|
41
|
+
'shortcuts.modifier': '修饰键',
|
|
42
|
+
'shortcuts.noModifier': '无',
|
|
43
|
+
'shortcuts.base': '基础键',
|
|
44
|
+
'shortcuts.needTty': 'handmux shortcuts 需要交互式终端。',
|
|
45
|
+
'shortcuts.wrote': '已保存 {path}',
|
|
46
|
+
'shortcuts.exited': '未保存修改',
|
|
47
|
+
'shortcuts.applied': '已立即应用到正在运行的 handmux。',
|
|
48
|
+
'shortcuts.applyFailed': '配置已保存,但无法立即应用:{msg}。请运行 `handmux restart`。',
|
|
49
|
+
|
|
15
50
|
// ssh 预检
|
|
16
51
|
'ssh.confirmSetup': '到 {host} 的免密 SSH 还没配置。现在配置吗?',
|
|
17
52
|
'ssh.notSetup': '免密 SSH 未配置 —— 请运行:{bin} setup-key {host}',
|
|
@@ -135,6 +170,7 @@ export default {
|
|
|
135
170
|
'setup.choose': '选择 1-6',
|
|
136
171
|
'setup.invalid': '无效选择',
|
|
137
172
|
'setup.askPort': '服务端口',
|
|
173
|
+
'setup.askPreviewDomain': '预览域名(不要填 http://、https:// 或 *.)',
|
|
138
174
|
'setup.askHostname': '公网域名(例如 handmux.example.com)',
|
|
139
175
|
'setup.askTunnelName': '隧道名称',
|
|
140
176
|
'setup.askSshHost': 'ssh 主机(user@host[:port])',
|
|
@@ -156,6 +192,7 @@ export default {
|
|
|
156
192
|
'setup.secName': '名称',
|
|
157
193
|
'setup.secPort': '端口',
|
|
158
194
|
'setup.secToken': '令牌',
|
|
195
|
+
'setup.secPreview': '动态预览',
|
|
159
196
|
'setup.tokenAuto': '自动 · 每次启动新生成',
|
|
160
197
|
'setup.tokenCustom': '自定义令牌…',
|
|
161
198
|
'setup.tokenRandom': '随机生成一个',
|
|
@@ -168,6 +205,7 @@ export default {
|
|
|
168
205
|
'setup.secPush': '推送',
|
|
169
206
|
'setup.secVoice': '语音',
|
|
170
207
|
'setup.default': '(默认)',
|
|
208
|
+
'setup.previewOff': '未配置',
|
|
171
209
|
'setup.on': '开',
|
|
172
210
|
'setup.off': '关',
|
|
173
211
|
'setup.yes': '是',
|
|
@@ -192,6 +230,7 @@ export default {
|
|
|
192
230
|
'setup.valPort': '端口需为 1–65535 的整数',
|
|
193
231
|
'setup.valRequired': '{label} 不能为空',
|
|
194
232
|
'setup.valHost': '请输入有效域名(如 myapp.example.com)',
|
|
233
|
+
'setup.valPreviewDomain': '只填域名,如 preview.example.com;不要填 http://、https://、端口或 *.',
|
|
195
234
|
'setup.valContact': '请填 mailto:you@example.com 或 https://你的站点(要真实域名 —— 苹果会拒收假域名)',
|
|
196
235
|
'setup.sumTemp': '临时',
|
|
197
236
|
'setup.sumFixed': '固定',
|
|
@@ -247,20 +286,66 @@ export default {
|
|
|
247
286
|
'setup.sshHelp1': '服务端(一次性):把反向代理指向被转发的 loopback 端口。',
|
|
248
287
|
'setup.sshHelpNginx': ' nginx: proxy_pass http://127.0.0.1:{port}; (加上 client_max_body_size 60m; proxy_read_timeout 90s;)',
|
|
249
288
|
'setup.sshHelpCaddy': ' caddy: {url} { reverse_proxy 127.0.0.1:{port} }',
|
|
250
|
-
'setup.
|
|
251
|
-
'setup.
|
|
289
|
+
'setup.previewAbout': '用于在手机上按端口打开本机正在运行的开发服务;不配置时,静态目录预览仍可使用。',
|
|
290
|
+
'setup.previewRoute': '只填预览域名(如 preview.example.com)。handmux 会使用 <项目名>.preview.example.com;请将 *.preview.example.com 路由到 handmux 网关。',
|
|
291
|
+
'setup.previewHttps': '预览域名本身必须支持 HTTPS;本机开发服务可使用 HTTP 或 HTTPS。',
|
|
252
292
|
'setup.previewTlsCf': ' TLS:Cloudflare 的免费证书只覆盖一级(*.example.com);更深一层(*.preview.example.com)需要 Advanced Certificate Manager。',
|
|
253
293
|
'setup.previewTlsEdge': ' TLS:由你自己的边缘提供通配证书(例如 Let\'s Encrypt 的 *.preview.your.domain)。',
|
|
254
294
|
|
|
295
|
+
// 工作区恢复
|
|
296
|
+
'restore.usage': '用法: handmux restore [--dry-run] [--checkpoint <id>] [--session <name>] | handmux restore --list',
|
|
297
|
+
'restore.badFlag': '未知的恢复参数 {flag}。',
|
|
298
|
+
'restore.unexpectedArgument': '未预期的恢复参数: {value}。',
|
|
299
|
+
'restore.unknownShortFlag': '未知的恢复参数 {flag}。',
|
|
300
|
+
'restore.flagBoolean': '{flag} 不接受参数值。',
|
|
301
|
+
'restore.checkpointValue': '--checkpoint 需要一个 id(或 latest)。',
|
|
302
|
+
'restore.checkpointId': '--checkpoint 必须是有效 id,只能包含字母、数字、点、下划线或连字符。',
|
|
303
|
+
'restore.sessionValue': '--session 需要一个非空会话名。',
|
|
304
|
+
'restore.listExclusive': '--list 必须独占使用,不能与 --dry-run、--checkpoint 或 --session 组合。',
|
|
305
|
+
'restore.listRow': '{id} {time} {sessions} 个会话 · {windows} 个窗口 · {panes} 个窗格 · {agents} 个 agent',
|
|
306
|
+
'restore.listUnavailable': '{id} 不可用: {error}',
|
|
307
|
+
'restore.listEmpty': '目前还没有已归档的工作区备份。',
|
|
308
|
+
'restore.selectCheckpoint': '选择要恢复的工作区备份',
|
|
309
|
+
'restore.selectHint': '{time} · {sessions} 个会话 · {windows} 个窗口 · {panes} 个窗格',
|
|
310
|
+
'restore.noCheckpoint': '没有可用的历史备份。handmux 正在静默保护当前工作区;请在电脑或 tmux 下次重启后再试。',
|
|
311
|
+
'restore.selectionCancelled': '没有选择备份。准备好后可再次运行 `{command}`。',
|
|
312
|
+
'restore.planCheckpoint': '备份: {time}({id};{sessions} 个会话,{windows} 个窗口,{panes} 个窗格)',
|
|
313
|
+
'restore.planCurrent': '当前: {sessions} 个在线会话',
|
|
314
|
+
'restore.planCreate': '+ {session}',
|
|
315
|
+
'restore.planRenamed': '+ {session} -> {target}(名称已被占用)',
|
|
316
|
+
'restore.planAlready': '= {session}(已经恢复)',
|
|
317
|
+
'restore.planUnavailable': '! {session}(不可用: {reason})',
|
|
318
|
+
'restore.reason.linkedWindows': '该备份含有当前版本无法安全恢复的链接窗口',
|
|
319
|
+
'restore.reason.invalidTopology': '该备份的 tmux 拓扑不完整或无效',
|
|
320
|
+
'restore.reason.unknown': '不支持的备份拓扑({reason})',
|
|
321
|
+
'restore.manualRecovery': '请改为手动恢复这个会话: {command}',
|
|
322
|
+
'restore.warning': '警告: {warning}',
|
|
323
|
+
'restore.sessionWarning': ' {session} 警告: {warning}',
|
|
324
|
+
'restore.nonDestructive': '不会停止或修改任何现有会话或进程。',
|
|
325
|
+
'restore.nonDestructivePast': '现有会话没有被修改。',
|
|
326
|
+
'restore.dryRunHint': '运行 `{command}` 继续。',
|
|
327
|
+
'restore.resultRestored': '✓ {session}',
|
|
328
|
+
'restore.resultRenamed': '✓ {session} 已恢复为 {target}',
|
|
329
|
+
'restore.resultAlready': '= {session}(已经恢复)',
|
|
330
|
+
'restore.sessionFailed': '备份 {checkpoint};会话 {session};阶段 {stage}: {error}',
|
|
331
|
+
'restore.operationFailed': '备份 {checkpoint};阶段 {stage}: {error}',
|
|
332
|
+
'restore.retrySession': '只重试这个会话: {command}',
|
|
333
|
+
'restore.resultSummary': '已恢复 {restored} 个会话;{already} 个已存在;{failed} 个失败。',
|
|
334
|
+
'restore.error': '备份 {checkpoint} 在恢复命令阶段失败: {error}',
|
|
335
|
+
'restore.retry': '修复上面报告的原因后,运行 `{command}` 重试。',
|
|
336
|
+
|
|
255
337
|
// help
|
|
256
338
|
'help.body': `handmux —— 从手机驱动你的 tmux
|
|
257
339
|
|
|
258
340
|
handmux start 直接运行(默认仅局域网;无需配置)
|
|
259
341
|
handmux open <会话名> 接入 tmux 会话,不存在则新建——手机上建的也能一键接管
|
|
260
342
|
handmux setup 配置隧道 / 名称 / 通知(写入配置;重跑即可修改)
|
|
343
|
+
handmux shortcuts 配置命令/聊天模式的共享常用快捷项
|
|
261
344
|
handmux stop | restart | status
|
|
262
345
|
handmux logs [--follow] [--lines N]
|
|
263
346
|
handmux push <标题> <正文> 从脚本推一条通知到手机(--session 会话 · --device 设备key · --tag · --url)
|
|
347
|
+
handmux restore [--dry-run] [--checkpoint <id>] [--session <name>]
|
|
348
|
+
handmux restore --list
|
|
264
349
|
handmux update 升级到最新发布版本(npm i -g handmux@latest)
|
|
265
350
|
|
|
266
351
|
第一次用?跑 'handmux setup' —— 它会带你配好外网访问 + 手机通知。
|
package/src/cli/options.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// Resolution order is flags > config file > env > built-in default. The token is ALWAYS materialised
|
|
3
3
|
// (generated when unset) so a public tunnel can never come up token-less.
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
|
+
import { normalizeShortcuts } from '../shortcutConfig.js';
|
|
5
6
|
|
|
6
7
|
export const TUNNELS = ['none', 'cloudflare', 'cloudflare-named', 'ssh', 'natapp', 'cpolar'];
|
|
7
8
|
|
|
@@ -12,17 +13,33 @@ const camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
|
|
12
13
|
export function parseArgs(argv) {
|
|
13
14
|
const [command = 'help', ...rest] = argv;
|
|
14
15
|
const flags = {};
|
|
16
|
+
const positionals = [];
|
|
17
|
+
const unknownShortFlags = [];
|
|
18
|
+
const multiFlags = new Set(['session']);
|
|
19
|
+
const assign = (key, value) => {
|
|
20
|
+
if (!multiFlags.has(key) || flags[key] === undefined) { flags[key] = value; return; }
|
|
21
|
+
flags[key] = Array.isArray(flags[key]) ? [...flags[key], value] : [flags[key], value];
|
|
22
|
+
};
|
|
15
23
|
for (let i = 0; i < rest.length; i++) {
|
|
16
24
|
const a = rest[i];
|
|
17
25
|
if (a === '-f') { flags.foreground = true; continue; }
|
|
18
|
-
if (!a.startsWith('--'))
|
|
26
|
+
if (!a.startsWith('--')) {
|
|
27
|
+
if (a.startsWith('-')) unknownShortFlags.push(a);
|
|
28
|
+
else positionals.push(a);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
19
31
|
const key = a.slice(2);
|
|
20
|
-
if (key.startsWith('no-')) {
|
|
32
|
+
if (key.startsWith('no-')) { assign(camel(key.slice(3)), false); continue; }
|
|
21
33
|
const next = rest[i + 1];
|
|
22
|
-
if (next === undefined || next.startsWith('--')) {
|
|
23
|
-
else {
|
|
34
|
+
if (next === undefined || next.startsWith('--')) { assign(camel(key), true); }
|
|
35
|
+
else { assign(camel(key), next); i++; }
|
|
24
36
|
}
|
|
25
|
-
return {
|
|
37
|
+
return {
|
|
38
|
+
command,
|
|
39
|
+
flags,
|
|
40
|
+
...(positionals.length ? { positionals } : {}),
|
|
41
|
+
...(unknownShortFlags.length ? { unknownShortFlags } : {}),
|
|
42
|
+
};
|
|
26
43
|
}
|
|
27
44
|
|
|
28
45
|
// ssh 回退公网地址:SSH 不生成 URL,无 --public-url 时用 host:remotePort(去掉 user@ 与 :sshPort)。
|
|
@@ -58,6 +75,7 @@ export function resolveConfig(flags = {}, fileCfg = {}, env = process.env, gen =
|
|
|
58
75
|
previewTtl: pick('previewTtl', env.HANDMUX_PREVIEW_TTL) || null,
|
|
59
76
|
vapid: fileCfg.vapid || null, // { public, private, subject } — push notifications
|
|
60
77
|
xfyun: fileCfg.xfyun || null, // { appId, apiKey, apiSecret } — voice input
|
|
78
|
+
shortcuts: normalizeShortcuts(fileCfg.shortcuts),
|
|
61
79
|
// An explicit public URL is honoured for ANY tunnel mode — including 'none', so someone who runs their
|
|
62
80
|
// own tunnel/reverse-proxy can still have handmux advertise (print + QR) their real domain. The
|
|
63
81
|
// tunnel-specific blocks below only fill a *fallback* when it wasn't given.
|
package/src/cli/prompt.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// The ONLY module that touches @clack/prompts — the interactive
|
|
1
|
+
// The ONLY module that touches @clack/prompts — shared by the interactive setup and shortcut editors. Everything else
|
|
2
2
|
// imports from here, so the prompt library stays swappable behind one seam and the wizard's pure logic
|
|
3
3
|
// (configFromAnswers / validators / …) never depends on it.
|
|
4
4
|
import { select, text, password, confirm, note, intro, outro, cancel, isCancel } from '@clack/prompts';
|
package/src/cli/setupModel.js
CHANGED
|
@@ -39,11 +39,11 @@ export function findTunnelId(listJsonOut, name) {
|
|
|
39
39
|
|
|
40
40
|
// The config keys the wizard owns: everything it can set. mergeConfig wipes these from the existing config
|
|
41
41
|
// before re-applying the answers, so switching a tunnel (or clearing an optional field) cleanly drops the
|
|
42
|
-
// old value instead of leaving a stale field behind. Anything NOT here (staticDir,
|
|
42
|
+
// old value instead of leaving a stale field behind. Anything NOT here (staticDir, previewTtl…) is
|
|
43
43
|
// preserved untouched. `token` IS owned so the Token row can pin one AND clear it back to auto — but it
|
|
44
44
|
// round-trips through answersFromConfig, so a re-run that never touches the row still writes it back.
|
|
45
45
|
const WIZARD_KEYS = [
|
|
46
|
-
'lang', 'name', 'port', 'tunnel', 'token',
|
|
46
|
+
'lang', 'name', 'port', 'tunnel', 'token', 'previewDomain',
|
|
47
47
|
'sshHost', 'remotePort', 'sshJump', 'cfHostname', 'cfTunnelName', 'publicUrl',
|
|
48
48
|
'authtoken', 'cpolarRegion',
|
|
49
49
|
'vapid', 'xfyun',
|
|
@@ -58,6 +58,7 @@ export function configFromAnswers(a) {
|
|
|
58
58
|
if (a.lang) cfg.lang = a.lang;
|
|
59
59
|
if (a.name) cfg.name = a.name;
|
|
60
60
|
if (a.token) cfg.token = a.token; // blank = don't pin one → the server mints a fresh token each start
|
|
61
|
+
if (a.previewDomain) cfg.previewDomain = a.previewDomain;
|
|
61
62
|
if (a.tunnel === 'ssh') {
|
|
62
63
|
cfg.sshHost = a.sshHost;
|
|
63
64
|
cfg.remotePort = a.remotePort;
|
|
@@ -94,6 +95,7 @@ export function answersFromConfig(cfg = {}) {
|
|
|
94
95
|
lang: cfg.lang || getLocale(),
|
|
95
96
|
name: cfg.name || '',
|
|
96
97
|
token: cfg.token || '', // '' = not pinned (auto each start); seeded so an untouched re-run rewrites it
|
|
98
|
+
previewDomain: cfg.previewDomain || '',
|
|
97
99
|
tunnel: cfg.tunnel || 'none',
|
|
98
100
|
port: Number(cfg.port) || 19999,
|
|
99
101
|
};
|
|
@@ -125,6 +127,13 @@ export function validateHost(v) {
|
|
|
125
127
|
const s = String(v || '').trim().replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
126
128
|
return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(s) ? undefined : t('setup.valHost');
|
|
127
129
|
}
|
|
130
|
+
// Dynamic previews use `https://<name>.<previewDomain>/`; only the base domain belongs in config. Blank
|
|
131
|
+
// deliberately disables the optional feature, while a URL / wildcard would produce a broken host name.
|
|
132
|
+
export function validatePreviewDomain(v) {
|
|
133
|
+
const s = String(v || '').trim();
|
|
134
|
+
if (!s) return undefined;
|
|
135
|
+
return /^[a-z0-9-]+(\.[a-z0-9-]+)+$/i.test(s) ? undefined : t('setup.valPreviewDomain');
|
|
136
|
+
}
|
|
128
137
|
// VAPID subject: Apple (APNs) rejects a fake/.local domain with BadJwtToken, so require a real-looking
|
|
129
138
|
// mailto:you@host.tld or an https:// URL and reject the known-bad .local. Keeps push from silently
|
|
130
139
|
// failing on iOS. (Can't fully validate "real" client-side — this just catches the obvious footguns.)
|
package/src/cli/setupWizard.js
CHANGED
|
@@ -23,13 +23,13 @@ import { intro, outro, note, cancel, select, text, password, confirm, ask, CANCE
|
|
|
23
23
|
import {
|
|
24
24
|
cfConfigYaml, parseTunnelCreate, findTunnelId,
|
|
25
25
|
mergeConfig, answersFromConfig, summarizeConnection,
|
|
26
|
-
validatePort, validateHost, validateNonEmpty, validateContact, validateToken,
|
|
26
|
+
validatePort, validateHost, validatePreviewDomain, validateNonEmpty, validateContact, validateToken,
|
|
27
27
|
TUNNEL_KEYS,
|
|
28
28
|
} from './setupModel.js';
|
|
29
29
|
export {
|
|
30
30
|
cfConfigYaml, parseTunnelCreate, findTunnelId,
|
|
31
31
|
configFromAnswers, mergeConfig, answersFromConfig, summarizeConnection,
|
|
32
|
-
validatePort, validateHost, validateNonEmpty, validateContact, validateToken,
|
|
32
|
+
validatePort, validateHost, validatePreviewDomain, validateNonEmpty, validateContact, validateToken,
|
|
33
33
|
} from './setupModel.js';
|
|
34
34
|
|
|
35
35
|
function readExisting(file) {
|
|
@@ -73,6 +73,7 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
|
|
|
73
73
|
{ value: 'name', label: t('setup.secName'), hint: a.name || t('setup.default') },
|
|
74
74
|
{ value: 'port', label: t('setup.secPort'), hint: String(a.port) },
|
|
75
75
|
{ value: 'token', label: t('setup.secToken'), hint: a.token ? maskSecret(a.token) : t('setup.tokenAuto') },
|
|
76
|
+
{ value: 'preview', label: t('setup.secPreview'), hint: a.previewDomain || t('setup.previewOff') },
|
|
76
77
|
{ value: 'push', label: t('setup.secPush'), hint: a.vapid ? (a.vapid.subject || t('setup.on')) : t('setup.off') },
|
|
77
78
|
{ value: 'voice', label: t('setup.secVoice'), hint: a.xfyun ? (a.xfyun.appId || t('setup.on')) : t('setup.off') },
|
|
78
79
|
// A CLI-tool preference (language of handmux's own terminal output), not an app setting — so it
|
|
@@ -91,7 +92,6 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
|
|
|
91
92
|
fs.writeFileSync(target, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
|
|
92
93
|
outro(t('setup.wrote', { path: target }));
|
|
93
94
|
if (a.tunnel === 'ssh') printSshServerHelp(a, log);
|
|
94
|
-
if (a.tunnel === 'cloudflare-named' || a.tunnel === 'ssh') printPreviewHelp(a.tunnel, log);
|
|
95
95
|
return { cfg, start: choice === 'start' };
|
|
96
96
|
}
|
|
97
97
|
cursor = choice; // remember the row so returning from an edit re-highlights it
|
|
@@ -101,6 +101,7 @@ export async function runSetup({ home = homedir(), target = configPath(home), lo
|
|
|
101
101
|
else if (choice === 'name') a.name = await editName(a);
|
|
102
102
|
else if (choice === 'port') a.port = await editPort(a);
|
|
103
103
|
else if (choice === 'token') a.token = await editToken(a);
|
|
104
|
+
else if (choice === 'preview') a.previewDomain = await editPreviewDomain(a);
|
|
104
105
|
else if (choice === 'language') a.lang = await editLanguage(a);
|
|
105
106
|
else if (choice === 'push') a.vapid = await editPush(a);
|
|
106
107
|
else if (choice === 'voice') a.xfyun = await editVoice(a);
|
|
@@ -139,6 +140,21 @@ async function editPort(a) {
|
|
|
139
140
|
return Number(v);
|
|
140
141
|
}
|
|
141
142
|
|
|
143
|
+
async function editPreviewDomain(a) {
|
|
144
|
+
note(t('setup.previewAbout'));
|
|
145
|
+
note(t('setup.previewRoute'));
|
|
146
|
+
note(t('setup.previewHttps'));
|
|
147
|
+
if (a.tunnel === 'cloudflare-named') note(t('setup.previewTlsCf'));
|
|
148
|
+
else if (a.tunnel === 'ssh') note(t('setup.previewTlsEdge'));
|
|
149
|
+
const value = await ask(text({
|
|
150
|
+
message: withBack(t('setup.askPreviewDomain')),
|
|
151
|
+
placeholder: 'preview.example.com',
|
|
152
|
+
initialValue: a.previewDomain || '',
|
|
153
|
+
validate: validatePreviewDomain,
|
|
154
|
+
}));
|
|
155
|
+
return String(value || '').trim().toLowerCase();
|
|
156
|
+
}
|
|
157
|
+
|
|
142
158
|
// The access token — the one secret in the phone's URL. Unset = the server mints a fresh one each start
|
|
143
159
|
// (printed + QR'd), so the link changes every restart; pinning one keeps the same URL. A mini-hub (like
|
|
144
160
|
// push/voice): type your own, generate + pin a strong random one, or reset back to auto. Editing custom
|
|
@@ -492,18 +508,3 @@ function printSshServerHelp(a, log) {
|
|
|
492
508
|
log.log(t('setup.sshHelpCaddy', { url: a.publicUrl || '<your-domain>', port: a.remotePort }));
|
|
493
509
|
log.log('');
|
|
494
510
|
}
|
|
495
|
-
|
|
496
|
-
// FYI on dynamic port preview: it's optional and NOT wired by this wizard (separate wildcard domain). Print
|
|
497
|
-
// the requirement + a TLS note that fits the chosen tunnel — Cloudflare's free cert only covers one level
|
|
498
|
-
// (so deeper needs ACM), whereas on the ssh/own-edge path the user serves their own wildcard cert. Shown
|
|
499
|
-
// only for wildcard-capable tunnels (a quick tunnel can't do wildcards at all).
|
|
500
|
-
function printPreviewHelp(tunnel, log) {
|
|
501
|
-
log.log(t('setup.previewHelp1'));
|
|
502
|
-
log.log(t('setup.previewHelp2'));
|
|
503
|
-
if (tunnel === 'cloudflare-named') {
|
|
504
|
-
log.log(t('setup.previewTlsCf'));
|
|
505
|
-
} else {
|
|
506
|
-
log.log(t('setup.previewTlsEdge'));
|
|
507
|
-
}
|
|
508
|
-
log.log('');
|
|
509
|
-
}
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { normalizeShortcuts, shortcutIdentity } from '../shortcutConfig.js';
|
|
4
|
+
import { t } from './i18n/index.js';
|
|
5
|
+
import * as prompts from './prompt.js';
|
|
6
|
+
import { acquireLifecycleLock, isAlive, readState } from './state.js';
|
|
7
|
+
|
|
8
|
+
const MODIFIERS = {
|
|
9
|
+
none: { prefixes: [], labels: [] },
|
|
10
|
+
ctrl: { prefixes: ['C-'], labels: ['Ctrl'] },
|
|
11
|
+
shift: { prefixes: ['S-'], labels: ['Shift'] },
|
|
12
|
+
alt: { prefixes: ['M-'], labels: ['Alt'] },
|
|
13
|
+
'ctrl-shift': { prefixes: ['C-', 'S-'], labels: ['Ctrl', 'Shift'] },
|
|
14
|
+
'ctrl-alt': { prefixes: ['C-', 'M-'], labels: ['Ctrl', 'Alt'] },
|
|
15
|
+
};
|
|
16
|
+
const NAMED_BASES = ['Up', 'Down', 'Left', 'Right', 'Tab', 'Enter', 'Escape', 'Space', 'BSpace', 'Home', 'End', 'PageUp', 'PageDown'];
|
|
17
|
+
const CHAR_BASES = [...'abcdefghijklmnopqrstuvwxyz', ...'0123456789'];
|
|
18
|
+
const DISPLAY = { Escape: 'Esc', BSpace: '⌫', PageUp: 'PgUp', PageDown: 'PgDn' };
|
|
19
|
+
|
|
20
|
+
export function buildShortcutKey(modifier, base) {
|
|
21
|
+
const mod = MODIFIERS[modifier];
|
|
22
|
+
if (!mod) throw new Error(`unknown modifier: ${modifier}`);
|
|
23
|
+
if (!NAMED_BASES.includes(base) && !CHAR_BASES.includes(base)) throw new Error(`unsupported base key: ${base}`);
|
|
24
|
+
if (CHAR_BASES.includes(base) && modifier === 'none') throw new Error('a character key needs a modifier');
|
|
25
|
+
if (CHAR_BASES.includes(base) && mod.prefixes.includes('S-')) throw new Error('Shift + character is not a distinct tmux key');
|
|
26
|
+
const key = base === 'Tab' && modifier === 'shift'
|
|
27
|
+
? 'BTab'
|
|
28
|
+
: `${mod.prefixes.join('')}${CHAR_BASES.includes(base) ? base.toLowerCase() : base}`;
|
|
29
|
+
const shownBase = CHAR_BASES.includes(base) ? base.toUpperCase() : (DISPLAY[base] || base);
|
|
30
|
+
return { type: 'key', key, label: [...mod.labels, shownBase].join('+') };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function moveShortcut(items, index, target) {
|
|
34
|
+
if (index < 0 || target < 0 || index >= items.length || target >= items.length || target === index) return items;
|
|
35
|
+
const next = items.slice();
|
|
36
|
+
const [item] = next.splice(index, 1);
|
|
37
|
+
next.splice(target, 0, item);
|
|
38
|
+
return next;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function readExisting(target) {
|
|
42
|
+
try { return JSON.parse(fs.readFileSync(target, 'utf8')); }
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (error.code === 'ENOENT') return {};
|
|
45
|
+
throw error;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function saveShortcutConfig(target, shortcuts) {
|
|
50
|
+
const existing = readExisting(target);
|
|
51
|
+
const cfg = { ...existing, shortcuts: normalizeShortcuts(shortcuts) };
|
|
52
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
53
|
+
const tmp = `${target}.tmp`;
|
|
54
|
+
fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
|
|
55
|
+
fs.renameSync(tmp, target);
|
|
56
|
+
fs.chmodSync(target, 0o600);
|
|
57
|
+
return cfg;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const defaultUi = {
|
|
61
|
+
intro: prompts.intro,
|
|
62
|
+
outro: prompts.outro,
|
|
63
|
+
cancel: prompts.cancel,
|
|
64
|
+
select: prompts.select,
|
|
65
|
+
text: prompts.text,
|
|
66
|
+
confirm: prompts.confirm,
|
|
67
|
+
ask: prompts.ask,
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const modeLabel = (mode) => t(mode === 'command' ? 'shortcuts.command' : 'shortcuts.chat');
|
|
71
|
+
const itemLabel = (item) => item.type === 'key' ? item.label : item.text;
|
|
72
|
+
const itemHint = (item) => item.type === 'key'
|
|
73
|
+
? t('shortcuts.key')
|
|
74
|
+
: t(item.enter ? 'shortcuts.textEnter' : 'shortcuts.textOnly');
|
|
75
|
+
const positionOptions = (items, index = null) => {
|
|
76
|
+
const remaining = index === null ? items : items.filter((_item, i) => i !== index);
|
|
77
|
+
const count = remaining.length + 1;
|
|
78
|
+
if (remaining.length === 0) return [{ value: 0, label: t('shortcuts.positionLast', { n: 1 }) }];
|
|
79
|
+
return Array.from({ length: count }, (_item, target) => {
|
|
80
|
+
if (target === 0) return { value: target, label: t('shortcuts.positionFirst') };
|
|
81
|
+
if (target === count - 1) return { value: target, label: t('shortcuts.positionLast', { n: count }) };
|
|
82
|
+
return {
|
|
83
|
+
value: target,
|
|
84
|
+
label: t('shortcuts.positionAfter', { n: target + 1, item: itemLabel(remaining[target - 1]) }),
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
const validateText = (value) => {
|
|
89
|
+
if (typeof value !== 'string' || !value.length || /[\r\n\x00-\x1f\x7f]/.test(value)) return t('shortcuts.badText');
|
|
90
|
+
return undefined;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function parseShortcutKey(item) {
|
|
94
|
+
if (!item || item.type !== 'key') return { modifier: 'none', base: 'Escape' };
|
|
95
|
+
if (item.key === 'BTab') return { modifier: 'shift', base: 'Tab' };
|
|
96
|
+
let key = item.key;
|
|
97
|
+
const ctrl = key.startsWith('C-'); if (ctrl) key = key.slice(2);
|
|
98
|
+
const alt = key.startsWith('M-'); if (alt) key = key.slice(2);
|
|
99
|
+
const shift = key.startsWith('S-'); if (shift) key = key.slice(2);
|
|
100
|
+
const modifier = ctrl && alt ? 'ctrl-alt' : ctrl && shift ? 'ctrl-shift'
|
|
101
|
+
: ctrl ? 'ctrl' : alt ? 'alt' : shift ? 'shift' : 'none';
|
|
102
|
+
return { modifier, base: key };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const modifierOptions = () => Object.entries(MODIFIERS).map(([value, item]) => ({
|
|
106
|
+
value, label: item.labels.length ? item.labels.join('+') : t('shortcuts.noModifier'),
|
|
107
|
+
}));
|
|
108
|
+
const baseOptions = (modifier) => {
|
|
109
|
+
const allowChars = modifier !== 'none' && !MODIFIERS[modifier].prefixes.includes('S-');
|
|
110
|
+
return [...NAMED_BASES, ...(allowChars ? CHAR_BASES : [])].map((value) => ({
|
|
111
|
+
value, label: DISPLAY[value] || value.toUpperCase?.() || value,
|
|
112
|
+
}));
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
async function editItem(mode, seed, ui, forcedType = null) {
|
|
116
|
+
const type = forcedType || await ui.ask(ui.select({
|
|
117
|
+
message: t('shortcuts.type'), initialValue: seed?.type || 'text',
|
|
118
|
+
options: [
|
|
119
|
+
{ value: 'text', label: t('shortcuts.text') },
|
|
120
|
+
{ value: 'key', label: t('shortcuts.key') },
|
|
121
|
+
],
|
|
122
|
+
}));
|
|
123
|
+
if (type === 'text') {
|
|
124
|
+
const text = await ui.ask(ui.text({
|
|
125
|
+
message: t('shortcuts.textPrompt'), initialValue: seed?.type === 'text' ? seed.text : '', validate: validateText,
|
|
126
|
+
}));
|
|
127
|
+
const enter = await ui.ask(ui.confirm({
|
|
128
|
+
message: t('shortcuts.enter'), initialValue: seed?.type === 'text' ? seed.enter : mode === 'chat',
|
|
129
|
+
}));
|
|
130
|
+
return { type: 'text', text, enter };
|
|
131
|
+
}
|
|
132
|
+
const parsed = parseShortcutKey(seed);
|
|
133
|
+
const modifier = await ui.ask(ui.select({
|
|
134
|
+
message: t('shortcuts.modifier'), initialValue: parsed.modifier, options: modifierOptions(),
|
|
135
|
+
}));
|
|
136
|
+
const available = baseOptions(modifier);
|
|
137
|
+
const base = await ui.ask(ui.select({
|
|
138
|
+
message: t('shortcuts.base'),
|
|
139
|
+
initialValue: available.some((option) => option.value === parsed.base) ? parsed.base : available[0].value,
|
|
140
|
+
options: available,
|
|
141
|
+
}));
|
|
142
|
+
return buildShortcutKey(modifier, base);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function editMode(mode, initial, ui) {
|
|
146
|
+
let items = initial.slice();
|
|
147
|
+
for (;;) {
|
|
148
|
+
const choice = await ui.ask(ui.select({
|
|
149
|
+
message: modeLabel(mode),
|
|
150
|
+
options: [
|
|
151
|
+
...items.map((item, index) => ({ value: `item:${index}`, label: itemLabel(item), hint: itemHint(item) })),
|
|
152
|
+
{ value: 'add-key', label: t('shortcuts.addKey') },
|
|
153
|
+
{ value: 'add-text', label: t('shortcuts.addText') },
|
|
154
|
+
{ value: 'back', label: t('shortcuts.back') },
|
|
155
|
+
],
|
|
156
|
+
}));
|
|
157
|
+
if (choice === 'back') return items;
|
|
158
|
+
if (choice === 'add-key' || choice === 'add-text') {
|
|
159
|
+
const item = await editItem(mode, null, ui, choice === 'add-key' ? 'key' : 'text');
|
|
160
|
+
if (!items.some((existing) => shortcutIdentity(existing) === shortcutIdentity(item))) {
|
|
161
|
+
const target = await ui.ask(ui.select({
|
|
162
|
+
message: t('shortcuts.addPositionPrompt'), options: positionOptions(items),
|
|
163
|
+
}));
|
|
164
|
+
items = moveShortcut([...items, item], items.length, target);
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const index = Number(choice.slice(5));
|
|
169
|
+
const action = await ui.ask(ui.select({
|
|
170
|
+
message: itemLabel(items[index]),
|
|
171
|
+
options: [
|
|
172
|
+
{ value: 'edit', label: t('shortcuts.edit') },
|
|
173
|
+
...(items.length > 1 ? [{ value: 'move', label: t('shortcuts.move') }] : []),
|
|
174
|
+
{ value: 'delete', label: t('shortcuts.delete') },
|
|
175
|
+
{ value: 'back', label: t('shortcuts.back') },
|
|
176
|
+
],
|
|
177
|
+
}));
|
|
178
|
+
if (action === 'edit') {
|
|
179
|
+
const edited = await editItem(mode, items[index], ui);
|
|
180
|
+
if (!items.some((item, i) => i !== index && shortcutIdentity(item) === shortcutIdentity(edited))) {
|
|
181
|
+
items = items.map((item, i) => i === index ? edited : item);
|
|
182
|
+
}
|
|
183
|
+
} else if (action === 'move') {
|
|
184
|
+
const target = await ui.ask(ui.select({
|
|
185
|
+
message: t('shortcuts.movePrompt', { n: index + 1 }), options: positionOptions(items, index),
|
|
186
|
+
}));
|
|
187
|
+
items = moveShortcut(items, index, target);
|
|
188
|
+
} else if (action === 'delete') items = items.filter((_item, i) => i !== index);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export async function runShortcutEditor({
|
|
193
|
+
target, log = console, isTTY = process.stdin.isTTY, ui = defaultUi,
|
|
194
|
+
commit = async (file, shortcuts) => ({ cfg: saveShortcutConfig(file, shortcuts) }),
|
|
195
|
+
} = {}) {
|
|
196
|
+
if (!isTTY) { log.error(t('shortcuts.needTty')); return { error: 'non-tty' }; }
|
|
197
|
+
const existing = readExisting(target);
|
|
198
|
+
let shortcuts = normalizeShortcuts(existing.shortcuts);
|
|
199
|
+
ui.intro('handmux shortcuts');
|
|
200
|
+
try {
|
|
201
|
+
for (;;) {
|
|
202
|
+
const choice = await ui.ask(ui.select({
|
|
203
|
+
message: t('shortcuts.title'),
|
|
204
|
+
options: [
|
|
205
|
+
{ value: 'command', label: modeLabel('command'), hint: t('shortcuts.count', { n: shortcuts.command.length }) },
|
|
206
|
+
{ value: 'chat', label: modeLabel('chat'), hint: t('shortcuts.count', { n: shortcuts.chat.length }) },
|
|
207
|
+
{ value: 'save', label: t('shortcuts.save') },
|
|
208
|
+
{ value: 'exit', label: t('shortcuts.exit') },
|
|
209
|
+
],
|
|
210
|
+
}));
|
|
211
|
+
if (choice === 'exit') { ui.cancel(t('shortcuts.exited')); return null; }
|
|
212
|
+
if (choice === 'command' || choice === 'chat') {
|
|
213
|
+
shortcuts = { ...shortcuts, [choice]: await editMode(choice, shortcuts[choice], ui) };
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const result = await commit(target, shortcuts);
|
|
217
|
+
ui.outro(t('shortcuts.wrote', { path: target }));
|
|
218
|
+
return result;
|
|
219
|
+
}
|
|
220
|
+
} catch (error) {
|
|
221
|
+
if (error === prompts.CANCELLED) { ui.cancel(t('shortcuts.exited')); return null; }
|
|
222
|
+
throw error;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export async function applyShortcutsLive({
|
|
227
|
+
state, shortcuts, fetchImpl = globalThis.fetch, timeoutMs = 8000,
|
|
228
|
+
}) {
|
|
229
|
+
if (!state?.localUrl || !state?.token) throw new Error('running server state is incomplete');
|
|
230
|
+
const base = state.localUrl.replace(/\/$/, '');
|
|
231
|
+
const controller = new AbortController();
|
|
232
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
233
|
+
try {
|
|
234
|
+
const response = await fetchImpl(`${base}/api/config/shortcuts`, {
|
|
235
|
+
method: 'PUT',
|
|
236
|
+
redirect: 'manual',
|
|
237
|
+
signal: controller.signal,
|
|
238
|
+
headers: { Authorization: `Bearer ${state.token}`, 'Content-Type': 'application/json' },
|
|
239
|
+
body: JSON.stringify({ shortcuts }),
|
|
240
|
+
});
|
|
241
|
+
if (!response.ok) throw new Error(`server returned HTTP ${response.status}`);
|
|
242
|
+
let acknowledgment;
|
|
243
|
+
try { acknowledgment = await response.json(); } catch { /* validated below */ }
|
|
244
|
+
if (acknowledgment?.ok !== true) throw new Error('invalid server response');
|
|
245
|
+
} catch (error) {
|
|
246
|
+
if (controller.signal.aborted) throw new Error(`server request timed out after ${timeoutMs}ms`);
|
|
247
|
+
throw error;
|
|
248
|
+
} finally {
|
|
249
|
+
clearTimeout(timer);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Keep the durable write and the runtime replacement in one short cross-process critical section.
|
|
254
|
+
// Otherwise two editors can interleave their save/PUT calls and leave disk and memory disagreeing.
|
|
255
|
+
export async function commitShortcuts({
|
|
256
|
+
home, target, shortcuts,
|
|
257
|
+
acquireLock = acquireLifecycleLock,
|
|
258
|
+
readStateImpl = readState,
|
|
259
|
+
isAliveImpl = isAlive,
|
|
260
|
+
saveImpl = saveShortcutConfig,
|
|
261
|
+
fetchImpl = globalThis.fetch,
|
|
262
|
+
timeoutMs = 8000,
|
|
263
|
+
}) {
|
|
264
|
+
const release = acquireLock(home);
|
|
265
|
+
try {
|
|
266
|
+
const cfg = saveImpl(target, shortcuts);
|
|
267
|
+
const state = readStateImpl(home);
|
|
268
|
+
if (!state || !isAliveImpl(state.supervisorPid)) return { cfg, running: false, applied: false };
|
|
269
|
+
try {
|
|
270
|
+
await applyShortcutsLive({ state, shortcuts: cfg.shortcuts, fetchImpl, timeoutMs });
|
|
271
|
+
return { cfg, running: true, applied: true };
|
|
272
|
+
} catch (error) {
|
|
273
|
+
return { cfg, running: true, applied: false, error };
|
|
274
|
+
}
|
|
275
|
+
} finally {
|
|
276
|
+
release();
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function reportShortcutCommit(result, output = console) {
|
|
281
|
+
if (!result.running) return 0;
|
|
282
|
+
if (result.applied) {
|
|
283
|
+
output.log(t('shortcuts.applied'));
|
|
284
|
+
return 0;
|
|
285
|
+
}
|
|
286
|
+
output.error(t('shortcuts.applyFailed', { msg: result.error?.message || 'unknown error' }));
|
|
287
|
+
return 1;
|
|
288
|
+
}
|
package/src/cli/supervisor.js
CHANGED
|
@@ -95,6 +95,7 @@ export function supervise(cfg, { home, log = console } = {}) {
|
|
|
95
95
|
if (cfg.staticDir) env.HANDMUX_STATIC_DIR = cfg.staticDir;
|
|
96
96
|
if (cfg.uploadExts) env.HANDMUX_UPLOAD_EXTS = cfg.uploadExts;
|
|
97
97
|
if (cfg.previewTtl) env.HANDMUX_PREVIEW_TTL = String(cfg.previewTtl);
|
|
98
|
+
env.HANDMUX_SHORTCUTS = JSON.stringify(cfg.shortcuts);
|
|
98
99
|
if (cfg.vapid) {
|
|
99
100
|
if (cfg.vapid.public) env.VAPID_PUBLIC = cfg.vapid.public;
|
|
100
101
|
if (cfg.vapid.private) env.VAPID_PRIVATE = cfg.vapid.private;
|