cc-viewer 1.6.300 → 1.6.302
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/cli.js +26 -14
- package/dist/assets/{App-DO_8O7_4.js → App-COezHOeh.js} +1 -1
- package/dist/assets/{MdxEditorPanel-kMkQ3-Hd.js → MdxEditorPanel-BkJKua3-.js} +1 -1
- package/dist/assets/{Mobile-DYGGuCPh.js → Mobile-BtNceCJC.js} +1 -1
- package/dist/assets/index-DSz9bNGm.js +2 -0
- package/dist/assets/seqResourceLoaders-C8gBbhlC.js +2 -0
- package/dist/index.html +1 -1
- package/package.json +1 -1
- package/server/i18n.js +56 -36
- package/server/lib/base-path.js +34 -0
- package/server/lib/log-watcher.js +7 -1
- package/server/lib/pty-flood-coalescer.js +191 -0
- package/server/lib/term-signals.js +80 -0
- package/server/pty-manager.js +13 -2
- package/server/routes/files-fs.js +6 -2
- package/server/routes/misc.js +5 -1
- package/server/routes/preferences.js +29 -9
- package/server/scratch-pty-manager.js +6 -1
- package/server/server.js +113 -41
- package/dist/assets/index-BXb1GO0R.js +0 -2
- package/dist/assets/seqResourceLoaders-BJ_EK_tf.js +0 -2
package/dist/index.html
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// 整体显示大小已弃用 CSS zoom:Electron 改用 webFrame.setZoomFactor(首屏抢占见
|
|
22
22
|
// electron/tab-content-preload.js),纯浏览器交由用户用浏览器自带快捷键缩放,故此处不再设 zoom。
|
|
23
23
|
</script>
|
|
24
|
-
<script type="module" crossorigin src="/assets/index-
|
|
24
|
+
<script type="module" crossorigin src="/assets/index-DSz9bNGm.js"></script>
|
|
25
25
|
<link rel="modulepreload" crossorigin href="/assets/vendor-antd-Bur5ZxWE.js">
|
|
26
26
|
<link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-Si44UqBp.js">
|
|
27
27
|
<link rel="modulepreload" crossorigin href="/assets/vendor-mdxeditor-Cco3AQJS.js">
|
package/package.json
CHANGED
package/server/i18n.js
CHANGED
|
@@ -448,44 +448,44 @@ const i18nData = {
|
|
|
448
448
|
"uk": "\nCC Viewer запущено:"
|
|
449
449
|
},
|
|
450
450
|
"server.startedLocal": {
|
|
451
|
-
"zh": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
452
|
-
"en": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
453
|
-
"zh-TW": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
454
|
-
"ko": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
455
|
-
"ja": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
456
|
-
"de": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
457
|
-
"es": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
458
|
-
"fr": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
459
|
-
"it": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
460
|
-
"da": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
461
|
-
"pl": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
462
|
-
"ru": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
463
|
-
"ar": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
464
|
-
"no": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
465
|
-
"pt-BR": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
466
|
-
"th": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
467
|
-
"tr": " ➜ Local: {protocol}://127.0.0.1:{port}",
|
|
468
|
-
"uk": " ➜ Local: {protocol}://127.0.0.1:{port}"
|
|
451
|
+
"zh": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
452
|
+
"en": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
453
|
+
"zh-TW": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
454
|
+
"ko": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
455
|
+
"ja": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
456
|
+
"de": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
457
|
+
"es": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
458
|
+
"fr": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
459
|
+
"it": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
460
|
+
"da": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
461
|
+
"pl": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
462
|
+
"ru": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
463
|
+
"ar": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
464
|
+
"no": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
465
|
+
"pt-BR": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
466
|
+
"th": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
467
|
+
"tr": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}",
|
|
468
|
+
"uk": " ➜ Local: {protocol}://127.0.0.1:{port}{basePath}"
|
|
469
469
|
},
|
|
470
470
|
"server.startedNetwork": {
|
|
471
|
-
"zh": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
472
|
-
"en": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
473
|
-
"zh-TW": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
474
|
-
"ko": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
475
|
-
"ja": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
476
|
-
"de": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
477
|
-
"es": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
478
|
-
"fr": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
479
|
-
"it": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
480
|
-
"da": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
481
|
-
"pl": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
482
|
-
"ru": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
483
|
-
"ar": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
484
|
-
"no": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
485
|
-
"pt-BR": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
486
|
-
"th": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
487
|
-
"tr": " ➜ Network: {protocol}://{ip}:{port}?token={token}",
|
|
488
|
-
"uk": " ➜ Network: {protocol}://{ip}:{port}?token={token}"
|
|
471
|
+
"zh": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
472
|
+
"en": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
473
|
+
"zh-TW": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
474
|
+
"ko": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
475
|
+
"ja": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
476
|
+
"de": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
477
|
+
"es": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
478
|
+
"fr": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
479
|
+
"it": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
480
|
+
"da": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
481
|
+
"pl": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
482
|
+
"ru": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
483
|
+
"ar": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
484
|
+
"no": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
485
|
+
"pt-BR": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
486
|
+
"th": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
487
|
+
"tr": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}",
|
|
488
|
+
"uk": " ➜ Network: {protocol}://{ip}:{port}{basePath}?token={token}"
|
|
489
489
|
},
|
|
490
490
|
"server.passwordActive": {
|
|
491
491
|
"zh": " 🔒 密码保护已开启,密码: {password}",
|
|
@@ -527,6 +527,26 @@ const i18nData = {
|
|
|
527
527
|
"tr": " ⚠️ Parola boş: uzaktan erişim doğrulama gerektirmez — güvenlik riski",
|
|
528
528
|
"uk": " ⚠️ Пароль порожній: віддалений доступ не потребує перевірки — загроза безпеці"
|
|
529
529
|
},
|
|
530
|
+
"basePath.missingLeadingSlash": {
|
|
531
|
+
"zh": " ⚠️ CCV_BASE_PATH \"{value}\" 必须以 \"/\" 开头,已忽略。示例:\"/proxy/\"",
|
|
532
|
+
"en": " ⚠️ CCV_BASE_PATH \"{value}\" must start with \"/\" — ignored. Use e.g. \"/proxy/\".",
|
|
533
|
+
"zh-TW": " ⚠️ CCV_BASE_PATH \"{value}\" 必須以 \"/\" 開頭,已忽略。範例:\"/proxy/\"",
|
|
534
|
+
"ko": " ⚠️ CCV_BASE_PATH \"{value}\"는 \"/\"로 시작해야 합니다 — 무시됨. 예: \"/proxy/\"",
|
|
535
|
+
"ja": " ⚠️ CCV_BASE_PATH \"{value}\" は \"/\" で始まる必要があります — 無視されました。例: \"/proxy/\"",
|
|
536
|
+
"de": " ⚠️ CCV_BASE_PATH \"{value}\" muss mit \"/\" beginnen — ignoriert. Beispiel: \"/proxy/\"",
|
|
537
|
+
"es": " ⚠️ CCV_BASE_PATH \"{value}\" debe comenzar con \"/\" — ignorado. Ejemplo: \"/proxy/\"",
|
|
538
|
+
"fr": " ⚠️ CCV_BASE_PATH \"{value}\" doit commencer par \"/\" — ignoré. Exemple : \"/proxy/\"",
|
|
539
|
+
"it": " ⚠️ CCV_BASE_PATH \"{value}\" deve iniziare con \"/\" — ignorato. Esempio: \"/proxy/\"",
|
|
540
|
+
"da": " ⚠️ CCV_BASE_PATH \"{value}\" skal starte med \"/\" — ignoreret. Eksempel: \"/proxy/\"",
|
|
541
|
+
"pl": " ⚠️ CCV_BASE_PATH \"{value}\" musi zaczynać się od \"/\" — zignorowano. Przykład: \"/proxy/\"",
|
|
542
|
+
"ru": " ⚠️ CCV_BASE_PATH \"{value}\" должен начинаться с \"/\" — проигнорировано. Пример: \"/proxy/\"",
|
|
543
|
+
"ar": " ⚠️ يجب أن يبدأ CCV_BASE_PATH \"{value}\" بـ \"/\" — تم التجاهل. مثال: \"/proxy/\"",
|
|
544
|
+
"no": " ⚠️ CCV_BASE_PATH \"{value}\" må starte med \"/\" — ignorert. Eksempel: \"/proxy/\"",
|
|
545
|
+
"pt-BR": " ⚠️ CCV_BASE_PATH \"{value}\" deve começar com \"/\" — ignorado. Exemplo: \"/proxy/\"",
|
|
546
|
+
"th": " ⚠️ CCV_BASE_PATH \"{value}\" ต้องขึ้นต้นด้วย \"/\" — ถูกละเว้น ตัวอย่าง: \"/proxy/\"",
|
|
547
|
+
"tr": " ⚠️ CCV_BASE_PATH \"{value}\" \"/\" ile başlamalıdır — yok sayıldı. Örnek: \"/proxy/\"",
|
|
548
|
+
"uk": " ⚠️ CCV_BASE_PATH \"{value}\" має починатися з \"/\" — проігноровано. Приклад: \"/proxy/\""
|
|
549
|
+
},
|
|
530
550
|
"server.auth.loginTitle": {
|
|
531
551
|
"zh": "请输入访问密码",
|
|
532
552
|
"en": "Enter access password",
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// CCV_BASE_PATH(反向代理子路径部署)的统一 normalize / validate / strip。
|
|
2
|
+
// 纯函数,无副作用,server.js(HTTP 剥离 / <base> 注入 / WS upgrade)与 cli.js(启动 URL
|
|
3
|
+
// 打印)共用,消除各处复制粘贴的 normalize 逻辑。vite.config.js 不复用(构建期 base 有
|
|
4
|
+
// `undefined→'/'` 的三态语义,与运行时"未设=无前缀"不同,见该文件注释)。
|
|
5
|
+
|
|
6
|
+
// 规范化 basePath:未设 / 空串 / 根 '/' → ''(无前缀);其余补尾斜杠('/proxy' → '/proxy/')。
|
|
7
|
+
// 尾斜杠是 startsWith 匹配的防歧义关键('/proxy/' 不会误命中 '/proxyextra/x')。
|
|
8
|
+
// 缺前导 '/' 的非法值('proxy/x')也返回 ''(忽略)——否则注入段会产出相对 <base> 破坏页面;
|
|
9
|
+
// 告警由 validateBasePath 在启动期负责。
|
|
10
|
+
export function normalizeBasePath(raw) {
|
|
11
|
+
if (!raw || raw === '/' || !raw.startsWith('/')) return '';
|
|
12
|
+
// 剥裸换行符:含 \n/\r 的值会把 index.html 注入的 JS 字符串断成语法错误(页面级 DoS)
|
|
13
|
+
return raw.replace(/[\r\n]/g, '').replace(/\/?$/, '/');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// 校验 + 规范化。非空但缺前导 '/'(如 'proxy/x')属配置错误:startsWith 永不命中、剥离
|
|
17
|
+
// 静默失效。不自动补 '/' —— 自动修正会掩盖与代理侧前缀的错配,更难排查;这里选择
|
|
18
|
+
// 忽略(按无前缀工作)并返回 i18n key 供启动期 console.warn。
|
|
19
|
+
export function validateBasePath(raw) {
|
|
20
|
+
if (raw && raw !== '/' && !raw.startsWith('/')) {
|
|
21
|
+
return { ok: false, normalized: '', warning: 'basePath.missingLeadingSlash' };
|
|
22
|
+
}
|
|
23
|
+
return { ok: true, normalized: normalizeBasePath(raw), warning: null };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// 从请求 pathname 剥掉 basePath 前缀,保证结果带前导 '/'(路由表按 '/api/...' 匹配)。
|
|
27
|
+
// slice(length - 1) 让 normalizedBase 的尾斜杠留作结果的前导斜杠:
|
|
28
|
+
// stripBasePath('/proxy/api/x', '/proxy/') → '/api/x'
|
|
29
|
+
// stripBasePath('/proxy/', '/proxy/') → '/'
|
|
30
|
+
// 不匹配(含裸前缀 '/proxy' 无尾斜杠的访问)原样返回,由调用方按 SPA/404 处理。
|
|
31
|
+
export function stripBasePath(pathname, normalizedBase) {
|
|
32
|
+
if (!normalizedBase || !pathname.startsWith(normalizedBase)) return pathname;
|
|
33
|
+
return pathname.slice(normalizedBase.length - 1) || '/';
|
|
34
|
+
}
|
|
@@ -249,10 +249,16 @@ function _scheduleDebouncedRead(fileState) {
|
|
|
249
249
|
}, FSWATCH_DEBOUNCE_MS);
|
|
250
250
|
}
|
|
251
251
|
|
|
252
|
+
// 测试注入缝(仿 updater fetchImpl 惯例):替换轮询分支的 watchFile 实现。
|
|
253
|
+
// 真实 watchFile 的 stat 基线与 500ms 间隔在 CI 慢机上不可确定性驱动(基线竞态曾致
|
|
254
|
+
// 25s 全程静默 flake),单测注入假实现手动触发回调即可零时序覆盖。生产恒为 node:fs 原版。
|
|
255
|
+
let _watchFileImpl = watchFile;
|
|
256
|
+
export function __setWatchFileImplForTests(fn) { _watchFileImpl = fn || watchFile; }
|
|
257
|
+
|
|
252
258
|
function _fallbackToPolling(fileState) {
|
|
253
259
|
if (fileState.polling) return;
|
|
254
260
|
fileState.polling = true;
|
|
255
|
-
|
|
261
|
+
_watchFileImpl(fileState.logFile, { interval: 500 }, () => {
|
|
256
262
|
_readDelta(fileState);
|
|
257
263
|
});
|
|
258
264
|
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PTY → WS 洪泛限流器(Windows ConPTY 洪泛防卡死,server 侧字节率上限)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:pty-manager 的 setImmediate 合帧已把"消息数"压到每 tick 一条,但**字节量
|
|
5
|
+
* 没有上限**——ConPTY 把 TUI 全屏对话框/重绘转译成 macOS forkpty 10~100 倍的字节流
|
|
6
|
+
* (如 /theme 选择器开/关、全屏 TUI 重绘),ws-backpressure gate 只在慢网络
|
|
7
|
+
* (bufferedAmount > 1MB)介入,快 LAN 上洪泛字节全量到达前端,xterm 逐帧解析渲染
|
|
8
|
+
* 稠密 SGR+CJK 重绘把主线程打满。本器在发送侧限流:
|
|
9
|
+
*
|
|
10
|
+
* - 直通态:chunk 立即 send(零延迟,不起 timer)。字节率按固定 flushMs 桶统计,
|
|
11
|
+
* 当前桶累计超 floodThresholdBytesPerWin → 进入限流态。打字回显 / 正常 token
|
|
12
|
+
* 流远低于阈值(≈256KB/s),不受影响。
|
|
13
|
+
* - 限流态:chunk 剥掉自带的 DEC 2026 标记后追加进 pending(pending 内部因此
|
|
14
|
+
* **绝无 2026 标记**,截断永不切坏配对);每 flushMs 把 pending 用单对
|
|
15
|
+
* SYNC_BEGIN/END 重新包裹成**一条** send 发出并清空(无论下游是否跳发,
|
|
16
|
+
* flush 后必清——下游 bpGate 跳发时由其 data-resync 快照对齐,不在这里重试)。
|
|
17
|
+
* 发送前若 pending 超 flushBudgetBytes → findSafeSliceStart 截到尾部预算内:
|
|
18
|
+
* 这是真正的速率上限(≈ flushBudgetBytes / flushMs ≈ 1.9MB/s),与前端
|
|
19
|
+
* TerminalWriteQueue 32KB/帧的消化速率同量级,洪泛期客户端入速 ≈ 出速不积压。
|
|
20
|
+
* pending 超 pendingCap → 同样截到 trimTo(flush 间隔内的内存上界)。中间全屏
|
|
21
|
+
* 重绘帧 last-wins 可丢,ConPTY 重绘流自愈。findSafeSliceStart 只保 ANSI 边界、
|
|
22
|
+
* 不保 2026 配对——配平靠剥标记+重包裹兜底,截断切坏配对会让 xterm 卡在同步
|
|
23
|
+
* 缓冲态(黑屏)。scratch PTY 的 chunk 本就无 2026 标记,剥除为 no-op、重包裹
|
|
24
|
+
* 无害(不支持的终端忽略该序列),两条路径共用同一实现。
|
|
25
|
+
* - 回落:连续 fallbackWins 个桶低于阈值 → flush 残余后回直通态。
|
|
26
|
+
* - reset():清 pending + timer + 回直通态。bpGate onBehind/onResume 时必须调用:
|
|
27
|
+
* data-resync 快照(getOutputBuffer)是唯一真相源,残留 pending 若不清会把早于
|
|
28
|
+
* 快照的旧字节回灌导致画面回退。
|
|
29
|
+
*
|
|
30
|
+
* 纯逻辑、时钟可注入(setTimer/clearTimer/now),便于单测。仿 ws-backpressure.js 惯例。
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const SYNC_BEGIN = '\x1b[?2026h';
|
|
34
|
+
const SYNC_END = '\x1b[?2026l';
|
|
35
|
+
// 全局替换两种标记。pty-manager flushBatch 只在首尾各加一对,但限流态 pending 由
|
|
36
|
+
// 多个 chunk 拼接而成,内部会出现多对交替——统一剥净再整体包一对。
|
|
37
|
+
const SYNC_MARKS_RE = /\x1b\[\?2026[hl]/g;
|
|
38
|
+
|
|
39
|
+
// 默认常量可经 CCV_FLOOD_* 环境变量覆盖(仿 CCV_FORCE_POLL 先例),便于 Windows
|
|
40
|
+
// 实机排障时调参而不改源码。非法/非正整数值回落默认。
|
|
41
|
+
function envInt(name, fallback) {
|
|
42
|
+
const v = parseInt(process.env[name], 10);
|
|
43
|
+
return Number.isFinite(v) && v > 0 ? v : fallback;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const DEFAULT_FLUSH_MS = envInt('CCV_FLOOD_FLUSH_MS', 33); // 限流态合并窗口 = 字节率统计桶宽
|
|
47
|
+
const DEFAULT_FLOOD_THRESHOLD = envInt('CCV_FLOOD_THRESHOLD', 8 * 1024); // 单桶超 8KB(≈256KB/s)判定洪泛
|
|
48
|
+
const DEFAULT_FALLBACK_WINS = envInt('CCV_FLOOD_FALLBACK_WINS', 3); // 连续 N 个低于阈值的桶才回直通(迟滞)
|
|
49
|
+
const DEFAULT_PENDING_CAP = envInt('CCV_FLOOD_PENDING_CAP', 256 * 1024); // 限流态 pending 上限(flush 间隔内的内存上界)
|
|
50
|
+
const DEFAULT_TRIM_TO = envInt('CCV_FLOOD_TRIM_TO', 128 * 1024); // pendingCap 截断后保留的尾部量
|
|
51
|
+
// 单次 flush 发送预算 = 真速率上限:64KB / 33ms ≈ 1.9MB/s,与前端 32KB/帧消化速率同量级
|
|
52
|
+
const DEFAULT_FLUSH_BUDGET = envInt('CCV_FLOOD_FLUSH_BUDGET', 64 * 1024);
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* @param {object} opts
|
|
56
|
+
* @param {(data: string) => void} opts.send - 实际发送回调(调用方在内部接 bpGate + ws.send)
|
|
57
|
+
* @param {(buf: string, rawStart: number) => number} opts.findSafeSliceStart - ANSI 安全截断(pty-manager 导出)
|
|
58
|
+
* @param {(buffered: number) => void} [opts.onFloodStart] - 进入限流态(observability 埋点)
|
|
59
|
+
* @param {() => void} [opts.onFloodEnd] - 回落直通态
|
|
60
|
+
* @param {number} [opts.flushMs]
|
|
61
|
+
* @param {number} [opts.floodThresholdBytesPerWin]
|
|
62
|
+
* @param {number} [opts.fallbackWins]
|
|
63
|
+
* @param {number} [opts.pendingCap]
|
|
64
|
+
* @param {number} [opts.trimTo]
|
|
65
|
+
* @param {number} [opts.flushBudgetBytes]
|
|
66
|
+
* @param {(fn: Function, ms: number) => any} [opts.setTimer] - 测试注入
|
|
67
|
+
* @param {(t: any) => void} [opts.clearTimer] - 测试注入
|
|
68
|
+
* @returns {{ offer: (chunk: string) => void, reset: () => void, dispose: () => void, isFlooding: () => boolean }}
|
|
69
|
+
*/
|
|
70
|
+
export function createFloodCoalescer({
|
|
71
|
+
send,
|
|
72
|
+
findSafeSliceStart,
|
|
73
|
+
onFloodStart,
|
|
74
|
+
onFloodEnd,
|
|
75
|
+
flushMs = DEFAULT_FLUSH_MS,
|
|
76
|
+
floodThresholdBytesPerWin = DEFAULT_FLOOD_THRESHOLD,
|
|
77
|
+
fallbackWins = DEFAULT_FALLBACK_WINS,
|
|
78
|
+
pendingCap = DEFAULT_PENDING_CAP,
|
|
79
|
+
trimTo = DEFAULT_TRIM_TO,
|
|
80
|
+
flushBudgetBytes = DEFAULT_FLUSH_BUDGET,
|
|
81
|
+
setTimer = setTimeout,
|
|
82
|
+
clearTimer = clearTimeout,
|
|
83
|
+
}) {
|
|
84
|
+
let flooding = false;
|
|
85
|
+
let pending = '';
|
|
86
|
+
let winBytes = 0; // 当前桶累计字节(直通态由 offer 累计,限流态由 flush 结算)
|
|
87
|
+
let calmWins = 0; // 连续低于阈值的桶数
|
|
88
|
+
let flushTimer = null; // 限流态周期 flush;直通态下亦作为桶边界 timer(见 offer)
|
|
89
|
+
let disposed = false;
|
|
90
|
+
|
|
91
|
+
const stopTimer = () => {
|
|
92
|
+
if (flushTimer) {
|
|
93
|
+
clearTimer(flushTimer);
|
|
94
|
+
flushTimer = null;
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
// 直通态的桶边界:到点清零计数。无流量时 timer 不存在,零常驻开销。
|
|
99
|
+
const armPassthroughWindow = () => {
|
|
100
|
+
if (flushTimer) return;
|
|
101
|
+
flushTimer = setTimer(() => {
|
|
102
|
+
flushTimer = null;
|
|
103
|
+
winBytes = 0;
|
|
104
|
+
}, flushMs);
|
|
105
|
+
flushTimer.unref?.();
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const flushPending = () => {
|
|
109
|
+
if (!pending) return;
|
|
110
|
+
// 单次 flush 发送预算 = 真正的速率上限:超预算截到尾部(last-wins),
|
|
111
|
+
// 保证洪泛期送达客户端的字节率 ≤ flushBudgetBytes/flushMs,与前端消化速率同量级。
|
|
112
|
+
if (pending.length > flushBudgetBytes) {
|
|
113
|
+
const rawStart = pending.length - flushBudgetBytes;
|
|
114
|
+
pending = pending.slice(findSafeSliceStart(pending, rawStart));
|
|
115
|
+
}
|
|
116
|
+
const merged = SYNC_BEGIN + pending + SYNC_END;
|
|
117
|
+
pending = '';
|
|
118
|
+
try { send(merged); } catch { }
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const onFloodTick = () => {
|
|
122
|
+
flushTimer = null;
|
|
123
|
+
if (disposed || !flooding) return;
|
|
124
|
+
// 本桶结算:低于阈值累计 calm 桶数,连续 fallbackWins 个即回落
|
|
125
|
+
if (winBytes <= floodThresholdBytesPerWin) {
|
|
126
|
+
calmWins++;
|
|
127
|
+
} else {
|
|
128
|
+
calmWins = 0;
|
|
129
|
+
}
|
|
130
|
+
winBytes = 0;
|
|
131
|
+
flushPending();
|
|
132
|
+
if (calmWins >= fallbackWins) {
|
|
133
|
+
flooding = false;
|
|
134
|
+
calmWins = 0;
|
|
135
|
+
try { onFloodEnd?.(); } catch { }
|
|
136
|
+
return; // 不再续约 timer,回直通
|
|
137
|
+
}
|
|
138
|
+
flushTimer = setTimer(onFloodTick, flushMs);
|
|
139
|
+
flushTimer.unref?.();
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
/** 每条 PTY chunk 调用。直通态立即 send;限流态进 pending 等周期 flush。 */
|
|
144
|
+
offer(chunk) {
|
|
145
|
+
if (disposed || !chunk) return;
|
|
146
|
+
winBytes += chunk.length;
|
|
147
|
+
if (!flooding) {
|
|
148
|
+
if (winBytes > floodThresholdBytesPerWin) {
|
|
149
|
+
// 进入限流态:当前 chunk 是压垮桶的那条,一并纳入 pending(含它之前
|
|
150
|
+
// 本桶已直通的部分不回收——它们已发出,量级在阈值内)。
|
|
151
|
+
flooding = true;
|
|
152
|
+
calmWins = 0;
|
|
153
|
+
stopTimer();
|
|
154
|
+
pending = chunk.replace(SYNC_MARKS_RE, '');
|
|
155
|
+
flushTimer = setTimer(onFloodTick, flushMs);
|
|
156
|
+
flushTimer.unref?.();
|
|
157
|
+
try { onFloodStart?.(winBytes); } catch { }
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
armPassthroughWindow();
|
|
161
|
+
try { send(chunk); } catch { }
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
pending += chunk.replace(SYNC_MARKS_RE, '');
|
|
165
|
+
// 单 chunk 可超 cap:pty-manager 每 tick 合帧,一个 tick(如 /resume 重放)可达数百 KB,
|
|
166
|
+
// 故该分支并非不可达——它是 flush 间隔内的内存上界,与 flushPending 的速率预算各司其职。
|
|
167
|
+
if (pending.length > pendingCap) {
|
|
168
|
+
const rawStart = pending.length - trimTo;
|
|
169
|
+
const safeStart = findSafeSliceStart(pending, rawStart);
|
|
170
|
+
pending = pending.slice(safeStart);
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
/** bpGate onBehind/onResume 时调用:resync 快照是唯一真相源,清掉旧 pending 防回灌。 */
|
|
174
|
+
reset() {
|
|
175
|
+
stopTimer();
|
|
176
|
+
pending = '';
|
|
177
|
+
winBytes = 0;
|
|
178
|
+
calmWins = 0;
|
|
179
|
+
flooding = false;
|
|
180
|
+
},
|
|
181
|
+
isFlooding() {
|
|
182
|
+
return flooding;
|
|
183
|
+
},
|
|
184
|
+
/** ws close 时调用,终态。 */
|
|
185
|
+
dispose() {
|
|
186
|
+
disposed = true;
|
|
187
|
+
stopTimer();
|
|
188
|
+
pending = '';
|
|
189
|
+
},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Windows Ctrl+C 退出链三层防御(macOS/Linux 行为不变)。
|
|
2
|
+
// 背景:cli.js / server.js 的 SIGINT handler 都是 `doCleanup → .finally(process.exit)` 形态,
|
|
3
|
+
// 注册 handler 后 Node 不再默认退出 —— 链上任何一环挂住(node-pty ConPTY kill 同步挂起、
|
|
4
|
+
// IM bridge teardown await 无超时、SIGINT 事件被 ConPTY 吞掉)进程就永不退出,
|
|
5
|
+
// 即 Windows 用户报告的 "Ctrl+C 完全无反应"。
|
|
6
|
+
// 三函数全部依赖注入(仿 log-watcher __setWatchFileImplForTests 惯例),纯逻辑可单测。
|
|
7
|
+
|
|
8
|
+
import { spawnSync as _spawnSync } from 'node:child_process';
|
|
9
|
+
import { emitKeypressEvents as _emitKeypressEvents } from 'node:readline';
|
|
10
|
+
|
|
11
|
+
// 把 `doCleanup → exit` 包装成幂等 + 双保险的 cleanup:
|
|
12
|
+
// 首次触发:先武装 watchdog(watchdogMs 后强制 exit(130),unref 不滞留事件循环),
|
|
13
|
+
// 再 try/catch 跑 doCleanup(同步抛错不阻断退出),完成后 exit()。
|
|
14
|
+
// 二次触发(用户连按 Ctrl+C):立即 exit(130),不再等优雅收尾。
|
|
15
|
+
// watchdogMs 默认 5s:_doStop 内 IM teardown + serverStopping hook 共用一个 3s 总预算
|
|
16
|
+
// (见 server.js _doStop 的合并 race),其后还有 rename temp jsonl(用户数据)要顺序
|
|
17
|
+
// 执行 —— watchdog 必须 > 3s 总预算 + rename 余量,避免中途截断。
|
|
18
|
+
export function createHardenedCleanup({ doCleanup, exit = process.exit, setTimeoutImpl = setTimeout, watchdogMs = 5000 }) {
|
|
19
|
+
let invoked = false;
|
|
20
|
+
return function hardenedCleanup() {
|
|
21
|
+
if (invoked) { exit(130); return; }
|
|
22
|
+
invoked = true;
|
|
23
|
+
const watchdog = setTimeoutImpl(() => exit(130), watchdogMs);
|
|
24
|
+
if (watchdog && typeof watchdog.unref === 'function') watchdog.unref();
|
|
25
|
+
try {
|
|
26
|
+
const r = doCleanup();
|
|
27
|
+
if (r && typeof r.then === 'function') {
|
|
28
|
+
r.then(() => exit(), () => exit(130));
|
|
29
|
+
} else {
|
|
30
|
+
// 同步 doCleanup:完成即退(统一契约"doCleanup 结束 → exit";当前所有调用方
|
|
31
|
+
// 都返回 promise 走上面分支,此分支为前向兼容)
|
|
32
|
+
exit();
|
|
33
|
+
}
|
|
34
|
+
} catch {
|
|
35
|
+
exit(130);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Windows 下 ConPTY/控制台事件链偶发吞掉 Ctrl+C(SIGINT 永不送达)的兜底:
|
|
41
|
+
// 把本地终端 stdin 切 raw mode 监听 keypress,\x03(Ctrl+C)/\x04(Ctrl+D) 直连 onInterrupt。
|
|
42
|
+
// 注意:raw mode 下控制台**不再产生 SIGINT**,onInterrupt 必须直接是 hardened cleanup
|
|
43
|
+
// 本体(不能 re-emit SIGINT —— 那条路在 raw mode 下已死)。
|
|
44
|
+
// 仅 win32 且 stdin 是 TTY 时安装(silent/PTY 与 SDK 模式本地终端本就无人读 stdin,
|
|
45
|
+
// 无副作用;proxy 模式 stdio:'inherit' 子进程持有控制台,调用方不得安装)。
|
|
46
|
+
// 进程退出时通过 'exit' 同步钩子恢复 cooked mode,防 Windows 终端残留 raw 态
|
|
47
|
+
// (watchdog 的 exit(130) 同样触发 'exit' 钩子)。
|
|
48
|
+
export function installWinKeypressFallback({ stdin = process.stdin, onInterrupt, platform = process.platform, emitKeypressEvents = _emitKeypressEvents }) {
|
|
49
|
+
if (platform !== 'win32' || !stdin || !stdin.isTTY) return null;
|
|
50
|
+
emitKeypressEvents(stdin);
|
|
51
|
+
try { stdin.setRawMode(true); } catch { return null; }
|
|
52
|
+
const restore = () => { try { stdin.setRawMode(false); } catch { /* noop */ } };
|
|
53
|
+
process.on('exit', restore);
|
|
54
|
+
const onKeypress = (str, key) => {
|
|
55
|
+
const isCtrlC = (key && key.ctrl && key.name === 'c') || str === '\u0003';
|
|
56
|
+
const isCtrlD = (key && key.ctrl && key.name === 'd') || str === '\u0004';
|
|
57
|
+
if (isCtrlC || isCtrlD) onInterrupt();
|
|
58
|
+
};
|
|
59
|
+
stdin.on('keypress', onKeypress);
|
|
60
|
+
stdin.resume();
|
|
61
|
+
return () => {
|
|
62
|
+
stdin.off('keypress', onKeypress);
|
|
63
|
+
restore();
|
|
64
|
+
process.off('exit', restore);
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Windows 下杀掉 PTY 进程树(ConPTY agent + claude)。
|
|
69
|
+
// 用 spawnSync taskkill 而非 ptyProcess.kill():后者在 ConPTY 下有已知同步挂起问题
|
|
70
|
+
// (microsoft/node-pty#454 等),且 killPty 的 respawn 调用方(spawnClaude 内部
|
|
71
|
+
// kill→立即重启、workspaces stop→launch)需要"返回时进程已死"的同步语义 ——
|
|
72
|
+
// taskkill /F 通常 <200ms,timeout 2s 兜底有界,不会废掉 5s watchdog。
|
|
73
|
+
// 非 win32 返回 false,调用方走原 ptyProcess.kill() 路径。
|
|
74
|
+
export function killPtyTree(pid, { platform = process.platform, spawnSyncImpl = _spawnSync } = {}) {
|
|
75
|
+
if (platform !== 'win32' || !pid) return false;
|
|
76
|
+
try {
|
|
77
|
+
spawnSyncImpl('taskkill', ['/pid', String(pid), '/T', '/F'], { timeout: 2000, windowsHide: true });
|
|
78
|
+
} catch { /* taskkill 不在 PATH 等极端情况,调用方仍有 watchdog 兜底 */ }
|
|
79
|
+
return true;
|
|
80
|
+
}
|
package/server/pty-manager.js
CHANGED
|
@@ -5,6 +5,7 @@ import { chmodSync, statSync } from 'node:fs';
|
|
|
5
5
|
import { platform, arch, homedir } from 'node:os';
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
7
7
|
import { prepareEmbeddedShellSpawn, stripClaudeNoFlickerUnlessOptedIn } from './lib/terminal-env.js';
|
|
8
|
+
import { killPtyTree } from './lib/term-signals.js';
|
|
8
9
|
|
|
9
10
|
const __filename = fileURLToPath(import.meta.url);
|
|
10
11
|
const __dirname = dirname(__filename);
|
|
@@ -46,8 +47,11 @@ async function getPty() {
|
|
|
46
47
|
* 在 outputBuffer 截断时,找到安全的截断位置,
|
|
47
48
|
* 避免从 ANSI 转义序列中间开始导致终端状态紊乱。
|
|
48
49
|
* 策略:从截断点向后扫描,跳过可能被截断的不完整转义序列。
|
|
50
|
+
* 注意:只保 ANSI 序列边界,不保 DEC 2026 同步标记的配对——
|
|
51
|
+
* 跨 2026 块截断的配平由调用方负责(见 lib/pty-flood-coalescer.js)。
|
|
52
|
+
* export 供洪泛限流器复用同一截断语义。
|
|
49
53
|
*/
|
|
50
|
-
function findSafeSliceStart(buf, rawStart) {
|
|
54
|
+
export function findSafeSliceStart(buf, rawStart) {
|
|
51
55
|
// 从 rawStart 开始,向后最多扫描 64 字节寻找安全起点
|
|
52
56
|
const scanLimit = Math.min(rawStart + 64, buf.length);
|
|
53
57
|
let i = rawStart;
|
|
@@ -474,7 +478,14 @@ export function killPty() {
|
|
|
474
478
|
flushBatch();
|
|
475
479
|
batchBuffer = '';
|
|
476
480
|
batchScheduled = false;
|
|
477
|
-
|
|
481
|
+
// Windows:node-pty 的 ConPTY kill 有已知同步挂起问题(microsoft/node-pty#454),
|
|
482
|
+
// 挂住会连 Ctrl+C 退出链的 watchdog 一起废掉。改用 spawnSync taskkill /T /F 收割
|
|
483
|
+
// 整棵进程树(ConPTY agent + claude),有界(timeout 2s)且提供"返回时已死"语义
|
|
484
|
+
// (spawnClaude 内部 kill→respawn、workspaces stop→launch 依赖这一点)。
|
|
485
|
+
// win32 下完全跳过 ptyProcess.kill()。非 Windows 行为不变。
|
|
486
|
+
if (!killPtyTree(ptyProcess.pid)) {
|
|
487
|
+
try { ptyProcess.kill(); } catch { }
|
|
488
|
+
}
|
|
478
489
|
ptyProcess = null;
|
|
479
490
|
ptyKind = null;
|
|
480
491
|
ptySkipPermissions = false;
|
|
@@ -560,8 +560,12 @@ function revealFile(req, res, parsedUrl, isLocal, deps) {
|
|
|
560
560
|
if (plat === 'darwin') {
|
|
561
561
|
execFile('open', ['-R', fullPath], () => {});
|
|
562
562
|
} else if (plat === 'win32') {
|
|
563
|
-
// explorer /select
|
|
564
|
-
|
|
563
|
+
// explorer /select 的规范形式是 /select,"<path>"(仅路径部分加引号、整体一个 arg)。
|
|
564
|
+
// 必须 windowsVerbatimArguments 透传:否则 Node 对含空格 arg 整体加引号生成
|
|
565
|
+
// explorer.exe "/select,C:\My Proj\x.txt",explorer 解析不了 → 功能失效。
|
|
566
|
+
// Windows 文件名不允许 ",且不经 cmd.exe,无元字符注入面。
|
|
567
|
+
const child = spawn('explorer.exe', [`/select,"${fullPath}"`], { windowsVerbatimArguments: true, windowsHide: true });
|
|
568
|
+
child.on('error', () => {}); // 防 async ENOENT 变 uncaughtException 砸进程
|
|
565
569
|
} else {
|
|
566
570
|
execFile('xdg-open', [dirname(fullPath)], () => {});
|
|
567
571
|
}
|
package/server/routes/misc.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Miscellaneous small routes (moved verbatim from server.js handleRequest).
|
|
2
2
|
import { getUserProfile } from '../lib/user-profile.js';
|
|
3
3
|
import { runWaterfallHook } from '../lib/plugin-loader.js';
|
|
4
|
+
import { normalizeBasePath } from '../lib/base-path.js';
|
|
4
5
|
|
|
5
6
|
async function userProfile(req, res) {
|
|
6
7
|
const profile = await getUserProfile();
|
|
@@ -10,7 +11,10 @@ async function userProfile(req, res) {
|
|
|
10
11
|
|
|
11
12
|
async function localUrl(req, res, parsedUrl, isLocal, deps) {
|
|
12
13
|
const localIp = deps.getLocalIp();
|
|
13
|
-
|
|
14
|
+
// 反代子路径部署时分享/二维码 URL 也要带前缀,否则扫码绕过代理直连源站端口(与
|
|
15
|
+
// server.startedNetwork 启动打印保持一致)。未设 CCV_BASE_PATH 时为空串,行为不变。
|
|
16
|
+
const basePath = normalizeBasePath(process.env.CCV_BASE_PATH);
|
|
17
|
+
const defaultUrl = `${deps.protocol}://${localIp}:${deps.actualPort}${basePath}?token=${deps.ACCESS_TOKEN}`;
|
|
14
18
|
const hookResult = await runWaterfallHook('localUrl', { url: defaultUrl, ip: localIp, port: deps.actualPort, token: deps.ACCESS_TOKEN });
|
|
15
19
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
16
20
|
res.end(JSON.stringify({ url: hookResult.url }));
|
|
@@ -18,6 +18,14 @@ function stripImConfigs(obj) {
|
|
|
18
18
|
if (obj) for (const id of listPlatforms()) delete obj[id];
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
// /theme 选择器特征:选项文案高特异、不太可能出现在普通生成输出里(ESC 兜底的门控签名)
|
|
22
|
+
const THEME_PICKER_RE = /Auto \(match terminal\)|colorblind-friendly/;
|
|
23
|
+
|
|
24
|
+
// 并发切主题防重入:双端同时 POST themeColor 时只允许一条 /theme 同步链路在途,
|
|
25
|
+
// 防止双监听器 + 双 /theme 注入 + 双 ESC(后到的 POST 仅落盘偏好,跳过 PTY 同步)。
|
|
26
|
+
let _themeSyncInFlight = false;
|
|
27
|
+
export function _resetThemeSyncForTests() { _themeSyncInFlight = false; }
|
|
28
|
+
|
|
21
29
|
function preferencesGet(req, res, parsedUrl, isLocal, deps) {
|
|
22
30
|
let prefs = {};
|
|
23
31
|
try { if (existsSync(deps.getPrefsFile())) prefs = JSON.parse(readFileSync(deps.getPrefsFile(), 'utf-8')); } catch { }
|
|
@@ -84,11 +92,18 @@ function preferencesPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
84
92
|
// preferences.json 可能携带 auth 的 base64 密码 —— 与 lib/auth.js writePrefs 一致地
|
|
85
93
|
// 重申 0600,避免该路径(无 mode/不 chmod)把密码文件留成默认 umask 的可读权限。
|
|
86
94
|
try { chmodSync(prefsFile, 0o600); } catch { /* best-effort; non-POSIX or race */ }
|
|
87
|
-
// 主题切换时同步到 Claude Code CLI:发 /theme
|
|
88
|
-
|
|
95
|
+
// 主题切换时同步到 Claude Code CLI:发 /theme,监听输出验证结果。
|
|
96
|
+
// 现代 CLI(≥2.x)的 /theme 是交互式选择器(args 被忽略),注入后对话框可能
|
|
97
|
+
// 残留在终端:
|
|
98
|
+
// - mismatch 时**不再重发** /theme —— toggle 语义已不存在,重发只会把选择器
|
|
99
|
+
// 再次打开,把终端困进"确认-重开"循环(Windows ConPTY 下每轮全屏重绘洪泛)。
|
|
100
|
+
// - 5s 超时若 buf 检出选择器特征(选项文案,见 THEME_PICKER_RE),补发一次
|
|
101
|
+
// ESC 关闭残留对话框。无特征绝不发 ESC —— CLI 正在流式生成时 /theme 只是被
|
|
102
|
+
// 排队、对话框未开,误发 ESC 会 interrupt 用户正在跑的任务(宁漏关不误发)。
|
|
103
|
+
if (incoming.themeColor && deps.writeToPty && deps.onPtyData && !_themeSyncInFlight) {
|
|
104
|
+
_themeSyncInFlight = true;
|
|
89
105
|
const target = incoming.themeColor === 'light' ? 'light' : 'dark';
|
|
90
106
|
let buf = '';
|
|
91
|
-
let retried = false;
|
|
92
107
|
const removeListener = deps.onPtyData((data) => {
|
|
93
108
|
buf += data;
|
|
94
109
|
if (buf.length > 4096) buf = buf.slice(-2048); // 限制 buf 大小
|
|
@@ -97,15 +112,20 @@ function preferencesPost(req, res, parsedUrl, isLocal, deps) {
|
|
|
97
112
|
if (match) {
|
|
98
113
|
removeListener();
|
|
99
114
|
clearTimeout(timeout);
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
try { deps.writeToPty('/theme\r'); } catch {}
|
|
115
|
+
_themeSyncInFlight = false;
|
|
116
|
+
if (match[1] !== target) {
|
|
117
|
+
console.warn(`[preferences] CLI theme sync mismatch: got ${match[1]}, wanted ${target} (no retry; modern /theme is an interactive picker)`);
|
|
104
118
|
}
|
|
105
119
|
}
|
|
106
120
|
});
|
|
107
|
-
// 5
|
|
108
|
-
const timeout = setTimeout(() => {
|
|
121
|
+
// 5 秒超时,避免监听器泄漏;检出选择器残留时 ESC 兜底关闭
|
|
122
|
+
const timeout = setTimeout(() => {
|
|
123
|
+
removeListener();
|
|
124
|
+
_themeSyncInFlight = false;
|
|
125
|
+
if (THEME_PICKER_RE.test(buf)) {
|
|
126
|
+
try { deps.writeToPty('\x1b'); } catch {}
|
|
127
|
+
}
|
|
128
|
+
}, 5000);
|
|
109
129
|
try { deps.writeToPty('/theme\r'); } catch {}
|
|
110
130
|
}
|
|
111
131
|
// 回显里也剥离 auth/authByProject(含 base64 密码) —— GET 已剥离,POST 回显同样不能漏给
|
|
@@ -4,6 +4,7 @@ import { chmodSync, statSync } from 'node:fs';
|
|
|
4
4
|
import { platform, arch, homedir } from 'node:os';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
6
|
import { prepareEmbeddedShellSpawn } from './lib/terminal-env.js';
|
|
7
|
+
import { killPtyTree } from './lib/term-signals.js';
|
|
7
8
|
|
|
8
9
|
const __filename = fileURLToPath(import.meta.url);
|
|
9
10
|
const __dirname = dirname(__filename);
|
|
@@ -219,7 +220,11 @@ export function killScratch(id) {
|
|
|
219
220
|
flushBatch(s);
|
|
220
221
|
s.batchBuffer = '';
|
|
221
222
|
s.batchScheduled = false;
|
|
222
|
-
|
|
223
|
+
// 与 pty-manager.killPty 同款:win32 用 taskkill /T 收割 ConPTY 树,
|
|
224
|
+
// 绕开 node-pty kill 的同步挂起问题;非 Windows 走原路径。
|
|
225
|
+
if (!killPtyTree(s.ptyProcess.pid)) {
|
|
226
|
+
try { s.ptyProcess.kill(); } catch { }
|
|
227
|
+
}
|
|
223
228
|
s.ptyProcess = null;
|
|
224
229
|
}
|
|
225
230
|
// 显式 kill 后整条记录清掉,监听器一并丢弃(前端 ws.close 也会清)
|