diff --git a/pxterm/TOP_LEVEL_CONSTRAINTS.md b/pxterm/TOP_LEVEL_CONSTRAINTS.md
new file mode 100644
index 0000000..39f7dea
--- /dev/null
+++ b/pxterm/TOP_LEVEL_CONSTRAINTS.md
@@ -0,0 +1,52 @@
+# 移动端终端交互顶级约束 (Mobile Terminal Top-Level Constraints)
+
+本文档定义了 PxTerm 移动端终端界面的**“四大不可回归”顶级约束**。在未来任何针对 CSS、JS 逻辑、甚至 Xterm.js 版本升级的修改中,都必须且优先满足以下条件。
+
+任何破坏以下任一体验的代码修改,都视为 **核心体验回归 (Critical Regression)**,禁止合入。
+
+---
+
+## 🛑 约束 1:正常的软键盘弹出与收回 (Keyboard Lifecycle)
+用户的输入意图必须被绝对尊重,不能因为防阻断逻辑导致系统键盘失灵或无法正常收起。
+
+- **约束标准**:
+ - 点击终端内最后一行空白处或已有文本,必须能够迅速唤起原生软键盘。
+ - 在唤起状态下,可以通过 UI 按钮或符合常理的操作顺畅收起键盘,而不引起全屏闪烁或虚假重绘。
+- **QA 测试用例 (Test Case 1)**:
+ 1. 在手机浏览器中打开终端。
+ 2. 短按屏幕输入区,**验证**:系统软键盘立刻出现。
+ 3. 点击顶部工具栏的隐藏键盘按钮/或进行收起手势,**验证**:软键盘顺利降下,终端不触发无关的字符输入或选中。
+
+## 🛑 约束 2:原生且丝滑的滚动 (Native-like Smooth Scroll)
+无论渲染层多么复杂(海量 `span`/`div` 标签),滑动都必须 1:1 跟手,并保留操作系统级的惯性阻尼。
+
+- **约束标准**:
+ - `touchmove` 事件绝不能由于 OS 原生手势侦测而在中途被无故截断(Frozen Screen)。
+ - 松开手指后必须有自然的衰减惯性滚动。
+- **QA 测试用例 (Test Case 2)**:
+ 1. 打印一长串历史构建输出(如 `ls -la /` 或 `dmesg`)。
+ 2. 手指在屏幕上连续、快速且不离开屏幕地上下来回拖拽。**验证**:无论速度多快,列表紧跟指尖,没有丝毫卡顿或断流。
+ 3. 快速向上一划并立刻松手。**验证**:列表如原生 App 一样继续依靠惯性顺滑滚动,直至自然停止。
+
+## 🛑 约束 3:完整的长按选中功能 (Long-Press Text Selection)
+滚动优化和事件劫持不能杀灭系统底层的文本选取工作流。
+
+- **约束标准**:
+ - 在触摸初始阶段 (`touchstart` / `pointerdown`) 绝对不滥用 `preventDefault` 导致 OS 选区定时器失效。
+ - 一旦触发选区,一切系统原生拖拽行为享有最高优先级,防劫持和自定义滚动必须全面让路。
+- **QA 测试用例 (Test Case 3)**:
+ 1. 在终端中找到一段带颜色的日志文本。
+ 2. 手指长按某个单词,**验证**:出现 iOS/Android 原生放大镜,并且选中该词(高亮或系统菜单出现)。
+ 3. 拖拽选区两端的“小拉手”扩大选中范围,**验证**:选区顺利扩大,且此时不应该误触发页面的大幅度重新滚动。
+
+## 🛑 约束 4:键盘唤起/收回期间光标与命令行的正确位置 (Viewport Pinning during Transitions)
+当可视区域 (`Visual Viewport`) 受到物理屏幕与虚拟键盘挤压变化时,我们必须保证用户正在操作的焦点不丢失。
+
+- **约束标准**:
+ - 软键盘弹出的瞬间,终端内部高度与排版自动重调,强制将**当前活动命令行 (光标所在行)** 顶出至可视范围内(浮于键盘正上方)。
+ - 软键盘收回时,恢复之前的满铺视图高度,且当前输入行依然停留在舒适可见范围内,不会沉入看不见的“黑洞(屏幕下方)”。
+- **QA 测试用例 (Test Case 4)**:
+ 1. 屏幕充满日志内容后,最下面的一行是当前的 Shell 提示符 `$ `。
+ 2. 短按唤起键盘,此时屏幕下半部分被键盘遮挡。
+ 3. **验证**:整个终端内容自动缩紧或向上推移,最后那行 `$ ` 以及光标刚好贴在这个物理键盘的顶部,能清晰看见输入的内容。
+ 4. 顺势收回键盘,**验证**:屏幕重新展平,最后一行依然保留在屏幕最下方,并未丢失位置。
diff --git a/pxterm/add_viewport_debug.cjs b/pxterm/add_viewport_debug.cjs
new file mode 100644
index 0000000..05b6aa0
--- /dev/null
+++ b/pxterm/add_viewport_debug.cjs
@@ -0,0 +1,28 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/components/TerminalPanel.vue', 'utf8');
+
+// The place where we can attach is right after viewportScroller is queried.
+// Let's find viewportScroller assignment.
+
+const attachCode = `
+ viewportScroller = containerRef.value.querySelector(".xterm-viewport");
+ if (viewportScroller) {
+ // ---- [VIEWPORT DEBUG START] ----
+ ['touchstart', 'touchmove', 'touchend', 'touchcancel'].forEach(type => {
+ viewportScroller.addEventListener(type, (event) => {
+ console.log(\`[Viewport Event] \${type}\`, {
+ target: (event.target as Node)?.nodeName,
+ eventPhase: event.eventPhase,
+ cancelable: event.cancelable,
+ defaultPrevented: event.defaultPrevented,
+ touches: (event as TouchEvent).touches?.length
+ });
+ }, { capture: true, passive: false });
+ });
+ // ---- [VIEWPORT DEBUG END] ----
+ }
+`;
+
+code = code.replace(/viewportScroller = containerRef\.value\.querySelector\("\.xterm-viewport"\);\n\s*if \(\!viewportScroller\) \{/m, attachCode + "\n if (!viewportScroller) {");
+
+fs.writeFileSync('src/components/TerminalPanel.vue', code);
diff --git a/pxterm/fix_css.cjs b/pxterm/fix_css.cjs
new file mode 100644
index 0000000..076fb4b
--- /dev/null
+++ b/pxterm/fix_css.cjs
@@ -0,0 +1,7 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/styles/main.css', 'utf8');
+
+// If touch-action is missing from xterm-viewport we need to add it
+code = code.replace(/\.terminal-wrapper \.xterm \.xterm-viewport \{/, '.terminal-wrapper .xterm .xterm-viewport {\n touch-action: none !important;');
+
+fs.writeFileSync('src/styles/main.css', code);
diff --git a/pxterm/fix_handlers.cjs b/pxterm/fix_handlers.cjs
new file mode 100644
index 0000000..356c47c
--- /dev/null
+++ b/pxterm/fix_handlers.cjs
@@ -0,0 +1,13 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/components/TerminalPanel.vue', 'utf8');
+
+// 1. Remove that duplicate touchstart preventDefault logic completely
+code = code.replace(/\s*\/\/ 必须阻止 touchstart.*\n\s*if \(event\.cancelable.*\n\s*event\.preventDefault\(\);\s*\n\s*\}/g, '');
+
+// 2. In touchmove, add the preventDefault ONLY IF selection is not active
+code = code.replace(/(\/\/ Stop xterm from intercepting this manually\s*\n\s*event\.stopImmediatePropagation\(\);)/g, `$1\n\n // 阻止 iOS 原生滚动,确保 move 事件不被吞噬\n if (event.cancelable) { event.preventDefault(); }\n`);
+
+// 3. Let's remove the duplicated pointer scroll logic since we rely on touchmove for scroll
+code = code.replace(/\/\/ --- Pointer Scroll Sync ---.*?\/\/ --- Pointer Scroll Sync End ---/gs, '');
+
+fs.writeFileSync('src/components/TerminalPanel.vue', code);
diff --git a/pxterm/fix_handlers2.cjs b/pxterm/fix_handlers2.cjs
new file mode 100644
index 0000000..4b6a1a3
--- /dev/null
+++ b/pxterm/fix_handlers2.cjs
@@ -0,0 +1,7 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/components/TerminalPanel.vue', 'utf8');
+
+const regex = /\/\/ \-\-\- Pointer Scroll Sync \-\-\-.*?(?=\s*\}\s*;\s*onTouchKeyboardPointerUp)/s;
+code = code.replace(regex, '');
+
+fs.writeFileSync('src/components/TerminalPanel.vue', code);
diff --git a/pxterm/fix_pointerMove.cjs b/pxterm/fix_pointerMove.cjs
new file mode 100644
index 0000000..0ee60e2
--- /dev/null
+++ b/pxterm/fix_pointerMove.cjs
@@ -0,0 +1,12 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/components/TerminalPanel.vue', 'utf8');
+
+// The reason touchmove stops is because touch events on iOS are sometimes silently swallowed if the pointer event stops propagation but touch doesn't, OR if touch-action hasn't mapped.
+// We added touch-action: none. Let's ALSO remove stopImmediatePropagation from pointermove and let standard Pointer Events do their thing!
+// Actually, earlier we added:
+// onTouchKeyboardPointerMove = (...) { event.stopImmediatePropagation() }
+// If we stop pointermove, the browser might think the gesture is dead.
+
+code = code.replace(/event\.stopImmediatePropagation\(\);/g, `// event.stopImmediatePropagation();`);
+
+fs.writeFileSync('src/components/TerminalPanel.vue', code);
diff --git a/pxterm/fix_pointer_scroll.cjs b/pxterm/fix_pointer_scroll.cjs
new file mode 100644
index 0000000..4e843b9
--- /dev/null
+++ b/pxterm/fix_pointer_scroll.cjs
@@ -0,0 +1,124 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/components/TerminalPanel.vue', 'utf8');
+
+// Convert Touch Events to Pointer Events for manual scroll tracking.
+// Since touchmove vanishes but pointermove remains active (with target changing from SPAN to DIV, etc)
+
+// Let's modify pointer_move to handle scrolling
+
+let pointerMoveCode = ` onTouchKeyboardPointerMove = (event: PointerEvent) => {
+ // 保持之前的检查,跳除非 touch 事件等
+ if (event.pointerType !== "touch" || touchGatePointerId !== event.pointerId) {
+ return;
+ }
+
+ const dx = event.clientX - touchGateStartX;
+ const dy = event.clientY - touchGateStartY;
+ const absDx = Math.abs(dx);
+ const absDy = Math.abs(dy);
+
+ if (absDx > TOUCH_KEYBOARD_TAP_MAX_MOVE_PX || absDy > TOUCH_KEYBOARD_TAP_MAX_MOVE_PX) {
+ touchGateMoved = true;
+ }
+ if (absDy > absDx && absDy > TOUCH_KEYBOARD_TAP_MAX_MOVE_PX) {
+ touchGateScrollLike = true;
+ }
+
+ // 我们不再拦截 touchmove,改用 pointermove 来计算自己的滚动量
+ const hasSelection = hasActiveNativeSelectionInTerminal();
+ if (!hasSelection) {
+ const now = performance.now();
+ const dt = now - touchScrollLastTime;
+ const currentY = event.clientY;
+ const stepDy = currentY - touchScrollLastY;
+
+ // 屏蔽初次的巨大跳跃(手指刚按下时)
+ if (dt > 0 && Math.abs(stepDy) < 200 && touchScrollLastTime > 0) {
+ if (viewportScroller && stepDy !== 0) {
+ viewportScroller.scrollTop -= stepDy;
+ }
+
+ const v = (-stepDy / dt) * 16;
+ if (touchScrollVelocity * v > 0) {
+ touchScrollVelocity = touchScrollVelocity * 0.5 + v * 0.5;
+ } else {
+ touchScrollVelocity = v;
+ }
+
+ const touchMaxSpeed = 120;
+ if (touchScrollVelocity > touchMaxSpeed) touchScrollVelocity = touchMaxSpeed;
+ else if (touchScrollVelocity < -touchMaxSpeed) touchScrollVelocity = -touchMaxSpeed;
+ }
+
+ touchScrollLastY = currentY;
+ touchScrollLastTime = now;
+ }
+
+ // 由于使用了 pointer,不要无脑阻止原生行为,但我们要阻止 xterm 被选中
+ // event.stopImmediatePropagation(); // 我们前面通过 touch-action: none 已经避免了系统层面缩放
+ };`;
+
+let pointerDownCode = ` onTouchKeyboardPointerDown = (event: PointerEvent) => {
+ console.log("[Scroll Deep] 🟡 POINTER DOWN: ", { pointerId: event.pointerId, type: event.pointerType, target: (event.target as Node)?.nodeName });
+ if (event.pointerType !== "touch") {
+ return;
+ }
+ if (!helperTextarea) {
+ return;
+ }
+ touchGatePointerId = event.pointerId;
+ touchGateStartX = event.clientX;
+ touchGateStartY = event.clientY;
+ touchGateMoved = false;
+ touchGateScrollLike = false;
+ touchGateStartInBand = isTouchInCursorActivationBand(event.clientY);
+ touchGateHadSelectionAtStart = hasActiveNativeSelectionInTerminal();
+
+ // == 初始化滚动参数 ==
+ clearTouchScrollMomentum();
+ touchScrollLastY = event.clientY;
+ touchScrollLastTime = performance.now();
+ touchScrollVelocity = 0;
+
+ if (DEBUG_TOUCH_FOCUS) {
+ console.log("[TouchFocus] pointerdown", {
+ inBand: touchGateStartInBand,
+ selectionStart: touchGateHadSelectionAtStart,
+ });
+ }
+
+ event.stopImmediatePropagation();
+ if (event.cancelable) {
+ event.preventDefault();
+ }
+
+ helperTextarea.readOnly = true;
+
+ if (sessionStore.state !== "connected") {
+ return;
+ }
+ };`;
+
+let pointerUpCode = ` onTouchKeyboardPointerUp = (event: PointerEvent) => {
+ console.log("[Scroll Deep] 🟠 POINTER UP: ", { pointerId: event.pointerId, type: event.pointerType });
+ if (event.pointerType !== "touch" || touchGatePointerId !== event.pointerId) {
+ return;
+ }
+
+ // 释放时触发滚行动量
+ const threshold = 0.2;
+ if (Math.abs(touchScrollVelocity) > threshold && touchGateScrollLike && !hasActiveNativeSelectionInTerminal()) {
+ touchScrollVelocity *= 1.35;
+ console.log(\`[Scroll Deep] 🚀 Pointer 释放,触发JS惯性,速度=\${touchScrollVelocity.toFixed(2)}\`);
+ runTouchScrollMomentum();
+ } else {
+ console.log(\`[Scroll Deep] 🛑 Pointer 静止释放或非滚动释放\`);
+ }
+`;
+
+// Now apply pointer changes
+code = code.replace(/onTouchKeyboardPointerDown = \(event: PointerEvent\) => \{[\s\S]*?if \(sessionStore\.state \!\=\= "connected"\) \{\n\s*return;\n\s*\}\n\s*\};/m, pointerDownCode);
+code = code.replace(/onTouchKeyboardPointerMove = \(event: PointerEvent\) => \{[\s\S]*?event\.stopImmediatePropagation\(\);\n\s*\};/m, pointerMoveCode);
+code = code.replace(/onTouchKeyboardPointerUp = \(event: PointerEvent\) => \{[\s\S]*?if \(event\.pointerType \!\=\= "touch" \|\| touchGatePointerId \!\=\= event.pointerId\) \{\n\s*return;\n\s*\}/m, pointerUpCode);
+
+fs.writeFileSync('src/components/TerminalPanel.vue', code);
diff --git a/pxterm/fix_prevent_default.cjs b/pxterm/fix_prevent_default.cjs
new file mode 100644
index 0000000..b07c959
--- /dev/null
+++ b/pxterm/fix_prevent_default.cjs
@@ -0,0 +1,8 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/components/TerminalPanel.vue', 'utf8');
+
+const badPattern = /\/\/ event\.stopImmediatePropagation\(\);\n\s*if \(event\.cancelable\)\s*\{\n\s*event\.preventDefault\(\); \/\/ 我们使用 JS 控制滚动,必须禁止系统原生滚动争抢\n\s*\}/g;
+
+code = code.replace(badPattern, 'event.stopImmediatePropagation();');
+
+fs.writeFileSync('src/components/TerminalPanel.vue', code);
diff --git a/pxterm/fix_selection_conflict.cjs b/pxterm/fix_selection_conflict.cjs
new file mode 100644
index 0000000..3fe735a
--- /dev/null
+++ b/pxterm/fix_selection_conflict.cjs
@@ -0,0 +1,9 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/components/TerminalPanel.vue', 'utf8');
+
+// I commented out all stopImmediatePropagation! That's bad for click/native selection.
+// Let's put back ONLY the one in stopImmediatePropagation for PASS_NATIVE etc, but leave touchmove alone.
+
+// Actually I just use regex to revert everything and do it right.
+code = code.replace(/\/\/ event\.stopImmediatePropagation\(\);/g, 'event.stopImmediatePropagation();');
+fs.writeFileSync('src/components/TerminalPanel.vue', code);
diff --git a/pxterm/fix_touch_action.cjs b/pxterm/fix_touch_action.cjs
new file mode 100644
index 0000000..89414c2
--- /dev/null
+++ b/pxterm/fix_touch_action.cjs
@@ -0,0 +1,9 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/styles/main.css', 'utf8');
+
+code = code.replace(/\.xterm-screen {\n touch-action: none !important;\n}\n\n\n/g, '');
+code = code.replace(/\.xterm-text-layer {\n touch-action: none !important;\n}\n\n\n/g, '');
+code = code.replace(/\.xterm-rows {\n touch-action: none !important;\n}\n/g, '');
+code = code.replace(/touch-action: none !important;\n touch-action: none !important;\n/g, '');
+
+fs.writeFileSync('src/styles/main.css', code);
diff --git a/pxterm/fix_touch_action.js b/pxterm/fix_touch_action.js
new file mode 100644
index 0000000..b3ce58e
--- /dev/null
+++ b/pxterm/fix_touch_action.js
@@ -0,0 +1,4 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/styles/main.css', 'utf8');
+code = code.replace(/touch-action: none !important;/g, 'touch-action: pan-y !important;');
+fs.writeFileSync('src/styles/main.css', code);
diff --git a/pxterm/fix_touch_action2.cjs b/pxterm/fix_touch_action2.cjs
new file mode 100644
index 0000000..ae3993f
--- /dev/null
+++ b/pxterm/fix_touch_action2.cjs
@@ -0,0 +1,8 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/styles/main.css', 'utf8');
+
+// I put touch-action on xterm-viewport... but wait, the screen you drag on is the `xterm-screen` canvas!
+// We need touch-action: none on .xterm-screen, .xterm-text-layer, and .xterm-viewport.
+code += '\n\n.xterm-rows {\n touch-action: none !important;\n}\n';
+
+fs.writeFileSync('src/styles/main.css', code);
diff --git a/pxterm/fix_touch_css.cjs b/pxterm/fix_touch_css.cjs
new file mode 100644
index 0000000..59b5dd2
--- /dev/null
+++ b/pxterm/fix_touch_css.cjs
@@ -0,0 +1,13 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/styles/main.css', 'utf8');
+
+// The reason touchmove stops is because touch events on iOS are sometimes silently swallowed if touch-action is not correct.
+// Actually, earlier you saw the logs: only `pointermove` is logging!! The `touchmove` ONLY LOGS ONCE!
+// Safari 13+ on iOS stops emitting touchmove if it emits pointermove, UNLESS we call e.preventDefault() on the touchmove, but since we didn't get touchmove... Wait.
+// Safari emits touchstart -> pointerdown -> touchmove (if not scrolling) / pointermove.
+// Actually, the iOS browser decides it's doing a native scroll, so it cancels the `touchmove`. But wait, there was NO `touchcancel`!
+// What if iOS is using `pointermove` instead of `touchmove` entirely for the rest of the gesture?
+// YES! Pointer events are replacing Touch events in Safari for some logic!
+// Our `touchmove` handler was the only one syncing the scroll:
+// \`onTouchKeyboardTouchMove = (event: TouchEvent) => { ... }\`
+// We IGNORED pointermove!
diff --git a/pxterm/fix_touch_handlers.cjs b/pxterm/fix_touch_handlers.cjs
new file mode 100644
index 0000000..7f67aaf
--- /dev/null
+++ b/pxterm/fix_touch_handlers.cjs
@@ -0,0 +1,27 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/components/TerminalPanel.vue', 'utf8');
+
+// I need to ensure touchmove event preventDefault is working nicely WITH touch-action: none.
+// In iOS Safari, if touch-action is none, you often don't even need preventDefault for scrolling, but since xterm is a beast, we need to completely bypass it.
+
+// Let's rewrite the touchmove prevent default logic:
+// Sometimes stopImmediatePropagation stops our own handlers if there are others attached manually to the DOM.
+
+// Since xterm also attaches to `.xterm-viewport` and `.xterm-screen`, we use the capture phase on `containerRef.value`.
+// The issue: "手指一直滑动,但屏幕没有滚动 但也没有触发 touchcancel"
+// Wait! If there is no touchcancel and no touchend... how did `pointerup` trigger?
+// Because pointer events are synthesized by iOS Safari!
+// In Safari, if a pointer starts but the system decides it is a scroll, it CANCELS the pointer (pointercancel).
+// But here, we got POINTER UP. This means Safari thought it was a click/drag, NOT a system scroll!
+// But why did touchmove stop firing?
+// In iOS, if you do `preventDefault()` on `touchstart` or `touchmove`, it disables system scrolling and gives you ALL `touchmove` events.
+// Let's check `touchstart`. We did NOT call `preventDefault()` on `touchstart`.
+// Let's call `preventDefault()` on `touchstart` when `touchGateHadSelectionAtStart` is false!
+
+code = code.replace(/onTouchKeyboardTouchStart = \(event: TouchEvent\) => \{/, `onTouchKeyboardTouchStart = (event: TouchEvent) => {
+ // 必须阻止 touchstart 默认行为,否则 iOS 会尝试将其识别为原生手势而截断后续 touchmove
+ if (event.cancelable && !hasActiveNativeSelectionInTerminal()) {
+ event.preventDefault();
+ }`);
+
+fs.writeFileSync('src/components/TerminalPanel.vue', code);
diff --git a/pxterm/fix_ts.cjs b/pxterm/fix_ts.cjs
new file mode 100644
index 0000000..128c91e
--- /dev/null
+++ b/pxterm/fix_ts.cjs
@@ -0,0 +1,8 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/components/TerminalPanel.vue', 'utf8');
+
+code = code.replace(/viewportScroller\.addEventListener\(type, /g, 'viewportScroller!.addEventListener(type, ');
+code = code.replace(/const threshold = 0\.2;/g, 'const threshold_v = 0.2;');
+code = code.replace(/threshold\)/g, 'threshold_v)');
+
+fs.writeFileSync('src/components/TerminalPanel.vue', code);
diff --git a/pxterm/fix_ts_2.cjs b/pxterm/fix_ts_2.cjs
new file mode 100644
index 0000000..b85b232
--- /dev/null
+++ b/pxterm/fix_ts_2.cjs
@@ -0,0 +1,8 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/components/TerminalPanel.vue', 'utf8');
+
+code = code.replace(/const threshold_v = 0\.2;/g, '');
+code = code.replace(/threshold_v/g, '0.2');
+code = code.replace(/Math\.abs\(touchScrollVelocity\) > threshold && /g, 'Math.abs(touchScrollVelocity) > 0.2 && ');
+
+fs.writeFileSync('src/components/TerminalPanel.vue', code);
diff --git a/pxterm/get_dom_structure.js b/pxterm/get_dom_structure.js
new file mode 100644
index 0000000..cd484dc
--- /dev/null
+++ b/pxterm/get_dom_structure.js
@@ -0,0 +1 @@
+// Pseudo script, we can't get actual DOM tree of a running client easily.
diff --git a/pxterm/hook_pointer_scroll.cjs b/pxterm/hook_pointer_scroll.cjs
new file mode 100644
index 0000000..7922ea8
--- /dev/null
+++ b/pxterm/hook_pointer_scroll.cjs
@@ -0,0 +1,64 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/components/TerminalPanel.vue', 'utf8');
+
+// Inside `onTouchKeyboardPointerMove`: we can perform the actual scrolling!
+const pointerMoveRegex = /onTouchKeyboardPointerMove = \(event: PointerEvent\) => \{[\s\S]*?touchGateScrollLike = true;\n\s*\}/m;
+// Let's replace the whole onTouchKeyboardPointerMove function to sync scrolling instead of just detecting it.
+// Actually, earlier the user showed that the `touchmove` stopped. Why did `touchmove` stop?
+// Because we have `touch-action: none`?
+// iOS Safari requires `e.preventDefault()` on `touchmove` to keep the `touchmove` stream alive, but you have to do it BEFORE the compositor steals it.
+// We added: `if (event.cancelable && !hasActiveNativeSelectionInTerminal()) { event.preventDefault(); }` to `touchstart`!
+// If we call preventDefault on touchstart, NO POINTER EVENTS or touch events should be stolen by scroll. BUT touchmove STILL DISAPPEARED?
+// Wait, if we call preventDefault on touchstart, does that disable pointermove? Or touchmove?
+// Actually if `touch-action: none` is present, pointer events fire constantly, while touch events MIGHT ONLY FIRE IF POINTER EVENTS DON'T CANCEL THEM.
+
+code = code.replace(/onTouchKeyboardPointerMove = \(event: PointerEvent\) => \{[\s\S]*?\}\;\n onTouchKeyboardPointerUp/m,
+` onTouchKeyboardPointerMove = (event: PointerEvent) => {
+ if (event.pointerType !== "touch" || touchGatePointerId !== event.pointerId) {
+ return;
+ }
+
+ const dx = event.clientX - touchGateStartX;
+ const dy = event.clientY - touchGateStartY;
+ const absDx = Math.abs(dx);
+ const absDy = Math.abs(dy);
+
+ if (absDx > TOUCH_KEYBOARD_TAP_MAX_MOVE_PX || absDy > TOUCH_KEYBOARD_TAP_MAX_MOVE_PX) {
+ touchGateMoved = true;
+ }
+ if (absDy > absDx && absDy > TOUCH_KEYBOARD_TAP_MAX_MOVE_PX) {
+ touchGateScrollLike = true;
+ }
+
+ // --- Pointer Scroll Sync ---
+ const hasSelection = hasActiveNativeSelectionInTerminal();
+ if (!hasSelection && viewportScroller) {
+ // Stop system gestures like swipe back if needed
+ event.stopImmediatePropagation();
+
+ const now = performance.now();
+ const dt = now - touchScrollLastTime;
+ const currentY = event.clientY;
+ const deltaY = currentY - (touchScrollLastY || currentY);
+
+ if (dt > 0 && touchScrollLastTime > 0) {
+ viewportScroller.scrollTop -= deltaY;
+
+ const v = (-deltaY / dt) * 16;
+ if (touchScrollVelocity * v > 0) {
+ touchScrollVelocity = touchScrollVelocity * 0.5 + v * 0.5;
+ } else {
+ touchScrollVelocity = v;
+ }
+ const touchMaxSpeed = 120;
+ if (touchScrollVelocity > touchMaxSpeed) touchScrollVelocity = touchMaxSpeed;
+ else if (touchScrollVelocity < -touchMaxSpeed) touchScrollVelocity = -touchMaxSpeed;
+ }
+
+ touchScrollLastY = currentY;
+ touchScrollLastTime = now;
+ }
+ };
+ onTouchKeyboardPointerUp`);
+
+fs.writeFileSync('src/components/TerminalPanel.vue', code);
diff --git a/pxterm/index.html b/pxterm/index.html
new file mode 100644
index 0000000..777bc59
--- /dev/null
+++ b/pxterm/index.html
@@ -0,0 +1,15 @@
+
+
+
+
+
+ RemoteConn
+
+
+
+
+
+
diff --git a/pxterm/mobile-scroll-optimization.md b/pxterm/mobile-scroll-optimization.md
new file mode 100644
index 0000000..35da56c
--- /dev/null
+++ b/pxterm/mobile-scroll-optimization.md
@@ -0,0 +1,38 @@
+# 移动端 Xterm.js 滚动优化总结 (Mobile Scroll Optimization)
+
+## 1. 之前的问题现象 (The Problem)
+在移动端(特别是 iOS Safari/WebKit)的 `xterm.js` 终端界面中,用户上下滑动时经常会遇到 **卡顿** 或 **突然冻结**。具体的表现是:手指在屏幕上持续滑动,但屏幕内容不再发生滚动。
+
+## 2. 根本原因 (Root Cause)
+通过在 `window` 级别捕获全局手指移动日志,我们发现了导致“屏幕结冰”的确切原因:
+1. **浏览器原生手势劫持 (Native Gesture Hijacking)**:移动端浏览器(OS 级别)默认会接管屏幕上的 `touch` 事件,用于判断是否需要进行页面滚动、双击放大或边缘返回等原生手势。
+2. **`touchmove` 事件被吞噬**:`xterm.js` 处于 DOM 渲染模式时,内部生成了大量的 `span` 和 `div` 节点。当手指在这些复杂的层叠节点上滑动时,系统的手势识别引擎一旦认定这是系统的原生滚动行为,就会**强制中断并停止派发 JS 层的 `touchmove` 事件**(或者派发 `touchcancel`),导致我们原有的滚动计算逻辑中途得不到坐标更新,画面即刻冻结。
+
+## 3. 演进与精细化方案 (Evolution & Refined Solution)
+单纯使用“防劫持猛药”能解决滚动断流问题,但会导致一个严重的副作用:**iOS 原生的长按文本选择功能(放大镜与高亮选区)会失效。**
+为此,我们抛弃了全局霸道的 `touch-action: none` 与过早的 `preventDefault`,转而使用“动态拦截(Dynamic Interception)”组合拳:
+
+### A. 撤销 CSS 暴力禁用,依赖 JS 动态阻止
+不要在终端交互区强加 `touch-action: none !important;`。必须保留浏览器默认的触摸能力,否则底层 OS 根本不会触发“长按开启选区”的计时器。
+
+### B. Touch Start 阶段:坚决放行 (Allow to Timer)
+在 `touchstart` 甚至 `pointerdown` 阶段,**绝对不再调用 `preventDefault()`**。
+- **作用**:确保手指刚摸到屏幕时,原生浏览器可以“观察”这次触屏是否可能变成一次“长按唤醒放大镜和选区”的操作。
+
+### C. Touch Move 阶段:精准拦截与驱动 (Precise Interception)
+这是最核心的一步:在持续跟手的 `touchmove` 事件中,依靠 JS 来判断到底该阻止系统,还是让渡给系统。
+- **如果已经有系统选区 (`hasActiveNativeSelectionInTerminal() === true`)**:
+ 说明用户正在拖拽选区的把手。此时代码**不拦截**,顺从原生的触摸动作,全权交由系统处理文本选取。
+- **如果没有选区 (`false`)**:
+ 说明用户大概率在普通滑动。此时马上执行 `event.preventDefault()`,**强行掐断系统的原生滚动尝试**,以防止 `touchmove` 被系统没收,并将 `dy` 直接交给我们的“自定义JS滚动”。
+
+### D. 自定义动量滚动 (Custom Momentum JS Scroll)
+依赖于不被吞噬的连续 move 事件,我们实现了高性能的滚动接管:
+1. **跟手滑动**:实时计算两次触屏事件的 `dy` (delta Y),直接映射 `viewportScroller.scrollTop -= dy`,实现完全的1:1跟手。
+2. **惯性衰减 (Momentum)**:记录滑动撒手 (`touchend`) 瞬间的最后速度,将其转入 `requestAnimationFrame` 驱动的动画循环中进行指数级衰减,精美模拟原本仅有 OS 层才能给到的平滑阻尼滚动感受。
+
+## 4. 最终达成的效果 (Final Results)
+凭借这种高度克制且动态的干扰策略,我们实现了“鱼与熊掌兼得”:
+1. **完美原生选区**:长按仍能顺畅唤起 iOS 文本放大镜与蓝色选框。
+2. **不断流的跟手滚动**:只要没有进入选区模式,手指在密集的字符网格上狂滑也不会触发浏览器原生手势劫持,配合 60fps 的 JS 帧动画达到了极致丝滑。
+3. **软键盘完整兼容**:没有使用全局焦点的强行剥夺,不阻碍日常的点击输入与键盘显隐。
\ No newline at end of file
diff --git a/pxterm/package-lock.json b/pxterm/package-lock.json
new file mode 100644
index 0000000..8d5e1b3
--- /dev/null
+++ b/pxterm/package-lock.json
@@ -0,0 +1,2041 @@
+{
+ "name": "@remoteconn/web",
+ "version": "1.0.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "@remoteconn/web",
+ "version": "1.0.0",
+ "dependencies": {
+ "@remoteconn/plugin-runtime": "file:./packages/plugin-runtime",
+ "@remoteconn/shared": "file:./packages/shared",
+ "dexie": "^4.2.0",
+ "pinia": "^3.0.3",
+ "vue": "^3.5.21",
+ "vue-router": "^4.5.1",
+ "xterm": "^5.3.0",
+ "xterm-addon-fit": "^0.8.0",
+ "xterm-addon-search": "^0.13.0",
+ "xterm-addon-unicode11": "^0.6.0",
+ "xterm-addon-webgl": "^0.16.0"
+ },
+ "devDependencies": {
+ "@types/node": "^25.3.3",
+ "@vitejs/plugin-vue": "^6.0.4",
+ "typescript": "^5.9.3",
+ "vite": "^7.3.1",
+ "vitest": "^4.0.18",
+ "vue-tsc": "^3.2.5"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.27.1",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+ "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.28.5",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+ "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
+ "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.0"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.0",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
+ "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.27.1",
+ "@babel/helper-validator-identifier": "^7.28.5"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz",
+ "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz",
+ "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz",
+ "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz",
+ "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz",
+ "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz",
+ "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz",
+ "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz",
+ "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz",
+ "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz",
+ "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz",
+ "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz",
+ "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz",
+ "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz",
+ "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz",
+ "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz",
+ "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz",
+ "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz",
+ "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz",
+ "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz",
+ "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz",
+ "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz",
+ "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz",
+ "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz",
+ "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "license": "MIT"
+ },
+ "node_modules/@remoteconn/plugin-runtime": {
+ "resolved": "packages/plugin-runtime",
+ "link": true
+ },
+ "node_modules/@remoteconn/shared": {
+ "resolved": "packages/shared",
+ "link": true
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-rc.2",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.2.tgz",
+ "integrity": "sha512-izyXV/v+cHiRfozX62W9htOAvwMo4/bXKDrQ+vom1L1qRuexPock/7VZDAhnpHCLNejd3NJ6hiab+tO0D44Rgw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz",
+ "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz",
+ "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz",
+ "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz",
+ "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz",
+ "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz",
+ "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz",
+ "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz",
+ "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz",
+ "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz",
+ "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz",
+ "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz",
+ "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz",
+ "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz",
+ "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz",
+ "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz",
+ "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz",
+ "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz",
+ "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz",
+ "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz",
+ "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz",
+ "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz",
+ "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz",
+ "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz",
+ "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@standard-schema/spec": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
+ "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/chai": {
+ "version": "5.2.3",
+ "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
+ "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/deep-eql": "*",
+ "assertion-error": "^2.0.1"
+ }
+ },
+ "node_modules/@types/deep-eql": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
+ "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.8",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
+ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "25.3.3",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-25.3.3.tgz",
+ "integrity": "sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~7.18.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-vue": {
+ "version": "6.0.4",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.4.tgz",
+ "integrity": "sha512-uM5iXipgYIn13UUQCZNdWkYk+sysBeA97d5mHsAoAt1u/wpN3+zxOmsVJWosuzX+IMGRzeYUNytztrYznboIkQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@rolldown/pluginutils": "1.0.0-rc.2"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "peerDependencies": {
+ "vite": "^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0",
+ "vue": "^3.2.25"
+ }
+ },
+ "node_modules/@vitest/expect": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
+ "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@types/chai": "^5.2.2",
+ "@vitest/spy": "4.0.18",
+ "@vitest/utils": "4.0.18",
+ "chai": "^6.2.1",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/mocker": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
+ "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/spy": "4.0.18",
+ "estree-walker": "^3.0.3",
+ "magic-string": "^0.30.21"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "msw": "^2.4.9",
+ "vite": "^6.0.0 || ^7.0.0-0"
+ },
+ "peerDependenciesMeta": {
+ "msw": {
+ "optional": true
+ },
+ "vite": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@vitest/mocker/node_modules/estree-walker": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
+ "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "^1.0.0"
+ }
+ },
+ "node_modules/@vitest/pretty-format": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
+ "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/runner": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
+ "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/utils": "4.0.18",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/snapshot": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
+ "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.0.18",
+ "magic-string": "^0.30.21",
+ "pathe": "^2.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/spy": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
+ "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@vitest/utils": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
+ "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/pretty-format": "4.0.18",
+ "tinyrainbow": "^3.0.3"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ }
+ },
+ "node_modules/@volar/language-core": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz",
+ "integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/source-map": "2.4.28"
+ }
+ },
+ "node_modules/@volar/source-map": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/source-map/-/source-map-2.4.28.tgz",
+ "integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@volar/typescript": {
+ "version": "2.4.28",
+ "resolved": "https://registry.npmjs.org/@volar/typescript/-/typescript-2.4.28.tgz",
+ "integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.28",
+ "path-browserify": "^1.0.1",
+ "vscode-uri": "^3.0.8"
+ }
+ },
+ "node_modules/@vue/compiler-core": {
+ "version": "3.5.29",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.29.tgz",
+ "integrity": "sha512-cuzPhD8fwRHk8IGfmYaR4eEe4cAyJEL66Ove/WZL7yWNL134nqLddSLwNRIsFlnnW1kK+p8Ck3viFnC0chXCXw==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@vue/shared": "3.5.29",
+ "entities": "^7.0.1",
+ "estree-walker": "^2.0.2",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-dom": {
+ "version": "3.5.29",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.29.tgz",
+ "integrity": "sha512-n0G5o7R3uBVmVxjTIYcz7ovr8sy7QObFG8OQJ3xGCDNhbG60biP/P5KnyY8NLd81OuT1WJflG7N4KWYHaeeaIg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-core": "3.5.29",
+ "@vue/shared": "3.5.29"
+ }
+ },
+ "node_modules/@vue/compiler-sfc": {
+ "version": "3.5.29",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.29.tgz",
+ "integrity": "sha512-oJZhN5XJs35Gzr50E82jg2cYdZQ78wEwvRO6Y63TvLVTc+6xICzJHP1UIecdSPPYIbkautNBanDiWYa64QSFIA==",
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.0",
+ "@vue/compiler-core": "3.5.29",
+ "@vue/compiler-dom": "3.5.29",
+ "@vue/compiler-ssr": "3.5.29",
+ "@vue/shared": "3.5.29",
+ "estree-walker": "^2.0.2",
+ "magic-string": "^0.30.21",
+ "postcss": "^8.5.6",
+ "source-map-js": "^1.2.1"
+ }
+ },
+ "node_modules/@vue/compiler-ssr": {
+ "version": "3.5.29",
+ "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.29.tgz",
+ "integrity": "sha512-Y/ARJZE6fpjzL5GH/phJmsFwx3g6t2KmHKHx5q+MLl2kencADKIrhH5MLF6HHpRMmlRAYBRSvv347Mepf1zVNw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.29",
+ "@vue/shared": "3.5.29"
+ }
+ },
+ "node_modules/@vue/devtools-api": {
+ "version": "7.7.9",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
+ "integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-kit": "^7.7.9"
+ }
+ },
+ "node_modules/@vue/devtools-kit": {
+ "version": "7.7.9",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
+ "integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-shared": "^7.7.9",
+ "birpc": "^2.3.0",
+ "hookable": "^5.5.3",
+ "mitt": "^3.0.1",
+ "perfect-debounce": "^1.0.0",
+ "speakingurl": "^14.0.1",
+ "superjson": "^2.2.2"
+ }
+ },
+ "node_modules/@vue/devtools-shared": {
+ "version": "7.7.9",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
+ "integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
+ "license": "MIT",
+ "dependencies": {
+ "rfdc": "^1.4.1"
+ }
+ },
+ "node_modules/@vue/language-core": {
+ "version": "3.2.5",
+ "resolved": "https://registry.npmjs.org/@vue/language-core/-/language-core-3.2.5.tgz",
+ "integrity": "sha512-d3OIxN/+KRedeM5wQ6H6NIpwS3P5gC9nmyaHgBk+rO6dIsjY+tOh4UlPpiZbAh3YtLdCGEX4M16RmsBqPmJV+g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/language-core": "2.4.28",
+ "@vue/compiler-dom": "^3.5.0",
+ "@vue/shared": "^3.5.0",
+ "alien-signals": "^3.0.0",
+ "muggle-string": "^0.4.1",
+ "path-browserify": "^1.0.1",
+ "picomatch": "^4.0.2"
+ }
+ },
+ "node_modules/@vue/reactivity": {
+ "version": "3.5.29",
+ "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.29.tgz",
+ "integrity": "sha512-zcrANcrRdcLtmGZETBxWqIkoQei8HaFpZWx/GHKxx79JZsiZ8j1du0VUJtu4eJjgFvU/iKL5lRXFXksVmI+5DA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/shared": "3.5.29"
+ }
+ },
+ "node_modules/@vue/runtime-core": {
+ "version": "3.5.29",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.29.tgz",
+ "integrity": "sha512-8DpW2QfdwIWOLqtsNcds4s+QgwSaHSJY/SUe04LptianUQ/0xi6KVsu/pYVh+HO3NTVvVJjIPL2t6GdeKbS4Lg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.29",
+ "@vue/shared": "3.5.29"
+ }
+ },
+ "node_modules/@vue/runtime-dom": {
+ "version": "3.5.29",
+ "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.29.tgz",
+ "integrity": "sha512-AHvvJEtcY9tw/uk+s/YRLSlxxQnqnAkjqvK25ZiM4CllCZWzElRAoQnCM42m9AHRLNJ6oe2kC5DCgD4AUdlvXg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/reactivity": "3.5.29",
+ "@vue/runtime-core": "3.5.29",
+ "@vue/shared": "3.5.29",
+ "csstype": "^3.2.3"
+ }
+ },
+ "node_modules/@vue/server-renderer": {
+ "version": "3.5.29",
+ "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.29.tgz",
+ "integrity": "sha512-G/1k6WK5MusLlbxSE2YTcqAAezS+VuwHhOvLx2KnQU7G2zCH6KIb+5Wyt6UjMq7a3qPzNEjJXs1hvAxDclQH+g==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-ssr": "3.5.29",
+ "@vue/shared": "3.5.29"
+ },
+ "peerDependencies": {
+ "vue": "3.5.29"
+ }
+ },
+ "node_modules/@vue/shared": {
+ "version": "3.5.29",
+ "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.29.tgz",
+ "integrity": "sha512-w7SR0A5zyRByL9XUkCfdLs7t9XOHUyJ67qPGQjOou3p6GvBeBW+AVjUUmlxtZ4PIYaRvE+1LmK44O4uajlZwcg==",
+ "license": "MIT"
+ },
+ "node_modules/alien-signals": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/alien-signals/-/alien-signals-3.1.2.tgz",
+ "integrity": "sha512-d9dYqZTS90WLiU0I5c6DHj/HcKkF8ZyGN3G5x8wSbslulz70KOxaqCT0hQCo9KOyhVqzqGojvNdJXoTumZOtcw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/assertion-error": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
+ "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
+ "node_modules/birpc": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/birpc/-/birpc-2.9.0.tgz",
+ "integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/antfu"
+ }
+ },
+ "node_modules/chai": {
+ "version": "6.2.2",
+ "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
+ "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/copy-anything": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/copy-anything/-/copy-anything-4.0.5.tgz",
+ "integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
+ "license": "MIT",
+ "dependencies": {
+ "is-what": "^5.2.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mesqueeb"
+ }
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "license": "MIT"
+ },
+ "node_modules/dexie": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/dexie/-/dexie-4.3.0.tgz",
+ "integrity": "sha512-5EeoQpJvMKHe6zWt/FSIIuRa3CWlZeIl6zKXt+Lz7BU6RoRRLgX9dZEynRfXrkLcldKYCBiz7xekTEylnie1Ug==",
+ "license": "Apache-2.0"
+ },
+ "node_modules/entities": {
+ "version": "7.0.1",
+ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
+ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=0.12"
+ },
+ "funding": {
+ "url": "https://github.com/fb55/entities?sponsor=1"
+ }
+ },
+ "node_modules/es-module-lexer": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
+ "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/esbuild": {
+ "version": "0.27.3",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz",
+ "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.27.3",
+ "@esbuild/android-arm": "0.27.3",
+ "@esbuild/android-arm64": "0.27.3",
+ "@esbuild/android-x64": "0.27.3",
+ "@esbuild/darwin-arm64": "0.27.3",
+ "@esbuild/darwin-x64": "0.27.3",
+ "@esbuild/freebsd-arm64": "0.27.3",
+ "@esbuild/freebsd-x64": "0.27.3",
+ "@esbuild/linux-arm": "0.27.3",
+ "@esbuild/linux-arm64": "0.27.3",
+ "@esbuild/linux-ia32": "0.27.3",
+ "@esbuild/linux-loong64": "0.27.3",
+ "@esbuild/linux-mips64el": "0.27.3",
+ "@esbuild/linux-ppc64": "0.27.3",
+ "@esbuild/linux-riscv64": "0.27.3",
+ "@esbuild/linux-s390x": "0.27.3",
+ "@esbuild/linux-x64": "0.27.3",
+ "@esbuild/netbsd-arm64": "0.27.3",
+ "@esbuild/netbsd-x64": "0.27.3",
+ "@esbuild/openbsd-arm64": "0.27.3",
+ "@esbuild/openbsd-x64": "0.27.3",
+ "@esbuild/openharmony-arm64": "0.27.3",
+ "@esbuild/sunos-x64": "0.27.3",
+ "@esbuild/win32-arm64": "0.27.3",
+ "@esbuild/win32-ia32": "0.27.3",
+ "@esbuild/win32-x64": "0.27.3"
+ }
+ },
+ "node_modules/estree-walker": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
+ "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+ "license": "MIT"
+ },
+ "node_modules/expect-type": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
+ "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=12.0.0"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/hookable": {
+ "version": "5.5.3",
+ "resolved": "https://registry.npmjs.org/hookable/-/hookable-5.5.3.tgz",
+ "integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
+ "license": "MIT"
+ },
+ "node_modules/is-what": {
+ "version": "5.5.0",
+ "resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
+ "integrity": "sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/mesqueeb"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/mitt": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
+ "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
+ "license": "MIT"
+ },
+ "node_modules/muggle-string": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/muggle-string/-/muggle-string-0.4.1.tgz",
+ "integrity": "sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.11",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
+ "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/obug": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
+ "integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
+ "dev": true,
+ "funding": [
+ "https://github.com/sponsors/sxzz",
+ "https://opencollective.com/debug"
+ ],
+ "license": "MIT"
+ },
+ "node_modules/path-browserify": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
+ "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/pathe": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
+ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/perfect-debounce": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
+ "integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
+ "license": "MIT"
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
+ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/pinia": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
+ "integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-api": "^7.7.7"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "typescript": ">=4.5.0",
+ "vue": "^3.5.11"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.8",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz",
+ "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==",
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.11",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/rfdc": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
+ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
+ "license": "MIT"
+ },
+ "node_modules/rollup": {
+ "version": "4.59.0",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz",
+ "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.8"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.59.0",
+ "@rollup/rollup-android-arm64": "4.59.0",
+ "@rollup/rollup-darwin-arm64": "4.59.0",
+ "@rollup/rollup-darwin-x64": "4.59.0",
+ "@rollup/rollup-freebsd-arm64": "4.59.0",
+ "@rollup/rollup-freebsd-x64": "4.59.0",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.59.0",
+ "@rollup/rollup-linux-arm-musleabihf": "4.59.0",
+ "@rollup/rollup-linux-arm64-gnu": "4.59.0",
+ "@rollup/rollup-linux-arm64-musl": "4.59.0",
+ "@rollup/rollup-linux-loong64-gnu": "4.59.0",
+ "@rollup/rollup-linux-loong64-musl": "4.59.0",
+ "@rollup/rollup-linux-ppc64-gnu": "4.59.0",
+ "@rollup/rollup-linux-ppc64-musl": "4.59.0",
+ "@rollup/rollup-linux-riscv64-gnu": "4.59.0",
+ "@rollup/rollup-linux-riscv64-musl": "4.59.0",
+ "@rollup/rollup-linux-s390x-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-gnu": "4.59.0",
+ "@rollup/rollup-linux-x64-musl": "4.59.0",
+ "@rollup/rollup-openbsd-x64": "4.59.0",
+ "@rollup/rollup-openharmony-arm64": "4.59.0",
+ "@rollup/rollup-win32-arm64-msvc": "4.59.0",
+ "@rollup/rollup-win32-ia32-msvc": "4.59.0",
+ "@rollup/rollup-win32-x64-gnu": "4.59.0",
+ "@rollup/rollup-win32-x64-msvc": "4.59.0",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/siginfo": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
+ "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/speakingurl": {
+ "version": "14.0.1",
+ "resolved": "https://registry.npmjs.org/speakingurl/-/speakingurl-14.0.1.tgz",
+ "integrity": "sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==",
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/stackback": {
+ "version": "0.0.2",
+ "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
+ "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/std-env": {
+ "version": "3.10.0",
+ "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
+ "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/superjson": {
+ "version": "2.2.6",
+ "resolved": "https://registry.npmjs.org/superjson/-/superjson-2.2.6.tgz",
+ "integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
+ "license": "MIT",
+ "dependencies": {
+ "copy-anything": "^4"
+ },
+ "engines": {
+ "node": ">=16"
+ }
+ },
+ "node_modules/tinybench": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
+ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tinyexec": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz",
+ "integrity": "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.15",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
+ "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/tinyrainbow": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
+ "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "7.18.2",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz",
+ "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vite": {
+ "version": "7.3.1",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz",
+ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.27.0",
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.3",
+ "postcss": "^8.5.6",
+ "rollup": "^4.43.0",
+ "tinyglobby": "^0.2.15"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^20.19.0 || >=22.12.0",
+ "jiti": ">=1.21.0",
+ "less": "^4.0.0",
+ "lightningcss": "^1.21.0",
+ "sass": "^1.70.0",
+ "sass-embedded": "^1.70.0",
+ "stylus": ">=0.54.8",
+ "sugarss": "^5.0.0",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vitest": {
+ "version": "4.0.18",
+ "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
+ "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@vitest/expect": "4.0.18",
+ "@vitest/mocker": "4.0.18",
+ "@vitest/pretty-format": "4.0.18",
+ "@vitest/runner": "4.0.18",
+ "@vitest/snapshot": "4.0.18",
+ "@vitest/spy": "4.0.18",
+ "@vitest/utils": "4.0.18",
+ "es-module-lexer": "^1.7.0",
+ "expect-type": "^1.2.2",
+ "magic-string": "^0.30.21",
+ "obug": "^2.1.1",
+ "pathe": "^2.0.3",
+ "picomatch": "^4.0.3",
+ "std-env": "^3.10.0",
+ "tinybench": "^2.9.0",
+ "tinyexec": "^1.0.2",
+ "tinyglobby": "^0.2.15",
+ "tinyrainbow": "^3.0.3",
+ "vite": "^6.0.0 || ^7.0.0",
+ "why-is-node-running": "^2.3.0"
+ },
+ "bin": {
+ "vitest": "vitest.mjs"
+ },
+ "engines": {
+ "node": "^20.0.0 || ^22.0.0 || >=24.0.0"
+ },
+ "funding": {
+ "url": "https://opencollective.com/vitest"
+ },
+ "peerDependencies": {
+ "@edge-runtime/vm": "*",
+ "@opentelemetry/api": "^1.9.0",
+ "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
+ "@vitest/browser-playwright": "4.0.18",
+ "@vitest/browser-preview": "4.0.18",
+ "@vitest/browser-webdriverio": "4.0.18",
+ "@vitest/ui": "4.0.18",
+ "happy-dom": "*",
+ "jsdom": "*"
+ },
+ "peerDependenciesMeta": {
+ "@edge-runtime/vm": {
+ "optional": true
+ },
+ "@opentelemetry/api": {
+ "optional": true
+ },
+ "@types/node": {
+ "optional": true
+ },
+ "@vitest/browser-playwright": {
+ "optional": true
+ },
+ "@vitest/browser-preview": {
+ "optional": true
+ },
+ "@vitest/browser-webdriverio": {
+ "optional": true
+ },
+ "@vitest/ui": {
+ "optional": true
+ },
+ "happy-dom": {
+ "optional": true
+ },
+ "jsdom": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vscode-uri": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz",
+ "integrity": "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/vue": {
+ "version": "3.5.29",
+ "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.29.tgz",
+ "integrity": "sha512-BZqN4Ze6mDQVNAni0IHeMJ5mwr8VAJ3MQC9FmprRhcBYENw+wOAAjRj8jfmN6FLl0j96OXbR+CjWhmAmM+QGnA==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/compiler-dom": "3.5.29",
+ "@vue/compiler-sfc": "3.5.29",
+ "@vue/runtime-dom": "3.5.29",
+ "@vue/server-renderer": "3.5.29",
+ "@vue/shared": "3.5.29"
+ },
+ "peerDependencies": {
+ "typescript": "*"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/vue-router": {
+ "version": "4.6.4",
+ "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz",
+ "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
+ "license": "MIT",
+ "dependencies": {
+ "@vue/devtools-api": "^6.6.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/posva"
+ },
+ "peerDependencies": {
+ "vue": "^3.5.0"
+ }
+ },
+ "node_modules/vue-router/node_modules/@vue/devtools-api": {
+ "version": "6.6.4",
+ "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+ "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+ "license": "MIT"
+ },
+ "node_modules/vue-tsc": {
+ "version": "3.2.5",
+ "resolved": "https://registry.npmjs.org/vue-tsc/-/vue-tsc-3.2.5.tgz",
+ "integrity": "sha512-/htfTCMluQ+P2FISGAooul8kO4JMheOTCbCy4M6dYnYYjqLe3BExZudAua6MSIKSFYQtFOYAll7XobYwcpokGA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@volar/typescript": "2.4.28",
+ "@vue/language-core": "3.2.5"
+ },
+ "bin": {
+ "vue-tsc": "bin/vue-tsc.js"
+ },
+ "peerDependencies": {
+ "typescript": ">=5.0.0"
+ }
+ },
+ "node_modules/why-is-node-running": {
+ "version": "2.3.0",
+ "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
+ "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "siginfo": "^2.0.0",
+ "stackback": "0.0.2"
+ },
+ "bin": {
+ "why-is-node-running": "cli.js"
+ },
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/xterm": {
+ "version": "5.3.0",
+ "resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
+ "integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==",
+ "deprecated": "This package is now deprecated. Move to @xterm/xterm instead.",
+ "license": "MIT"
+ },
+ "node_modules/xterm-addon-fit": {
+ "version": "0.8.0",
+ "resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz",
+ "integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==",
+ "deprecated": "This package is now deprecated. Move to @xterm/addon-fit instead.",
+ "license": "MIT",
+ "peerDependencies": {
+ "xterm": "^5.0.0"
+ }
+ },
+ "node_modules/xterm-addon-search": {
+ "version": "0.13.0",
+ "resolved": "https://registry.npmjs.org/xterm-addon-search/-/xterm-addon-search-0.13.0.tgz",
+ "integrity": "sha512-sDUwG4CnqxUjSEFh676DlS3gsh3XYCzAvBPSvJ5OPgF3MRL3iHLPfsb06doRicLC2xXNpeG2cWk8x1qpESWJMA==",
+ "deprecated": "This package is now deprecated. Move to @xterm/addon-search instead.",
+ "license": "MIT",
+ "peerDependencies": {
+ "xterm": "^5.0.0"
+ }
+ },
+ "node_modules/xterm-addon-unicode11": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/xterm-addon-unicode11/-/xterm-addon-unicode11-0.6.0.tgz",
+ "integrity": "sha512-5pkb8YoS/deRtNqQRw8t640mu+Ga8B2MG3RXGQu0bwgcfr8XiXIRI880TWM49ICAHhTmnOLPzIIBIjEnCq7k2A==",
+ "deprecated": "This package is now deprecated. Move to @xterm/addon-unicode11 instead.",
+ "license": "MIT",
+ "peerDependencies": {
+ "xterm": "^5.0.0"
+ }
+ },
+ "node_modules/xterm-addon-webgl": {
+ "version": "0.16.0",
+ "resolved": "https://registry.npmjs.org/xterm-addon-webgl/-/xterm-addon-webgl-0.16.0.tgz",
+ "integrity": "sha512-E8cq1AiqNOv0M/FghPT+zPAEnvIQRDbAbkb04rRYSxUym69elPWVJ4sv22FCLBqM/3LcrmBLl/pELnBebVFKgA==",
+ "deprecated": "This package is now deprecated. Move to @xterm/addon-webgl instead.",
+ "license": "MIT",
+ "peerDependencies": {
+ "xterm": "^5.0.0"
+ }
+ },
+ "packages/plugin-runtime": {
+ "name": "@remoteconn/plugin-runtime",
+ "version": "1.0.0",
+ "dependencies": {
+ "@remoteconn/shared": "1.0.0"
+ }
+ },
+ "packages/shared": {
+ "name": "@remoteconn/shared",
+ "version": "1.0.0"
+ }
+ }
+}
diff --git a/pxterm/package.json b/pxterm/package.json
new file mode 100644
index 0000000..b6f31ed
--- /dev/null
+++ b/pxterm/package.json
@@ -0,0 +1,34 @@
+{
+ "name": "@remoteconn/web",
+ "version": "1.0.0",
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vue-tsc --noEmit && vite build && node ../../scripts/check-web-bundle-size.mjs",
+ "preview": "vite preview",
+ "typecheck": "vue-tsc --noEmit",
+ "lint": "eslint src --ext .ts,.vue",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "@remoteconn/plugin-runtime": "file:./packages/plugin-runtime",
+ "@remoteconn/shared": "file:./packages/shared",
+ "dexie": "^4.2.0",
+ "pinia": "^3.0.3",
+ "vue": "^3.5.21",
+ "vue-router": "^4.5.1",
+ "xterm": "^5.3.0",
+ "xterm-addon-fit": "^0.8.0",
+ "xterm-addon-search": "^0.13.0",
+ "xterm-addon-unicode11": "^0.6.0",
+ "xterm-addon-webgl": "^0.16.0"
+ },
+ "devDependencies": {
+ "@types/node": "^25.3.3",
+ "@vitejs/plugin-vue": "^6.0.4",
+ "typescript": "^5.9.3",
+ "vite": "^7.3.1",
+ "vitest": "^4.0.18",
+ "vue-tsc": "^3.2.5"
+ }
+}
diff --git a/pxterm/packages/plugin-runtime/package.json b/pxterm/packages/plugin-runtime/package.json
new file mode 100644
index 0000000..13d5cac
--- /dev/null
+++ b/pxterm/packages/plugin-runtime/package.json
@@ -0,0 +1,19 @@
+{
+ "name": "@remoteconn/plugin-runtime",
+ "version": "1.0.0",
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "typecheck": "tsc -p tsconfig.json --noEmit",
+ "lint": "eslint src --ext .ts",
+ "test": "vitest run"
+ },
+ "dependencies": {
+ "@remoteconn/shared": "1.0.0"
+ }
+}
diff --git a/pxterm/packages/plugin-runtime/src/core/pluginManager.ts b/pxterm/packages/plugin-runtime/src/core/pluginManager.ts
new file mode 100644
index 0000000..6f43de9
--- /dev/null
+++ b/pxterm/packages/plugin-runtime/src/core/pluginManager.ts
@@ -0,0 +1,311 @@
+import type { PluginHostApis, PluginPackage, PluginRecord, PluginFsAdapter, PluginCommand } from "../types/plugin";
+import { validatePluginPackage } from "./validator";
+
+interface RuntimeSlot {
+ pluginId: string;
+ cleanupFns: Array<() => void>;
+ styleDisposer?: () => void;
+ api?: {
+ onload?: (ctx: unknown) => Promise | void;
+ onunload?: () => Promise | void;
+ };
+}
+
+/**
+ * 生产可用插件管理器:
+ * 1. 安装校验
+ * 2. 生命周期
+ * 3. 单插件熔断
+ * 4. 权限最小化
+ */
+export class PluginManager {
+ private readonly records = new Map();
+ private readonly runtime = new Map();
+ private readonly commands = new Map();
+
+ public constructor(
+ private readonly fs: PluginFsAdapter,
+ private readonly apis: PluginHostApis,
+ private readonly options: {
+ appVersion: string;
+ mountStyle: (pluginId: string, css: string) => () => void;
+ logger: (level: "info" | "warn" | "error", pluginId: string, message: string) => void;
+ }
+ ) {}
+
+ public async bootstrap(): Promise {
+ const stored = await this.fs.readStore("plugin_records", []);
+ for (const record of stored) {
+ this.records.set(record.id, record);
+ }
+
+ const packages = await this.fs.listPackages();
+ for (const item of packages) {
+ if (!this.records.has(item.manifest.id)) {
+ this.records.set(item.manifest.id, this.createRecord(item.manifest.id));
+ }
+ }
+
+ await this.persistRecords();
+ for (const record of this.records.values()) {
+ if (record.enabled) {
+ try {
+ await this.enable(record.id);
+ } catch (error) {
+ this.options.logger("error", record.id, `自动启用失败: ${(error as Error).message}`);
+ }
+ }
+ }
+ }
+
+ public async installPackage(payload: PluginPackage): Promise {
+ const valid = validatePluginPackage(payload);
+ await this.fs.upsertPackage(valid);
+ const record = this.records.get(valid.manifest.id) ?? this.createRecord(valid.manifest.id);
+ record.status = "validated";
+ record.updatedAt = new Date().toISOString();
+ record.lastError = "";
+ this.records.set(valid.manifest.id, record);
+ await this.persistRecords();
+ }
+
+ public async enable(pluginId: string): Promise {
+ const pluginPackage = await this.fs.getPackage(pluginId);
+ if (!pluginPackage) {
+ throw new Error("插件不存在");
+ }
+
+ const validPackage = validatePluginPackage(pluginPackage);
+ const record = this.records.get(pluginId) ?? this.createRecord(pluginId);
+
+ if (record.errorCount >= 3) {
+ throw new Error("插件已熔断,请先重载后再启用");
+ }
+
+ if (this.runtime.has(pluginId)) {
+ await this.disable(pluginId);
+ }
+
+ record.status = "loading";
+ record.lastError = "";
+ this.records.set(pluginId, record);
+ await this.persistRecords();
+
+ const runtime: RuntimeSlot = {
+ pluginId,
+ cleanupFns: []
+ };
+
+ try {
+ runtime.styleDisposer = this.options.mountStyle(pluginId, validPackage.stylesCss);
+ const ctx = this.createContext(validPackage.manifest, runtime);
+ runtime.api = this.loadPluginApi(validPackage.mainJs, ctx);
+
+ if (runtime.api?.onload) {
+ await this.runWithTimeout(Promise.resolve(runtime.api.onload(ctx)), 3000, "onload 超时");
+ }
+
+ record.enabled = true;
+ record.status = "active";
+ record.lastLoadedAt = new Date().toISOString();
+ this.runtime.set(pluginId, runtime);
+ await this.persistRecords();
+ this.options.logger("info", pluginId, "插件已启用");
+ } catch (error) {
+ runtime.styleDisposer?.();
+ record.enabled = false;
+ record.status = "failed";
+ record.errorCount += 1;
+ record.lastError = (error as Error).message;
+ this.records.set(pluginId, record);
+ await this.persistRecords();
+ this.options.logger("error", pluginId, `启用失败: ${(error as Error).message}`);
+ throw error;
+ }
+ }
+
+ public async disable(pluginId: string): Promise {
+ const runtime = this.runtime.get(pluginId);
+ const record = this.records.get(pluginId) ?? this.createRecord(pluginId);
+
+ if (runtime) {
+ try {
+ if (runtime.api?.onunload) {
+ await this.runWithTimeout(Promise.resolve(runtime.api.onunload()), 3000, "onunload 超时");
+ }
+ } catch (error) {
+ this.options.logger("warn", pluginId, `onunload 异常: ${(error as Error).message}`);
+ }
+
+ for (const cleanup of runtime.cleanupFns) {
+ cleanup();
+ }
+ runtime.styleDisposer?.();
+ this.runtime.delete(pluginId);
+ }
+
+ for (const id of Array.from(this.commands.keys())) {
+ if (id.startsWith(`${pluginId}:`)) {
+ this.commands.delete(id);
+ }
+ }
+
+ record.enabled = false;
+ if (record.status !== "failed") {
+ record.status = "stopped";
+ }
+ this.records.set(pluginId, record);
+ await this.persistRecords();
+ }
+
+ public async reload(pluginId: string): Promise {
+ const record = this.records.get(pluginId) ?? this.createRecord(pluginId);
+ record.errorCount = 0;
+ record.lastError = "";
+ this.records.set(pluginId, record);
+ await this.persistRecords();
+
+ await this.disable(pluginId);
+ await this.enable(pluginId);
+ }
+
+ public async remove(pluginId: string): Promise {
+ await this.disable(pluginId);
+ await this.fs.removePackage(pluginId);
+ this.records.delete(pluginId);
+ await this.persistRecords();
+ }
+
+ public listRecords(): PluginRecord[] {
+ return Array.from(this.records.values());
+ }
+
+ public listCommands(sessionState: "connected" | "disconnected"): PluginCommand[] {
+ return Array.from(this.commands.values()).filter((command) => {
+ if (command.when === "connected") {
+ return sessionState === "connected";
+ }
+ return true;
+ });
+ }
+
+ public async runCommand(commandId: string): Promise {
+ const command = this.commands.get(commandId);
+ if (!command) {
+ throw new Error("命令不存在");
+ }
+ await Promise.resolve(command.handler());
+ }
+
+ private createContext(manifest: PluginPackage["manifest"], runtime: RuntimeSlot): unknown {
+ const assertPermission = (permission: string): void => {
+ if (!manifest.permissions.includes(permission as never)) {
+ throw new Error(`权限不足: ${permission}`);
+ }
+ };
+
+ return {
+ app: this.apis.getAppMeta(),
+ commands: {
+ register: (command: PluginCommand) => {
+ assertPermission("commands.register");
+ const id = `${manifest.id}:${command.id}`;
+ this.commands.set(id, {
+ ...command,
+ id
+ });
+ runtime.cleanupFns.push(() => this.commands.delete(id));
+ }
+ },
+ session: {
+ send: async (input: string) => {
+ assertPermission("session.write");
+ await this.apis.session.send(input);
+ },
+ on: (eventName: "connected" | "disconnected" | "stdout" | "stderr" | "latency", handler: (payload: unknown) => void) => {
+ assertPermission("session.read");
+ const off = this.apis.session.on(eventName, handler);
+ runtime.cleanupFns.push(off);
+ return off;
+ }
+ },
+ storage: {
+ get: async (key: string) => {
+ assertPermission("storage.read");
+ const value = await this.fs.readStore>(`plugin_data_${manifest.id}`, {});
+ return value[key];
+ },
+ set: async (key: string, value: unknown) => {
+ assertPermission("storage.write");
+ const previous = await this.fs.readStore>(`plugin_data_${manifest.id}`, {});
+ previous[key] = value;
+ await this.fs.writeStore(`plugin_data_${manifest.id}`, previous);
+ }
+ },
+ ui: {
+ showNotice: (message: string, level: "info" | "warn" | "error" = "info") => {
+ assertPermission("ui.notice");
+ this.apis.showNotice(message, level);
+ }
+ },
+ logger: {
+ info: (...args: string[]) => this.options.logger("info", manifest.id, args.join(" ")),
+ warn: (...args: string[]) => this.options.logger("warn", manifest.id, args.join(" ")),
+ error: (...args: string[]) => this.options.logger("error", manifest.id, args.join(" "))
+ }
+ };
+ }
+
+ private loadPluginApi(mainJs: string, ctx: unknown): RuntimeSlot["api"] {
+ const module = { exports: {} as RuntimeSlot["api"] };
+ const exportsRef = module.exports;
+
+ const fn = new Function(
+ "ctx",
+ "module",
+ "exports",
+ `"use strict";
+const window = undefined;
+const document = undefined;
+const localStorage = undefined;
+const globalThis = undefined;
+${mainJs}
+return module.exports;`
+ );
+
+ return (fn(ctx, module, exportsRef) as RuntimeSlot["api"]) ?? module.exports;
+ }
+
+ private async runWithTimeout(promise: Promise, timeoutMs: number, message: string): Promise {
+ let timer: NodeJS.Timeout | undefined;
+ const timeout = new Promise((_, reject) => {
+ timer = setTimeout(() => reject(new Error(message)), timeoutMs);
+ });
+
+ try {
+ return await Promise.race([promise, timeout]);
+ } finally {
+ if (timer) {
+ clearTimeout(timer);
+ }
+ }
+ }
+
+ private createRecord(id: string): PluginRecord {
+ const now = new Date().toISOString();
+ return {
+ id,
+ enabled: false,
+ status: "discovered",
+ errorCount: 0,
+ lastError: "",
+ installedAt: now,
+ updatedAt: now,
+ lastLoadedAt: ""
+ };
+ }
+
+ private async persistRecords(): Promise {
+ await this.fs.writeStore("plugin_records", Array.from(this.records.values()));
+ }
+}
diff --git a/pxterm/packages/plugin-runtime/src/core/validator.test.ts b/pxterm/packages/plugin-runtime/src/core/validator.test.ts
new file mode 100644
index 0000000..1ffc040
--- /dev/null
+++ b/pxterm/packages/plugin-runtime/src/core/validator.test.ts
@@ -0,0 +1,23 @@
+import { describe, expect, it } from "vitest";
+import { validatePluginPackage } from "./validator";
+
+describe("plugin validator", () => {
+ it("通过最小合法插件", () => {
+ expect(() =>
+ validatePluginPackage({
+ manifest: {
+ id: "codex-shortcuts",
+ name: "Codex Shortcuts",
+ version: "0.1.0",
+ minAppVersion: "0.1.0",
+ description: "test",
+ entry: "main.js",
+ style: "styles.css",
+ permissions: ["commands.register"]
+ },
+ mainJs: "module.exports = {};",
+ stylesCss: ".x { color: red; }"
+ })
+ ).not.toThrow();
+ });
+});
diff --git a/pxterm/packages/plugin-runtime/src/core/validator.ts b/pxterm/packages/plugin-runtime/src/core/validator.ts
new file mode 100644
index 0000000..7b3c2dd
--- /dev/null
+++ b/pxterm/packages/plugin-runtime/src/core/validator.ts
@@ -0,0 +1,55 @@
+import { PERMISSION_WHITELIST, type PluginPackage } from "../types/plugin";
+
+/**
+ * 插件包静态校验。
+ */
+export function validatePluginPackage(pluginPackage: PluginPackage): PluginPackage {
+ const manifest = pluginPackage?.manifest;
+ if (!manifest) {
+ throw new Error("缺少 manifest");
+ }
+
+ const required = ["id", "name", "version", "minAppVersion", "description", "entry", "style", "permissions"];
+ for (const key of required) {
+ if (!(key in manifest)) {
+ throw new Error(`manifest 缺少字段: ${key}`);
+ }
+ }
+
+ if (!/^[a-z0-9][a-z0-9-]{1,62}$/.test(manifest.id)) {
+ throw new Error("插件 id 不符合规范");
+ }
+
+ if (!/^\d+\.\d+\.\d+$/.test(manifest.version)) {
+ throw new Error("version 必须是 SemVer,例如 0.1.0");
+ }
+
+ if (!/^\d+\.\d+\.\d+$/.test(manifest.minAppVersion)) {
+ throw new Error("minAppVersion 必须是 SemVer");
+ }
+
+ if (manifest.entry !== "main.js" || manifest.style !== "styles.css") {
+ throw new Error("entry/style 目前固定为 main.js / styles.css");
+ }
+
+ const allowed = new Set(PERMISSION_WHITELIST);
+ for (const permission of manifest.permissions || []) {
+ if (!allowed.has(permission)) {
+ throw new Error(`未知权限: ${permission}`);
+ }
+ }
+
+ if (!pluginPackage.mainJs?.trim()) {
+ throw new Error("mainJs 不能为空");
+ }
+
+ if (pluginPackage.stylesCss == null) {
+ throw new Error("stylesCss 不能为空");
+ }
+
+ if (/^\s*(\*|body|html)\s*[{,]/m.test(pluginPackage.stylesCss)) {
+ throw new Error("styles.css 禁止全局选择器(* / body / html)");
+ }
+
+ return pluginPackage;
+}
diff --git a/pxterm/packages/plugin-runtime/src/index.ts b/pxterm/packages/plugin-runtime/src/index.ts
new file mode 100644
index 0000000..652f6f6
--- /dev/null
+++ b/pxterm/packages/plugin-runtime/src/index.ts
@@ -0,0 +1,3 @@
+export * from "./types/plugin";
+export * from "./core/pluginManager";
+export * from "./core/validator";
diff --git a/pxterm/packages/plugin-runtime/src/types/plugin.ts b/pxterm/packages/plugin-runtime/src/types/plugin.ts
new file mode 100644
index 0000000..91aa591
--- /dev/null
+++ b/pxterm/packages/plugin-runtime/src/types/plugin.ts
@@ -0,0 +1,69 @@
+export const PERMISSION_WHITELIST = [
+ "commands.register",
+ "session.read",
+ "session.write",
+ "ui.notice",
+ "ui.statusbar",
+ "storage.read",
+ "storage.write",
+ "logs.read"
+] as const;
+
+export type PluginPermission = (typeof PERMISSION_WHITELIST)[number];
+
+export interface PluginManifest {
+ id: string;
+ name: string;
+ version: string;
+ minAppVersion: string;
+ description: string;
+ entry: "main.js";
+ style: "styles.css";
+ permissions: PluginPermission[];
+ author?: string;
+ homepage?: string;
+}
+
+export interface PluginPackage {
+ manifest: PluginManifest;
+ mainJs: string;
+ stylesCss: string;
+}
+
+export interface PluginRecord {
+ id: string;
+ enabled: boolean;
+ status: "discovered" | "validated" | "loading" | "active" | "stopped" | "failed";
+ errorCount: number;
+ lastError?: string;
+ installedAt: string;
+ updatedAt: string;
+ lastLoadedAt?: string;
+}
+
+export interface PluginCommand {
+ id: string;
+ title: string;
+ when?: "always" | "connected";
+ handler: () => Promise | void;
+}
+
+export interface PluginFsAdapter {
+ listPackages(): Promise;
+ getPackage(pluginId: string): Promise;
+ upsertPackage(pluginPackage: PluginPackage): Promise;
+ removePackage(pluginId: string): Promise;
+ readStore(key: string, fallback: T): Promise;
+ writeStore(key: string, value: T): Promise;
+}
+
+export interface PluginSessionApi {
+ send(input: string): Promise;
+ on(eventName: "connected" | "disconnected" | "stdout" | "stderr" | "latency", handler: (payload: unknown) => void): () => void;
+}
+
+export interface PluginHostApis {
+ getAppMeta(): { version: string; platform: "web" | "ios" | "miniapp" };
+ session: PluginSessionApi;
+ showNotice(message: string, level: "info" | "warn" | "error"): void;
+}
diff --git a/pxterm/packages/plugin-runtime/tsconfig.json b/pxterm/packages/plugin-runtime/tsconfig.json
new file mode 100644
index 0000000..b5956bd
--- /dev/null
+++ b/pxterm/packages/plugin-runtime/tsconfig.json
@@ -0,0 +1,12 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist",
+ "baseUrl": ".",
+ "paths": {
+ "@remoteconn/shared": ["../shared/src/index.ts"]
+ }
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/pxterm/packages/shared/package.json b/pxterm/packages/shared/package.json
new file mode 100644
index 0000000..39225e5
--- /dev/null
+++ b/pxterm/packages/shared/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "@remoteconn/shared",
+ "version": "1.0.0",
+ "type": "module",
+ "main": "src/index.ts",
+ "types": "src/index.ts",
+ "exports": {
+ ".": "./src/index.ts"
+ },
+ "scripts": {
+ "build": "tsc -p tsconfig.json",
+ "typecheck": "tsc -p tsconfig.json --noEmit",
+ "lint": "eslint src --ext .ts",
+ "test": "vitest run"
+ }
+}
diff --git a/pxterm/packages/shared/src/codex/orchestrator.test.ts b/pxterm/packages/shared/src/codex/orchestrator.test.ts
new file mode 100644
index 0000000..7243b73
--- /dev/null
+++ b/pxterm/packages/shared/src/codex/orchestrator.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from "vitest";
+import { buildCdCommand, buildCodexPlan } from "./orchestrator";
+
+describe("buildCdCommand", () => {
+ it("`~` 应展开为 HOME", () => {
+ expect(buildCdCommand("~")).toBe('cd "$HOME"');
+ });
+
+ it("`~/...` 应保留 HOME 前缀并安全引用余下路径", () => {
+ expect(buildCdCommand("~/workspace/remoteconn")).toBe('cd "$HOME"/\'workspace/remoteconn\'');
+ });
+
+ it("普通绝对路径应保持单引号安全转义", () => {
+ expect(buildCdCommand("/var/www/my app")).toBe("cd '/var/www/my app'");
+ });
+});
+
+describe("buildCodexPlan", () => {
+ it("首条命令应使用 cd 计划且包含 sandbox 启动命令", () => {
+ const plan = buildCodexPlan({
+ projectPath: "~/workspace/remoteconn",
+ sandbox: "workspace-write"
+ });
+
+ expect(plan).toHaveLength(3);
+ const cdStep = plan[0];
+ const checkStep = plan[1];
+ const runStep = plan[2];
+ expect(cdStep).toBeDefined();
+ expect(checkStep).toBeDefined();
+ expect(runStep).toBeDefined();
+ if (!cdStep || !checkStep || !runStep) {
+ throw new Error("Codex 计划步骤缺失");
+ }
+
+ expect(cdStep).toEqual({
+ step: "cd",
+ command: 'cd "$HOME"/\'workspace/remoteconn\'',
+ markerType: "cd"
+ });
+ expect(checkStep.command).toBe("command -v codex");
+ expect(runStep.command).toBe("codex --sandbox workspace-write");
+ });
+});
diff --git a/pxterm/packages/shared/src/codex/orchestrator.ts b/pxterm/packages/shared/src/codex/orchestrator.ts
new file mode 100644
index 0000000..483b4dd
--- /dev/null
+++ b/pxterm/packages/shared/src/codex/orchestrator.ts
@@ -0,0 +1,63 @@
+/**
+ * Codex 模式编排命令。
+ */
+export interface CodexCommandPlan {
+ step: "cd" | "check" | "run";
+ command: string;
+ markerType: "cd" | "check" | "run";
+}
+
+export interface CodexRunOptions {
+ projectPath: string;
+ sandbox: "read-only" | "workspace-write" | "danger-full-access";
+}
+
+/**
+ * 对路径做最小安全转义,防止单引号截断。
+ */
+export function shellQuote(value: string): string {
+ return `'${String(value).replace(/'/g, "'\\''")}'`;
+}
+
+/**
+ * 构造 `cd` 命令:
+ * - `~` 与 `~/...` 需要保留 HOME 展开语义,不能整体单引号包裹;
+ * - 其他路径走单引号最小转义,避免空格/特殊字符破坏命令。
+ */
+export function buildCdCommand(projectPath: string): string {
+ const normalized = String(projectPath || "~").trim() || "~";
+
+ if (normalized === "~") {
+ return 'cd "$HOME"';
+ }
+
+ if (normalized.startsWith("~/")) {
+ const relative = normalized.slice(2);
+ return relative ? `cd "$HOME"/${shellQuote(relative)}` : 'cd "$HOME"';
+ }
+
+ return `cd ${shellQuote(normalized)}`;
+}
+
+/**
+ * 生成 Codex 模式三步命令。
+ */
+export function buildCodexPlan(options: CodexRunOptions): CodexCommandPlan[] {
+ return [
+ {
+ step: "cd",
+ command: buildCdCommand(options.projectPath),
+ markerType: "cd"
+ },
+ {
+ step: "check",
+ command: "command -v codex",
+ markerType: "check"
+ },
+ {
+ step: "run",
+ command: `codex --sandbox ${options.sandbox}`,
+ markerType: "run"
+ }
+ ];
+}
diff --git a/pxterm/packages/shared/src/index.ts b/pxterm/packages/shared/src/index.ts
new file mode 100644
index 0000000..9afe411
--- /dev/null
+++ b/pxterm/packages/shared/src/index.ts
@@ -0,0 +1,7 @@
+export * from "./types/models";
+export * from "./session/stateMachine";
+export * from "./codex/orchestrator";
+export * from "./theme/contrast";
+export * from "./theme/presets";
+export * from "./logs/mask";
+export * from "./security/hostKey";
diff --git a/pxterm/packages/shared/src/logs/mask.ts b/pxterm/packages/shared/src/logs/mask.ts
new file mode 100644
index 0000000..ae19a22
--- /dev/null
+++ b/pxterm/packages/shared/src/logs/mask.ts
@@ -0,0 +1,15 @@
+/**
+ * 日志脱敏函数,避免导出文本泄露密码和主机信息。
+ */
+export function maskSensitive(value: string): string {
+ return String(value)
+ .replace(/([0-9]{1,3}\.){3}[0-9]{1,3}/g, "***.***.***.***")
+ .replace(/(token|password|passphrase|secret)\s*[=:]\s*[^\s]+/gi, "$1=***")
+ .replace(/~\/.+?(?=\s|$)/g, "~/***");
+}
+
+export function maskHost(host: string): string {
+ return String(host)
+ .replace(/([a-zA-Z0-9._%+-]+)@/, "***@")
+ .replace(/([0-9]{1,3}\.){3}[0-9]{1,3}/, "***.***.***.***");
+}
diff --git a/pxterm/packages/shared/src/security/hostKey.ts b/pxterm/packages/shared/src/security/hostKey.ts
new file mode 100644
index 0000000..8f84acb
--- /dev/null
+++ b/pxterm/packages/shared/src/security/hostKey.ts
@@ -0,0 +1,61 @@
+/**
+ * 主机指纹策略。
+ */
+export type HostKeyPolicy = "strict" | "trustFirstUse" | "manualEachTime";
+
+export interface KnownHostsRecord {
+ [hostPort: string]: string;
+}
+
+export interface VerifyHostKeyParams {
+ hostPort: string;
+ incomingFingerprint: string;
+ policy: HostKeyPolicy;
+ knownHosts: KnownHostsRecord;
+ /**
+ * 当策略需要用户确认时由上层注入。
+ */
+ onConfirm: (payload: { hostPort: string; fingerprint: string; reason: string }) => Promise;
+}
+
+export async function verifyHostKey(params: VerifyHostKeyParams): Promise<{ accepted: boolean; updated: KnownHostsRecord }> {
+ const { hostPort, incomingFingerprint, policy, onConfirm } = params;
+ const knownHosts = { ...params.knownHosts };
+ const stored = knownHosts[hostPort];
+
+ if (stored && stored !== incomingFingerprint) {
+ return { accepted: false, updated: knownHosts };
+ }
+
+ if (policy === "trustFirstUse") {
+ if (!stored) {
+ knownHosts[hostPort] = incomingFingerprint;
+ }
+ return { accepted: true, updated: knownHosts };
+ }
+
+ if (policy === "strict") {
+ if (stored) {
+ return { accepted: true, updated: knownHosts };
+ }
+ const accepted = await onConfirm({
+ hostPort,
+ fingerprint: incomingFingerprint,
+ reason: "首次连接,严格模式要求确认"
+ });
+ if (accepted) {
+ knownHosts[hostPort] = incomingFingerprint;
+ }
+ return { accepted, updated: knownHosts };
+ }
+
+ const accepted = await onConfirm({
+ hostPort,
+ fingerprint: incomingFingerprint,
+ reason: "手动确认模式"
+ });
+ if (accepted) {
+ knownHosts[hostPort] = incomingFingerprint;
+ }
+ return { accepted, updated: knownHosts };
+}
diff --git a/pxterm/packages/shared/src/session/stateMachine.ts b/pxterm/packages/shared/src/session/stateMachine.ts
new file mode 100644
index 0000000..c004750
--- /dev/null
+++ b/pxterm/packages/shared/src/session/stateMachine.ts
@@ -0,0 +1,28 @@
+import type { SessionState } from "../types/models";
+
+/**
+ * 会话状态机,确保连接生命周期可预测。
+ */
+const transitions: Record = {
+ idle: ["connecting", "disconnected"],
+ connecting: ["auth_pending", "error", "disconnected"],
+ auth_pending: ["connected", "error", "disconnected"],
+ connected: ["reconnecting", "disconnected", "error"],
+ reconnecting: ["connected", "error", "disconnected"],
+ disconnected: ["connecting", "idle"],
+ error: ["connecting", "disconnected"]
+};
+
+export function canTransition(from: SessionState, to: SessionState): boolean {
+ return transitions[from].includes(to);
+}
+
+export function assertTransition(from: SessionState, to: SessionState): void {
+ if (!canTransition(from, to)) {
+ throw new Error(`非法状态跳转: ${from} -> ${to}`);
+ }
+}
+
+export function allStates(): SessionState[] {
+ return Object.keys(transitions) as SessionState[];
+}
diff --git a/pxterm/packages/shared/src/theme/contrast.test.ts b/pxterm/packages/shared/src/theme/contrast.test.ts
new file mode 100644
index 0000000..c54e242
--- /dev/null
+++ b/pxterm/packages/shared/src/theme/contrast.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "vitest";
+import { contrastRatio, pickBestBackground, pickShellAccentColor } from "./contrast";
+
+describe("theme contrast", () => {
+ it("计算对比度", () => {
+ expect(contrastRatio("#ffffff", "#000000")).toBeGreaterThan(7);
+ });
+
+ it("自动选择背景", () => {
+ const selected = pickBestBackground("#e6f0ff", "#5bd2ff");
+ expect(selected.startsWith("#")).toBe(true);
+ });
+
+ it("终端强调色取背景和前景之间,并略偏前景", () => {
+ expect(pickShellAccentColor("#192b4d", "#e6f0ff")).toBe("#9ca9bf");
+ });
+});
diff --git a/pxterm/packages/shared/src/theme/contrast.ts b/pxterm/packages/shared/src/theme/contrast.ts
new file mode 100644
index 0000000..3410f58
--- /dev/null
+++ b/pxterm/packages/shared/src/theme/contrast.ts
@@ -0,0 +1,105 @@
+/**
+ * 主题引擎:提供 WCAG 对比度计算与背景自动优化。
+ */
+
+export interface RgbColor {
+ r: number;
+ g: number;
+ b: number;
+}
+
+/**
+ * 终端强调色插值系数:
+ * - 0.5 代表正中间;
+ * - >0.5 代表向前景色偏移;
+ * - 当前取 0.64,满足“中间色且略偏前景”的视觉要求。
+ */
+export const SHELL_ACCENT_BLEND_T = 0.64;
+
+export function normalizeHex(input: string, fallback: string): string {
+ return /^#[0-9a-fA-F]{6}$/.test(input) ? input.toLowerCase() : fallback;
+}
+
+export function hexToRgb(hex: string): RgbColor {
+ const value = normalizeHex(hex, "#000000");
+ return {
+ r: Number.parseInt(value.slice(1, 3), 16),
+ g: Number.parseInt(value.slice(3, 5), 16),
+ b: Number.parseInt(value.slice(5, 7), 16)
+ };
+}
+
+function srgbToLinear(value: number): number {
+ const normalized = value / 255;
+ if (normalized <= 0.03928) {
+ return normalized / 12.92;
+ }
+ return ((normalized + 0.055) / 1.055) ** 2.4;
+}
+
+export function luminance(hex: string): number {
+ const rgb = hexToRgb(hex);
+ return 0.2126 * srgbToLinear(rgb.r) + 0.7152 * srgbToLinear(rgb.g) + 0.0722 * srgbToLinear(rgb.b);
+}
+
+export function contrastRatio(a: string, b: string): number {
+ const la = luminance(a);
+ const lb = luminance(b);
+ const lighter = Math.max(la, lb);
+ const darker = Math.min(la, lb);
+ return (lighter + 0.05) / (darker + 0.05);
+}
+
+export function pickBestBackground(textColor: string, accentColor: string): string {
+ const candidates = [
+ "#0a1325",
+ "#132747",
+ "#102b34",
+ "#2e223b",
+ normalizeHex(accentColor, "#5bd2ff")
+ ];
+
+ let best = candidates[0] ?? "#0a1325";
+ let bestScore = 0;
+ for (const candidate of candidates) {
+ const score = contrastRatio(normalizeHex(textColor, "#e6f0ff"), candidate);
+ if (score > bestScore) {
+ bestScore = score;
+ best = candidate;
+ }
+ }
+ return best;
+}
+
+/**
+ * 颜色线性插值:
+ * - t=0 表示返回背景色;
+ * - t=1 表示返回前景色;
+ * - 用于按钮色与终端强调色等“在 bg/text 之间取色”的场景。
+ */
+function mixColor(bgColor: string, textColor: string, t: number, fallbackBg: string, fallbackText: string): string {
+ const bg = hexToRgb(normalizeHex(bgColor, fallbackBg));
+ const text = hexToRgb(normalizeHex(textColor, fallbackText));
+ const factor = Number.isFinite(t) ? Math.min(1, Math.max(0, t)) : 0.5;
+ const r = Math.round(bg.r + (text.r - bg.r) * factor);
+ const g = Math.round(bg.g + (text.g - bg.g) * factor);
+ const b = Math.round(bg.b + (text.b - bg.b) * factor);
+ return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`;
+}
+
+/**
+ * 按钮色自动推导:在背景色和文本色之间取色,偏向文本色一侧(t=0.72)。
+ * 确保按钮与背景有足够对比度,同时色调协调。
+ */
+export function pickBtnColor(bgColor: string, textColor: string): string {
+ return mixColor(bgColor, textColor, 0.72, "#192b4d", "#e6f0ff");
+}
+
+/**
+ * 终端强调色自动推导:
+ * - 在终端背景色与前景色之间取“中间偏前景”的颜色;
+ * - 目标是避免强调色贴近背景导致识别度不足,同时避免过亮抢占正文层级。
+ */
+export function pickShellAccentColor(bgColor: string, textColor: string): string {
+ return mixColor(bgColor, textColor, SHELL_ACCENT_BLEND_T, "#192b4d", "#e6f0ff");
+}
diff --git a/pxterm/packages/shared/src/theme/presets.test.ts b/pxterm/packages/shared/src/theme/presets.test.ts
new file mode 100644
index 0000000..9da12cc
--- /dev/null
+++ b/pxterm/packages/shared/src/theme/presets.test.ts
@@ -0,0 +1,11 @@
+import { describe, expect, it } from "vitest";
+import { pickShellAccentColor } from "./contrast";
+import { getShellVariant } from "./presets";
+
+describe("theme presets", () => {
+ it("shell 变体的 cursor 按背景和前景自动推导", () => {
+ const variant = getShellVariant("tide", "dark");
+ expect(variant.cursor).toBe(pickShellAccentColor(variant.bg, variant.text));
+ expect(variant.cursor).toBe("#9ca9bf");
+ });
+});
diff --git a/pxterm/packages/shared/src/theme/presets.ts b/pxterm/packages/shared/src/theme/presets.ts
new file mode 100644
index 0000000..79b663c
--- /dev/null
+++ b/pxterm/packages/shared/src/theme/presets.ts
@@ -0,0 +1,257 @@
+import { pickShellAccentColor } from "./contrast";
+
+/**
+ * 主题色板预设(按配置计划 §主题实现方案)。
+ *
+ * 每套主题包含:
+ * - palette: 原始色板(按 Figma 顺序)
+ * - dark: dark 变体(bg / text / accent / btn)
+ * - light: light 变体(bg / text / accent / btn)
+ */
+
+export type ThemePreset =
+ | "tide"
+ | "暮砂"
+ | "霓潮"
+ | "苔暮"
+ | "焰岩"
+ | "岩陶"
+ | "靛雾"
+ | "绛霓"
+ | "玫蓝"
+ | "珊湾"
+ | "苔荧"
+ | "铜暮"
+ | "炽潮"
+ | "藕夜"
+ | "沙海"
+ | "珀岚"
+ | "炫虹"
+ | "鎏霓"
+ | "珊汐"
+ | "黛苔"
+ | "霜绯";
+
+export interface ThemeVariant {
+ /** 页面/卡片背景色 */
+ bg: string;
+ /** 正文字体色 */
+ text: string;
+ /** 强调色(链接、输入框聚焦线、开关等) */
+ accent: string;
+ /** 主按钮底色 */
+ btn: string;
+}
+
+export interface ThemeDefinition {
+ name: ThemePreset;
+ /** 色板原始顺序 */
+ palette: string[];
+ dark: ThemeVariant;
+ light: ThemeVariant;
+ /** shell 专用 dark 变体(终端背景/前景/光标) */
+ shellDark: { bg: string; text: string; cursor: string };
+ /** shell 专用 light 变体 */
+ shellLight: { bg: string; text: string; cursor: string };
+}
+
+export const THEME_PRESETS: Record = {
+ tide: {
+ name: "tide",
+ palette: ["#192b4d", "#5bd2ff", "#e6f0ff", "#3D86FF"],
+ dark: { bg: "#192b4d", text: "#e6f0ff", accent: "#5bd2ff", btn: "#3D86FF" },
+ light: { bg: "#e6f0ff", text: "#192b4d", accent: "#3D86FF", btn: "#3D86FF" },
+ shellDark: { bg: "#192b4d", text: "#e6f0ff", cursor: "#5bd2ff" },
+ shellLight: { bg: "#e6f0ff", text: "#192b4d", cursor: "#3D86FF" }
+ },
+ 暮砂: {
+ name: "暮砂",
+ palette: ["#F4F1DE", "#EAB69F", "#E07A5F", "#8F5D5D", "#3D405B", "#5F797B", "#81B29A", "#9EB998", "#BABF95", "#F2CC8F"],
+ dark: { bg: "#3D405B", text: "#F4F1DE", accent: "#81B29A", btn: "#E07A5F" },
+ light: { bg: "#F4F1DE", text: "#3D405B", accent: "#E07A5F", btn: "#8F5D5D" },
+ shellDark: { bg: "#3D405B", text: "#F4F1DE", cursor: "#81B29A" },
+ shellLight: { bg: "#F4F1DE", text: "#3D405B", cursor: "#E07A5F" }
+ },
+ 霓潮: {
+ name: "霓潮",
+ palette: ["#EF476F", "#F78C6B", "#FFD166", "#83D483", "#06D6A0", "#001914", "#118AB2", "#0F7799", "#0C637F", "#073B4C"],
+ dark: { bg: "#073B4C", text: "#FFD166", accent: "#06D6A0", btn: "#118AB2" },
+ light: { bg: "#FFD166", text: "#073B4C", accent: "#EF476F", btn: "#0F7799" },
+ shellDark: { bg: "#073B4C", text: "#FFD166", cursor: "#06D6A0" },
+ shellLight: { bg: "#FFD166", text: "#073B4C", cursor: "#EF476F" }
+ },
+ 苔暮: {
+ name: "苔暮",
+ palette: ["#A8B868", "#798575", "#4A5282", "#393F7C", "#313679", "#282C75", "#3C3C9D", "#4E4CC3", "#645FD4", "#7A71E4"],
+ dark: { bg: "#282C75", text: "#A8B868", accent: "#7A71E4", btn: "#84916c" },
+ light: { bg: "#A8B868", text: "#282C75", accent: "#4E4CC3", btn: "#393F7C" },
+ shellDark: { bg: "#282C75", text: "#A8B868", cursor: "#7A71E4" },
+ shellLight: { bg: "#A8B868", text: "#282C75", cursor: "#4E4CC3" }
+ },
+ 焰岩: {
+ name: "焰岩",
+ palette: ["#5F0F40", "#7D092F", "#9A031E", "#CB4721", "#FB8B24", "#EF781C", "#E36414", "#AE5E26", "#795838", "#0F4C5C"],
+ dark: { bg: "#0F4C5C", text: "#FB8B24", accent: "#E36414", btn: "#CB4721" },
+ light: { bg: "#FB8B24", text: "#0F4C5C", accent: "#CB4721", btn: "#9A031E" },
+ shellDark: { bg: "#0F4C5C", text: "#FB8B24", cursor: "#E36414" },
+ shellLight: { bg: "#FB8B24", text: "#0F4C5C", cursor: "#CB4721" }
+ },
+ 岩陶: {
+ name: "岩陶",
+ palette: ["#283D3B", "#21585A", "#197278", "#83A8A6", "#EDDDD4", "#D99185", "#E9B5AF", "#9E3A2E", "#772E25"],
+ dark: { bg: "#283D3B", text: "#EDDDD4", accent: "#E9B5AF", btn: "#D99185" },
+ light: { bg: "#EDDDD4", text: "#283D3B", accent: "#D99185", btn: "#9E3A2E" },
+ shellDark: { bg: "#283D3B", text: "#EDDDD4", cursor: "#E9B5AF" },
+ shellLight: { bg: "#EDDDD4", text: "#283D3B", cursor: "#D99185" }
+ },
+ 靛雾: {
+ name: "靛雾",
+ palette: ["#292281", "#2D2586", "#31278B", "#382D93", "#3F329B", "#4437A1", "#4A3BA6", "#9D96BA", "#C7C4C4", "#F1F0CD"],
+ dark: { bg: "#292281", text: "#F1F0CD", accent: "#9D96BA", btn: "#b9b6b8" },
+ light: { bg: "#F1F0CD", text: "#292281", accent: "#4A3BA6", btn: "#382D93" },
+ shellDark: { bg: "#292281", text: "#F1F0CD", cursor: "#9D96BA" },
+ shellLight: { bg: "#F1F0CD", text: "#292281", cursor: "#4A3BA6" }
+ },
+ 绛霓: {
+ name: "绛霓",
+ palette: ["#F72585", "#B5179E", "#7209B7", "#560BAD", "#480CA8", "#3A0CA3", "#3F37C9", "#4361EE", "#4895EF", "#4CC9F0"],
+ dark: { bg: "#3A0CA3", text: "#4CC9F0", accent: "#4895EF", btn: "#7209B7" },
+ light: { bg: "#4CC9F0", text: "#3A0CA3", accent: "#F72585", btn: "#7209B7" },
+ shellDark: { bg: "#3A0CA3", text: "#4CC9F0", cursor: "#4895EF" },
+ shellLight: { bg: "#4CC9F0", text: "#3A0CA3", cursor: "#F72585" }
+ },
+ 玫蓝: {
+ name: "玫蓝",
+ palette: ["#D06A79", "#984B8D", "#5E2BA1", "#482398", "#3D1F94", "#311B90", "#3D39B6", "#4857DC", "#567BE3", "#629FEB"],
+ dark: { bg: "#3D1F94", text: "#629FEB", accent: "#D06A79", btn: "#567BE3" },
+ light: { bg: "#629FEB", text: "#3D1F94", accent: "#D06A79", btn: "#4857DC" },
+ shellDark: { bg: "#3D1F94", text: "#629FEB", cursor: "#567BE3" },
+ shellLight: { bg: "#629FEB", text: "#3D1F94", cursor: "#D06A79" }
+ },
+ 珊湾: {
+ name: "珊湾",
+ palette: ["#EC5B57", "#AD635D", "#6C6B64", "#526660", "#45645D", "#37615B", "#3E8983", "#44B0AB", "#4BC8C4", "#52DFDD"],
+ dark: { bg: "#37615B", text: "#52DFDD", accent: "#EC5B57", btn: "#44B0AB" },
+ light: { bg: "#52DFDD", text: "#37615B", accent: "#EC5B57", btn: "#3E8983" },
+ shellDark: { bg: "#37615B", text: "#52DFDD", cursor: "#44B0AB" },
+ shellLight: { bg: "#52DFDD", text: "#37615B", cursor: "#EC5B57" }
+ },
+ 苔荧: {
+ name: "苔荧",
+ palette: ["#414731", "#515A23", "#616C15", "#99A32C", "#D1D942", "#C2CB37", "#B3BC2C", "#909636", "#6C6F41", "#252157"],
+ dark: { bg: "#252157", text: "#D1D942", accent: "#99A32C", btn: "#C2CB37" },
+ light: { bg: "#D1D942", text: "#252157", accent: "#909636", btn: "#99A32C" },
+ shellDark: { bg: "#252157", text: "#D1D942", cursor: "#C2CB37" },
+ shellLight: { bg: "#D1D942", text: "#252157", cursor: "#909636" }
+ },
+ 铜暮: {
+ name: "铜暮",
+ palette: ["#502939", "#672F2A", "#7E351A", "#B27225", "#E6B030", "#D99F27", "#CB8E1E", "#9F782D", "#72623C", "#1A375A"],
+ dark: { bg: "#1A375A", text: "#E6B030", accent: "#B27225", btn: "#D99F27" },
+ light: { bg: "#E6B030", text: "#1A375A", accent: "#B27225", btn: "#9F782D" },
+ shellDark: { bg: "#1A375A", text: "#E6B030", cursor: "#D99F27" },
+ shellLight: { bg: "#E6B030", text: "#1A375A", cursor: "#B27225" }
+ },
+ 炽潮: {
+ name: "炽潮",
+ palette: ["#5B2A28", "#771E1C", "#921211", "#C43133", "#F55054", "#E94347", "#DC363A", "#AA3E40", "#774547", "#125554"],
+ dark: { bg: "#125554", text: "#F55054", accent: "#C43133", btn: "#E94347" },
+ light: { bg: "#F55054", text: "#125554", accent: "#AA3E40", btn: "#DC363A" },
+ shellDark: { bg: "#125554", text: "#F55054", cursor: "#E94347" },
+ shellLight: { bg: "#F55054", text: "#125554", cursor: "#AA3E40" }
+ },
+ 藕夜: {
+ name: "藕夜",
+ palette: ["#322F4F", "#433E71", "#554C93", "#98958C", "#DBDD85", "#D8DD7D", "#D5DB74", "#CED56E", "#C8CF67", "#BAC35A"],
+ dark: { bg: "#322F4F", text: "#DBDD85", accent: "#554C93", btn: "#C8CF67" },
+ light: { bg: "#DBDD85", text: "#322F4F", accent: "#D5DB74", btn: "#433E71" },
+ shellDark: { bg: "#322F4F", text: "#DBDD85", cursor: "#C8CF67" },
+ shellLight: { bg: "#DBDD85", text: "#322F4F", cursor: "#554C93" }
+ },
+ 沙海: {
+ name: "沙海",
+ palette: ["#2B3B51", "#355971", "#3F7690", "#91A483", "#E2D075", "#E4C66F", "#E4BD69", "#E0B464", "#DBAA5F", "#D19654"],
+ dark: { bg: "#2B3B51", text: "#E2D075", accent: "#3F7690", btn: "#DBAA5F" },
+ light: { bg: "#E2D075", text: "#2B3B51", accent: "#355971", btn: "#D19654" },
+ shellDark: { bg: "#2B3B51", text: "#E2D075", cursor: "#DBAA5F" },
+ shellLight: { bg: "#E2D075", text: "#2B3B51", cursor: "#3F7690" }
+ },
+ 珀岚: {
+ name: "珀岚",
+ palette: ["#274D4C", "#2B7171", "#2F9595", "#8B9395", "#E79094", "#EC878A", "#EF7D7F", "#EC7578", "#E86D6F", "#E15D5F"],
+ dark: { bg: "#274D4C", text: "#E79094", accent: "#2F9595", btn: "#EC7578" },
+ light: { bg: "#E79094", text: "#274D4C", accent: "#2B7171", btn: "#E15D5F" },
+ shellDark: { bg: "#274D4C", text: "#E79094", cursor: "#EC7578" },
+ shellLight: { bg: "#E79094", text: "#274D4C", cursor: "#2F9595" }
+ },
+ 炫虹: {
+ name: "炫虹",
+ palette: ["#FFBE0B", "#FD8A09", "#FB5607", "#FD2B3B", "#FF006E", "#C11CAD", "#A22ACD", "#8338EC", "#5F5FF6", "#3A86FF"],
+ dark: { bg: "#8338EC", text: "#FFBE0B", accent: "#FF006E", btn: "#3A86FF" },
+ light: { bg: "#FFBE0B", text: "#8338EC", accent: "#FD2B3B", btn: "#5F5FF6" },
+ shellDark: { bg: "#8338EC", text: "#FFBE0B", cursor: "#3A86FF" },
+ shellLight: { bg: "#FFBE0B", text: "#8338EC", cursor: "#FF006E" }
+ },
+ 鎏霓: {
+ name: "鎏霓",
+ palette: ["#F3D321", "#E7B019", "#DC8C10", "#D67039", "#D05460", "#A2529A", "#8C51B8", "#7550D5", "#5F5FE3", "#476CEF"],
+ dark: { bg: "#7550D5", text: "#F3D321", accent: "#A2529A", btn: "#476CEF" },
+ light: { bg: "#F3D321", text: "#7550D5", accent: "#D67039", btn: "#5F5FE3" },
+ shellDark: { bg: "#7550D5", text: "#F3D321", cursor: "#476CEF" },
+ shellLight: { bg: "#F3D321", text: "#7550D5", cursor: "#A2529A" }
+ },
+ 珊汐: {
+ name: "珊汐",
+ palette: ["#FB5860", "#F74046", "#F2292C", "#F23433", "#F23E39", "#B86E68", "#9C867F", "#7F9E96", "#5FB4AE", "#3DCAC5"],
+ dark: { bg: "#7F9E96", text: "#FB5860", accent: "#3DCAC5", btn: "#F23E39" },
+ light: { bg: "#FB5860", text: "#7F9E96", accent: "#F2292C", btn: "#5FB4AE" },
+ shellDark: { bg: "#7F9E96", text: "#FB5860", cursor: "#3DCAC5" },
+ shellLight: { bg: "#FB5860", text: "#7F9E96", cursor: "#F2292C" }
+ },
+ 黛苔: {
+ name: "黛苔",
+ palette: ["#2F2E3B", "#353159", "#3A3376", "#908EA6", "#E7E8D6", "#BEC388", "#949D3A", "#788031", "#5B6127"],
+ dark: { bg: "#2F2E3B", text: "#E7E8D6", accent: "#788031", btn: "#949D3A" },
+ light: { bg: "#E7E8D6", text: "#2F2E3B", accent: "#788031", btn: "#5B6127" },
+ shellDark: { bg: "#2F2E3B", text: "#E7E8D6", cursor: "#949D3A" },
+ shellLight: { bg: "#E7E8D6", text: "#2F2E3B", cursor: "#788031" }
+ },
+ 霜绯: {
+ name: "霜绯",
+ palette: ["#293B3B", "#235959", "#1D7575", "#84A6A6", "#ECD7D8", "#D58A8A", "#BD3C3D", "#993333", "#732829"],
+ dark: { bg: "#293B3B", text: "#ECD7D8", accent: "#1D7575", btn: "#BD3C3D" },
+ light: { bg: "#ECD7D8", text: "#293B3B", accent: "#993333", btn: "#BD3C3D" },
+ shellDark: { bg: "#293B3B", text: "#ECD7D8", cursor: "#BD3C3D" },
+ shellLight: { bg: "#ECD7D8", text: "#293B3B", cursor: "#993333" }
+ }
+};
+
+/**
+ * 按主题名和模式获取 UI 变体颜色。
+ */
+export function getThemeVariant(preset: ThemePreset, mode: "dark" | "light"): ThemeVariant {
+ const def = THEME_PRESETS[preset] ?? THEME_PRESETS["tide"];
+ return mode === "dark" ? def.dark : def.light;
+}
+
+/**
+ * 按主题名和模式获取 Shell 变体颜色。
+ */
+export function getShellVariant(
+ preset: ThemePreset,
+ mode: "dark" | "light"
+): { bg: string; text: string; cursor: string } {
+ const def = THEME_PRESETS[preset] ?? THEME_PRESETS["tide"];
+ const base = mode === "dark" ? def.shellDark : def.shellLight;
+ return {
+ bg: base.bg,
+ text: base.text,
+ // 终端强调色统一按“背景与前景之间、略偏前景”的规则实时推导。
+ cursor: pickShellAccentColor(base.bg, base.text)
+ };
+}
+
+/**
+ * 返回所有可用主题预设名称列表。
+ */
+export const THEME_PRESET_NAMES: ThemePreset[] = Object.keys(THEME_PRESETS) as ThemePreset[];
diff --git a/pxterm/packages/shared/src/types/models.ts b/pxterm/packages/shared/src/types/models.ts
new file mode 100644
index 0000000..62a661b
--- /dev/null
+++ b/pxterm/packages/shared/src/types/models.ts
@@ -0,0 +1,137 @@
+/**
+ * 认证类型:首期支持密码和私钥,证书为二期预留。
+ */
+export type AuthType = "password" | "privateKey" | "certificate";
+
+/**
+ * 终端传输模式:Web/小程序通过网关,iOS 走原生插件。
+ */
+export type TransportMode = "gateway" | "ios-native";
+
+/**
+ * 服务器配置。
+ */
+export interface ServerProfile {
+ id: string;
+ name: string;
+ host: string;
+ port: number;
+ username: string;
+ authType: AuthType;
+ projectPath: string;
+ projectPresets: string[];
+ tags: string[];
+ timeoutSeconds: number;
+ heartbeatSeconds: number;
+ transportMode: TransportMode;
+ /**
+ * 服务器列表排序位次(值越小越靠前):
+ * - 仅用于前端“我的服务器”列表排序持久化;
+ * - 历史数据可能缺失,业务层会在加载时自动回填。
+ */
+ sortOrder?: number;
+ lastConnectedAt?: string;
+}
+
+/**
+ * 凭据引用,不在业务对象中保存明文。
+ */
+export interface CredentialRef {
+ id: string;
+ type: AuthType;
+ secureStoreKey: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+/**
+ * 已解析凭据,通常仅在临时连接阶段短时存在内存里。
+ */
+export interface ResolvedCredential {
+ type: AuthType;
+ password?: string;
+ privateKey?: string;
+ passphrase?: string;
+ certificate?: string;
+}
+
+export type SessionState =
+ | "idle"
+ | "connecting"
+ | "auth_pending"
+ | "connected"
+ | "reconnecting"
+ | "disconnected"
+ | "error";
+
+export interface CommandMarker {
+ at: string;
+ command: string;
+ source: "manual" | "codex" | "plugin";
+ markerType: "manual" | "cd" | "check" | "run";
+ code: number;
+ elapsedMs: number;
+}
+
+export interface SessionLog {
+ sessionId: string;
+ serverId: string;
+ startAt: string;
+ endAt?: string;
+ status: SessionState;
+ commandMarkers: CommandMarker[];
+ error?: string;
+}
+
+/**
+ * stdin 输入来源标识:
+ * - keyboard:常规键盘输入(含终端快捷键与控制字符)。
+ * - assist:输入法/语音等“候选提交型”输入。
+ */
+export type StdinSource = "keyboard" | "assist";
+
+/**
+ * stdin 元信息:
+ * - source 用于区分输入路径,便于网关侧做策略(如去重、观测)。
+ * - txnId 用于 assist 路径幂等去重(同一事务只接受一次最终提交)。
+ */
+export interface StdinMeta {
+ source: StdinSource;
+ txnId?: string;
+}
+
+/**
+ * WebSocket 网关协议。
+ */
+export type GatewayFrame =
+ | {
+ type: "init";
+ payload: {
+ host: string;
+ port: number;
+ username: string;
+ credential: ResolvedCredential;
+ clientSessionKey?: string;
+ knownHostFingerprint?: string;
+ pty: { cols: number; rows: number };
+ };
+ }
+ | { type: "stdin"; payload: { data: string; meta?: StdinMeta } }
+ | { type: "stdout"; payload: { data: string } }
+ | { type: "stderr"; payload: { data: string } }
+ | { type: "resize"; payload: { cols: number; rows: number } }
+ | {
+ type: "control";
+ payload: {
+ action: "ping" | "pong" | "disconnect" | "connected";
+ reason?: string;
+ fingerprint?: string;
+ };
+ }
+ | {
+ type: "error";
+ payload: {
+ code: string;
+ message: string;
+ };
+ };
diff --git a/pxterm/packages/shared/tsconfig.json b/pxterm/packages/shared/tsconfig.json
new file mode 100644
index 0000000..df59da5
--- /dev/null
+++ b/pxterm/packages/shared/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src/**/*.ts"]
+}
diff --git a/pxterm/patch_deep_log.cjs b/pxterm/patch_deep_log.cjs
new file mode 100644
index 0000000..84a43f8
--- /dev/null
+++ b/pxterm/patch_deep_log.cjs
@@ -0,0 +1,106 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/components/TerminalPanel.vue', 'utf8');
+
+// Replace standard touch handlers to heavily trace
+let newStart = ` onTouchKeyboardTouchStart = (event: TouchEvent) => {
+ clearTouchScrollMomentum();
+ if (event.touches.length > 0) {
+ touchScrollLastY = event.touches[0]?.clientY || 0;
+ touchScrollLastTime = performance.now();
+ touchScrollVelocity = 0;
+ }
+
+ console.log("[Scroll Deep] 🟢 TOUCH START:", {
+ touches: event.touches.length,
+ isCancelable: event.cancelable,
+ target: event.target?.nodeName,
+ viewportScrollTop: viewportScroller?.scrollTop
+ });
+
+ if (!helperTextarea) return;
+ if (focusKeyboardTimerId !== null) {
+ window.clearTimeout(focusKeyboardTimerId);
+ focusKeyboardTimerId = null;
+ clearFocusKeyboardBlurRecover();
+ focusKeyboardInProgress = false;
+ }
+ helperTextarea.readOnly = true;
+ };`;
+
+let newMove = ` onTouchKeyboardTouchMove = (event: TouchEvent) => {
+ console.log(\`[Scroll Deep] 🔵 TOUCH MOVE | cancelable: \${event.cancelable} | target: \${event.target?.nodeName}\`);
+ const hasSelection = hasActiveNativeSelectionInTerminal();
+ if (hasSelection) {
+ lastTouchAction = "PASS_NATIVE";
+ event.stopImmediatePropagation();
+ return;
+ }
+
+ const now = performance.now();
+ const dt = now - touchScrollLastTime;
+ const currentY = event.touches[0]?.clientY || 0;
+ const dy = currentY - touchScrollLastY;
+
+ // Stop xterm from intercepting this manually
+ event.stopImmediatePropagation();
+
+ if (viewportScroller && dt > 0) {
+ const before = viewportScroller.scrollTop;
+ if (dy !== 0) {
+ // JS 强控滑动:完全跟手,零延迟
+ viewportScroller.scrollTop -= dy;
+ }
+
+ // 计算物理滑动速度,用于释放后的动量
+ const v = (-dy / dt) * 16;
+ if (touchScrollVelocity * v > 0) {
+ touchScrollVelocity = touchScrollVelocity * 0.5 + v * 0.5;
+ } else {
+ touchScrollVelocity = v;
+ }
+
+ const touchMaxSpeed = 120;
+ if (touchScrollVelocity > touchMaxSpeed) touchScrollVelocity = touchMaxSpeed;
+ else if (touchScrollVelocity < -touchMaxSpeed) touchScrollVelocity = -touchMaxSpeed;
+
+ if (Math.abs(dy) > 2) {
+ console.log(\`[Scroll Deep] ➡️ 手指位移真实触发生效,Δy=\${dy.toFixed(1)}px | 渲染视口=\${before}→\${viewportScroller.scrollTop} | v=\${v.toFixed(2)}\`);
+ }
+ }
+
+ touchScrollLastY = currentY;
+ touchScrollLastTime = now;
+ };`;
+
+let newEnd = ` onTouchKeyboardTouchEnd = (event: TouchEvent) => {
+ console.log(\`[Scroll Deep] 🔴 TOUCH END/CANCEL | cancelable: \${event.cancelable} | target: \${event.target?.nodeName} | 最终 scrollTop: \${viewportScroller?.scrollTop}\`);
+
+ const threshold = 0.2;
+ if (Math.abs(touchScrollVelocity) > threshold) {
+ touchScrollVelocity *= 1.35;
+ console.log(\`[Scroll Deep] 🚀 准备进行 JS 惯性滚动,起步速度=\${touchScrollVelocity.toFixed(2)}\`);
+ runTouchScrollMomentum();
+ } else {
+ console.log(\`[Scroll Deep] 🛑 静止释放,不触发惯性\`);
+ }
+ };`;
+
+code = code.replace(/onTouchKeyboardTouchStart = \(event: TouchEvent\) => \{[\s\S]*?\}\;\n onTouchKeyboardTouchMove = \(event: TouchEvent\) => \{[\s\S]*?\}\;\n onTouchKeyboardTouchEnd = \(\) => \{[\s\S]*?\}\;/m,
+ newStart + '\n' + newMove + '\n' + newEnd);
+
+// Add pointer event logs
+code = code.replace(/onTouchKeyboardPointerDown = \(event: PointerEvent\) => \{/, `onTouchKeyboardPointerDown = (event: PointerEvent) => {
+ console.log("[Scroll Deep] 🟡 POINTER DOWN: ", { pointerId: event.pointerId, type: event.pointerType, target: event.target?.nodeName });`);
+
+code = code.replace(/onTouchKeyboardPointerMove = \(event: PointerEvent\) => \{/, `onTouchKeyboardPointerMove = (event: PointerEvent) => {
+ if (event.pointerType === "touch" && touchGatePointerId === event.pointerId) {
+ // only log occasionally to avoid spam, we are heavily logging touchmove already
+ }`);
+
+code = code.replace(/onTouchKeyboardPointerUp = \(event: PointerEvent\) => \{/, `onTouchKeyboardPointerUp = (event: PointerEvent) => {
+ console.log("[Scroll Deep] 🟠 POINTER UP: ", { pointerId: event.pointerId, type: event.pointerType });`);
+
+code = code.replace(/onTouchKeyboardPointerCancel = \(event: PointerEvent\) => \{/, `onTouchKeyboardPointerCancel = (event: PointerEvent) => {
+ console.log("[Scroll Deep] ⚫ POINTER CANCEL: ", { pointerId: event.pointerId, type: event.pointerType });`);
+
+fs.writeFileSync('src/components/TerminalPanel.vue', code);
diff --git a/pxterm/patch_ensure_all_listeners.cjs b/pxterm/patch_ensure_all_listeners.cjs
new file mode 100644
index 0000000..eb766e5
--- /dev/null
+++ b/pxterm/patch_ensure_all_listeners.cjs
@@ -0,0 +1,13 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/components/TerminalPanel.vue', 'utf8');
+
+// The original xterm might be intercepting touchstart to call preventDefault? No, xterm doesn't do that.
+// What about the terminal's wheel? xterm handles wheel.
+
+// The issue might be that pointer events are taking priority and scrolling text selection instead of the scroll pane.
+
+// Re-enable ALL events with very clear logs.
+code = code.replace(/containerRef\.value\.addEventListener\("touchmove", onTouchKeyboardTouchMove, \{ capture: true, passive: true \}\);/,
+ 'containerRef.value.addEventListener("touchmove", onTouchKeyboardTouchMove, { capture: true, passive: false });');
+
+fs.writeFileSync('src/components/TerminalPanel.vue', code);
diff --git a/pxterm/patch_passive_touch.cjs b/pxterm/patch_passive_touch.cjs
new file mode 100644
index 0000000..7cc6acb
--- /dev/null
+++ b/pxterm/patch_passive_touch.cjs
@@ -0,0 +1,24 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/components/TerminalPanel.vue', 'utf8');
+
+// The reason it fired PointerUp might be because iOS triggered a "click" action or lost touch tracking.
+// Since we used e.preventDefault() in touchmove, Safari DOES NOT cancel the pointer if it thinks we are handling panning.
+// BUT Safari needs `touch-action: none` on the container, otherwise it might intercept the gesture asynchronously, which sometimes leads to lost events.
+// Wait, if it intercepted, it fires touchcancel.
+// Let's modify the touchListeners to use non-passive on touchstart too!
+code = code.replace(/containerRef.value.addEventListener\("touchstart", onTouchKeyboardTouchStart, \{ capture: true, passive: true \}\);/g,
+ 'containerRef.value.addEventListener("touchstart", onTouchKeyboardTouchStart, { capture: true, passive: false });');
+
+// Actually, in CSS, `touch-action: none` completely disables native panning, giving JS 100% control of touchmove. IF text selection requires panning, then we should use `touch-action: pan-x pan-y`, but `none` solves weird touch interruptions.
+// Let's modify touchstart to preventDefault if it's strictly a scroll gesture? No, touchstart doesn't know it's a scroll yet.
+
+// Let's look at pointermove:
+// xterm literally calls `event.preventDefault()` on touchmove internally IF IT IS NOT PASSIVE.
+// BUT we intercepted and called stopImmediatePropagation, so xterm never sees it.
+// If the user says "手指一直滑动,但屏幕没有滚动", and NO MOVES LOGGED AFTER THE FIRST ONE, it means the browser killed the JS event stream because it decided to take over the pan, OR it fired touchend immediately. (The log shows Pointer Up... did Touch End log?)
+// Wait! There is NO Touch End log in the user's snippet!
+// "🟡 POINTER DOWN" -> "🟢 TOUCH START" -> "🔵 TOUCH MOVE" -> "➡️ 手指位移" -> "🟠 POINTER UP"
+// ONLY Pointer Up fired! Touch End and Touch Cancel NEVER fired? Or maybe they were missed? No, the user would provide them.
+
+// Why would Pointer Up fire without Touch End? iOS Safari sometimes drops touch events in favor of pointer events if scrolling initiates.
+
diff --git a/pxterm/patch_prevent_default.cjs b/pxterm/patch_prevent_default.cjs
new file mode 100644
index 0000000..29b1121
--- /dev/null
+++ b/pxterm/patch_prevent_default.cjs
@@ -0,0 +1,11 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/components/TerminalPanel.vue', 'utf8');
+
+// Inside touchmove, add e.preventDefault() if it is cancelable
+code = code.replace(/event\.stopImmediatePropagation\(\);/g,
+ `event.stopImmediatePropagation();
+ if (event.cancelable) {
+ event.preventDefault(); // 我们使用 JS 控制滚动,必须禁止系统原生滚动争抢
+ }`);
+
+fs.writeFileSync('src/components/TerminalPanel.vue', code);
diff --git a/pxterm/patch_revert_pointer.cjs b/pxterm/patch_revert_pointer.cjs
new file mode 100644
index 0000000..6f614e1
--- /dev/null
+++ b/pxterm/patch_revert_pointer.cjs
@@ -0,0 +1,18 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/components/TerminalPanel.vue', 'utf8');
+
+// The issue: "手指一直滑动,但屏幕没有滚动. 似乎无效的事件并没有被捕捉到"
+// AND we only saw ONE touchmove!
+// WAIT! "🟠 POINTER UP" fired immediately!
+// This means the OS literally cancelled the touch gesture because it thinks it became a native scrolling gesture!!
+// In iOS Safari, if you don't call preventDefault() on BOTH `touchstart` AND `touchmove` IMMEDIATELY, the browser takes over the touch payload, makes it a pan/scroll gesture, sending a `pointercancel` or `pointerup`, and STOPS firing `touchmove` to JS!
+// But wait, `touchstart` is marked as { passive: true }. iOS is seizing the pan gesture.
+
+// What if we try to switch back to letting xterm do its own DOM scroll but with proper CSS config?
+// No, the user explicitly asked NOT to use xterm's code and that xterm hijacked it.
+// Let's modify touchstart to be non-passive, or at least understand why pointerUp happened.
+// "POINTER UP" fired instead of "POINTER CANCEL". If it was a system pan, it would be pointerCancel or touchCancel. pointerUp means physical lift? But user says "手指一直滑动". So maybe user slid, but the touch tracker ended?
+// Actually, no, if it's long press native selection, pointerUp.
+// Wait! `absDy > TOUCH_KEYBOARD_TAP_MAX_MOVE_PX` -> `event.stopImmediatePropagation()` on pointermove!
+// Oh! Does our `pointermove` prevent `touchmove` from firing if pointer events are capturing it?
+// No, DOM standard dictates both fire. But in Safari, touch and pointer events interact weirdly.
diff --git a/pxterm/patch_smooth_scroll.cjs b/pxterm/patch_smooth_scroll.cjs
new file mode 100644
index 0000000..42f7d0c
--- /dev/null
+++ b/pxterm/patch_smooth_scroll.cjs
@@ -0,0 +1,27 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/components/TerminalPanel.vue', 'utf8');
+
+// The reason it's stuttering and freezing might be related to CSS native scrolling fighting with xterm's pointer/touch handlers, or our own JS momentum not playing well.
+// But wait, the logs showed Δy is HUGE (e.g. 102px in 16ms -> speed -102).
+// And the `viewportScroller.scrollTop` was actually updating fine, BUT xterm might not be syncing its rows because xterm intercepts `wheel` and `touchmove` originally to sync `core._core.viewport.scroll`.
+// Wait... if we update `.xterm-viewport`'s `scrollTop` manually, does xterm re-render the canvas/dom lines?
+// YES it does through the `scroll` event listener on '.xterm-viewport'.
+
+// Let's completely nuke `pointermove` preventions and xterm interception during passive scrolls, and let the BROWSER do EVERYTHING natively.
+// The user noted that when "现在手指滑动失效" (scrolling broke altogether), it was when we removed EventListeners completely. Because xterm sets up the viewport explicitly.
+
+// Let's revert touchmove and touchstart, and pointermove to just STOP PROPAGATION but NOT prevent default, and importantly, REMOVE our JS scroll tracking since native scroll is just better. Wait. The user's logs WERE our JS scrolling!
+// Look at the logs:
+// [Log] [Scroll] 🖐️ MOVE | 跟手延迟Δt=1.0ms | 手指位移Δy=50.7px | 渲染视口=3896→3845 | 当前算得速度v=-810.67
+// The browser JS engine is grouping events. 1.0ms and jumping 50.7px. This means it's a huge jump between RAF frames.
+// JS-driven scrolling via touchmove on mobile is ALWAYS janky because `touchmove` frequency is lower and uneven compared to compositor scrolling.
+
+// What we MUST DO:
+// 1. Let browser natively scroll the container (.xterm-viewport has overflow-y: auto)
+// 2. STOP xterm from intercepting touchmove so it doesn't call e.preventDefault().
+// 3. We do NOT update scrollTop manually.
+// 4. We DO use pointers / touches.
+
+// Wait, I already did this! Remember, user said "现在不动" (it doesn't move now) when I just did stopImmediatePropagation.
+// Why did it not move? Because `.xterm-viewport` DOES NOT HAVE CSS overflow: auto?
+// Actually I removed my CSS changes to `main.css` earlier!
diff --git a/pxterm/patch_touch.cjs b/pxterm/patch_touch.cjs
new file mode 100644
index 0000000..27f584f
--- /dev/null
+++ b/pxterm/patch_touch.cjs
@@ -0,0 +1,93 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/components/TerminalPanel.vue', 'utf8');
+
+// Replace standard touch handlers to use JS custom scrolling with deep logging
+let newStart = ` onTouchKeyboardTouchStart = (event: TouchEvent) => {
+ clearTouchScrollMomentum();
+ if (event.touches.length > 0) {
+ touchScrollLastY = event.touches[0]?.clientY || 0;
+ touchScrollLastTime = performance.now();
+ touchScrollVelocity = 0;
+ }
+
+ console.log("[Scroll] 👆 Touch START:", {
+ y: touchScrollLastY,
+ time: touchScrollLastTime.toFixed(1),
+ viewportScrollTop: viewportScroller?.scrollTop
+ });
+
+ if (!helperTextarea) return;
+ if (focusKeyboardTimerId !== null) {
+ window.clearTimeout(focusKeyboardTimerId);
+ focusKeyboardTimerId = null;
+ clearFocusKeyboardBlurRecover();
+ focusKeyboardInProgress = false;
+ }
+ helperTextarea.readOnly = true;
+ if (DEBUG_TOUCH_FOCUS) {
+ console.log("[TouchFocus] touchstart → readOnly=true (no blur)");
+ }
+ };`;
+
+let newMove = ` onTouchKeyboardTouchMove = (event: TouchEvent) => {
+ const hasSelection = hasActiveNativeSelectionInTerminal();
+ if (hasSelection) {
+ lastTouchAction = "PASS_NATIVE";
+ event.stopImmediatePropagation();
+ return;
+ }
+
+ const now = performance.now();
+ const dt = now - touchScrollLastTime;
+ const currentY = event.touches[0]?.clientY || 0;
+ const dy = currentY - touchScrollLastY;
+
+ // Stop xterm from intercepting this manually
+ event.stopImmediatePropagation();
+
+ if (viewportScroller && dt > 0) {
+ const before = viewportScroller.scrollTop;
+ if (dy !== 0) {
+ // JS 强控滑动:完全跟手,零延迟
+ viewportScroller.scrollTop -= dy;
+ }
+
+ // 计算物理滑动速度,用于释放后的动量
+ const v = (-dy / dt) * 16;
+ if (touchScrollVelocity * v > 0) {
+ touchScrollVelocity = touchScrollVelocity * 0.5 + v * 0.5;
+ } else {
+ touchScrollVelocity = v;
+ }
+
+ // 限制最大动量
+ const touchMaxSpeed = 120; // 匹配原本常量
+ if (touchScrollVelocity > touchMaxSpeed) touchScrollVelocity = touchMaxSpeed;
+ else if (touchScrollVelocity < -touchMaxSpeed) touchScrollVelocity = -touchMaxSpeed;
+
+ console.log(\`[Scroll] 🖐️ MOVE | 跟手延迟Δt=\${dt.toFixed(1)}ms | 手指位移Δy=\${dy.toFixed(1)}px | 渲染视口=\${before}→\${viewportScroller.scrollTop} | 当前算得速度v=\${v.toFixed(2)}\`);
+ }
+
+ touchScrollLastY = currentY;
+ touchScrollLastTime = now;
+ };`;
+
+let newEnd = ` onTouchKeyboardTouchEnd = () => {
+ console.log(\`[Scroll] 👇 Touch END | 释放时瞬时速度=\${touchScrollVelocity.toFixed(2)} | 最终 scrollTop: \${viewportScroller?.scrollTop}\`);
+
+ // 添加阈值,防止极轻微误触引发滚动
+ const threshold = 0.2;
+ if (Math.abs(touchScrollVelocity) > threshold) {
+ // 轻微乘子放大,提升顺滑手感
+ touchScrollVelocity *= 1.35;
+ console.log(\`[Scroll] 🚀 触发惯性滚动,起步速度=\${touchScrollVelocity.toFixed(2)}\`);
+ runTouchScrollMomentum();
+ } else {
+ console.log(\`[Scroll] 🛑 速度太小(\${touchScrollVelocity.toFixed(2)}),不触发惯性\`);
+ }
+ };`;
+
+code = code.replace(/onTouchKeyboardTouchStart = \(event: TouchEvent\) => \{[\s\S]*?\}\;\n onTouchKeyboardTouchMove = \(event: TouchEvent\) => \{[\s\S]*?\}\;\n onTouchKeyboardTouchEnd = \(\) => \{[\s\S]*?\}\;/m,
+ newStart + '\n' + newMove + '\n' + newEnd);
+
+fs.writeFileSync('src/components/TerminalPanel.vue', code);
diff --git a/pxterm/public/assets/icons/ai.svg b/pxterm/public/assets/icons/ai.svg
new file mode 100644
index 0000000..2a8e99f
--- /dev/null
+++ b/pxterm/public/assets/icons/ai.svg
@@ -0,0 +1,4 @@
+
diff --git a/pxterm/public/assets/icons/cancel.svg b/pxterm/public/assets/icons/cancel.svg
new file mode 100644
index 0000000..4946907
--- /dev/null
+++ b/pxterm/public/assets/icons/cancel.svg
@@ -0,0 +1,3 @@
+
diff --git a/pxterm/public/assets/icons/clear-input.svg b/pxterm/public/assets/icons/clear-input.svg
new file mode 100644
index 0000000..54b2bdd
--- /dev/null
+++ b/pxterm/public/assets/icons/clear-input.svg
@@ -0,0 +1,3 @@
+
diff --git a/pxterm/public/assets/icons/move.svg b/pxterm/public/assets/icons/move.svg
new file mode 100644
index 0000000..d46daeb
--- /dev/null
+++ b/pxterm/public/assets/icons/move.svg
@@ -0,0 +1,3 @@
+
diff --git a/pxterm/public/assets/icons/record.svg b/pxterm/public/assets/icons/record.svg
new file mode 100644
index 0000000..44daf8d
--- /dev/null
+++ b/pxterm/public/assets/icons/record.svg
@@ -0,0 +1,3 @@
+
diff --git a/pxterm/public/assets/icons/recordmanage.svg b/pxterm/public/assets/icons/recordmanage.svg
new file mode 100644
index 0000000..44085d5
--- /dev/null
+++ b/pxterm/public/assets/icons/recordmanage.svg
@@ -0,0 +1,3 @@
+
diff --git a/pxterm/public/assets/icons/recordmanager.svg b/pxterm/public/assets/icons/recordmanager.svg
new file mode 100644
index 0000000..a0369ae
--- /dev/null
+++ b/pxterm/public/assets/icons/recordmanager.svg
@@ -0,0 +1,3 @@
+
diff --git a/pxterm/public/assets/icons/sent.svg b/pxterm/public/assets/icons/sent.svg
new file mode 100644
index 0000000..d590f74
--- /dev/null
+++ b/pxterm/public/assets/icons/sent.svg
@@ -0,0 +1,3 @@
+
diff --git a/pxterm/public/assets/icons/voice.svg b/pxterm/public/assets/icons/voice.svg
new file mode 100644
index 0000000..573f352
--- /dev/null
+++ b/pxterm/public/assets/icons/voice.svg
@@ -0,0 +1,6 @@
+
diff --git a/pxterm/public/icons/back.svg b/pxterm/public/icons/back.svg
new file mode 100644
index 0000000..4d3c468
--- /dev/null
+++ b/pxterm/public/icons/back.svg
@@ -0,0 +1,4 @@
+
diff --git a/pxterm/public/icons/cancel.svg b/pxterm/public/icons/cancel.svg
new file mode 100644
index 0000000..4946907
--- /dev/null
+++ b/pxterm/public/icons/cancel.svg
@@ -0,0 +1,3 @@
+
diff --git a/pxterm/public/icons/clear-input.svg b/pxterm/public/icons/clear-input.svg
new file mode 100644
index 0000000..54b2bdd
--- /dev/null
+++ b/pxterm/public/icons/clear-input.svg
@@ -0,0 +1,3 @@
+
diff --git a/pxterm/public/icons/clear.svg b/pxterm/public/icons/clear.svg
new file mode 100644
index 0000000..95d7152
--- /dev/null
+++ b/pxterm/public/icons/clear.svg
@@ -0,0 +1,3 @@
+
diff --git a/pxterm/public/icons/codex.svg b/pxterm/public/icons/codex.svg
new file mode 100644
index 0000000..e2c526f
--- /dev/null
+++ b/pxterm/public/icons/codex.svg
@@ -0,0 +1,4 @@
+
diff --git a/pxterm/public/icons/config.svg b/pxterm/public/icons/config.svg
new file mode 100644
index 0000000..3f7de13
--- /dev/null
+++ b/pxterm/public/icons/config.svg
@@ -0,0 +1,3 @@
+
diff --git a/pxterm/public/icons/connect.svg b/pxterm/public/icons/connect.svg
new file mode 100644
index 0000000..a017ba2
--- /dev/null
+++ b/pxterm/public/icons/connect.svg
@@ -0,0 +1,7 @@
+
diff --git a/pxterm/public/icons/create.svg b/pxterm/public/icons/create.svg
new file mode 100644
index 0000000..00854f1
--- /dev/null
+++ b/pxterm/public/icons/create.svg
@@ -0,0 +1,3 @@
+
diff --git a/pxterm/public/icons/delete.svg b/pxterm/public/icons/delete.svg
new file mode 100644
index 0000000..c30efbe
--- /dev/null
+++ b/pxterm/public/icons/delete.svg
@@ -0,0 +1,3 @@
+
diff --git a/pxterm/public/icons/enter.svg b/pxterm/public/icons/enter.svg
new file mode 100644
index 0000000..2128956
--- /dev/null
+++ b/pxterm/public/icons/enter.svg
@@ -0,0 +1,3 @@
+
diff --git a/pxterm/public/icons/keyboard-arrows.svg b/pxterm/public/icons/keyboard-arrows.svg
new file mode 100644
index 0000000..27751a0
--- /dev/null
+++ b/pxterm/public/icons/keyboard-arrows.svg
@@ -0,0 +1,20 @@
+
diff --git a/pxterm/public/icons/keyboard.svg b/pxterm/public/icons/keyboard.svg
new file mode 100644
index 0000000..fcef393
--- /dev/null
+++ b/pxterm/public/icons/keyboard.svg
@@ -0,0 +1,3 @@
+
diff --git a/pxterm/public/icons/log.svg b/pxterm/public/icons/log.svg
new file mode 100644
index 0000000..c04c2d6
--- /dev/null
+++ b/pxterm/public/icons/log.svg
@@ -0,0 +1,6 @@
+
diff --git a/pxterm/public/icons/paste.svg b/pxterm/public/icons/paste.svg
new file mode 100644
index 0000000..d0c2693
--- /dev/null
+++ b/pxterm/public/icons/paste.svg
@@ -0,0 +1,3 @@
+
diff --git a/pxterm/public/icons/plugins.svg b/pxterm/public/icons/plugins.svg
new file mode 100644
index 0000000..993db13
--- /dev/null
+++ b/pxterm/public/icons/plugins.svg
@@ -0,0 +1,6 @@
+
diff --git a/pxterm/public/icons/save.svg b/pxterm/public/icons/save.svg
new file mode 100644
index 0000000..e2aabfe
--- /dev/null
+++ b/pxterm/public/icons/save.svg
@@ -0,0 +1,3 @@
+
diff --git a/pxterm/public/icons/search.svg b/pxterm/public/icons/search.svg
new file mode 100644
index 0000000..066aeff
--- /dev/null
+++ b/pxterm/public/icons/search.svg
@@ -0,0 +1,6 @@
+
diff --git a/pxterm/public/icons/selectall.svg b/pxterm/public/icons/selectall.svg
new file mode 100644
index 0000000..78e4427
--- /dev/null
+++ b/pxterm/public/icons/selectall.svg
@@ -0,0 +1,3 @@
+
diff --git a/pxterm/public/icons/sent.svg b/pxterm/public/icons/sent.svg
new file mode 100644
index 0000000..d590f74
--- /dev/null
+++ b/pxterm/public/icons/sent.svg
@@ -0,0 +1,3 @@
+
diff --git a/pxterm/public/icons/serverlist.svg b/pxterm/public/icons/serverlist.svg
new file mode 100644
index 0000000..c1da79e
--- /dev/null
+++ b/pxterm/public/icons/serverlist.svg
@@ -0,0 +1,3 @@
+
diff --git a/pxterm/public/icons/voice.svg b/pxterm/public/icons/voice.svg
new file mode 100644
index 0000000..573f352
--- /dev/null
+++ b/pxterm/public/icons/voice.svg
@@ -0,0 +1,6 @@
+
diff --git a/pxterm/src/App.vue b/pxterm/src/App.vue
new file mode 100644
index 0000000..eb2176b
--- /dev/null
+++ b/pxterm/src/App.vue
@@ -0,0 +1,556 @@
+
+
+
+
+
+
+ {{ toToastTitle(toast.level) }}
+ {{ toast.message }}
+
+
+
+
+
+
diff --git a/pxterm/src/assets/icons/ai.svg b/pxterm/src/assets/icons/ai.svg
new file mode 100644
index 0000000..cd0649c
--- /dev/null
+++ b/pxterm/src/assets/icons/ai.svg
@@ -0,0 +1,7 @@
+
diff --git a/pxterm/src/assets/icons/move.svg b/pxterm/src/assets/icons/move.svg
new file mode 100644
index 0000000..f331448
--- /dev/null
+++ b/pxterm/src/assets/icons/move.svg
@@ -0,0 +1,8 @@
+
diff --git a/pxterm/src/components/TerminalPanel.vue b/pxterm/src/components/TerminalPanel.vue
new file mode 100644
index 0000000..16e1a4a
--- /dev/null
+++ b/pxterm/src/components/TerminalPanel.vue
@@ -0,0 +1,2188 @@
+
+
+
+ 请点击右上角“重连”开关或左上角AI按钮,重新连接。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pxterm/src/components/TerminalPanel.vue.bak b/pxterm/src/components/TerminalPanel.vue.bak
new file mode 100644
index 0000000..8185411
--- /dev/null
+++ b/pxterm/src/components/TerminalPanel.vue.bak
@@ -0,0 +1,2093 @@
+
+
+
+ 请点击右上角“重连”开关或左上角AI按钮,重新连接。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pxterm/src/components/TerminalPanel.vue.bak2 b/pxterm/src/components/TerminalPanel.vue.bak2
new file mode 100644
index 0000000..717a247
--- /dev/null
+++ b/pxterm/src/components/TerminalPanel.vue.bak2
@@ -0,0 +1,2094 @@
+
+
+
+ 请点击右上角“重连”开关或左上角AI按钮,重新连接。
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pxterm/src/components/TerminalVoiceInput.vue b/pxterm/src/components/TerminalVoiceInput.vue
new file mode 100644
index 0000000..ec85be4
--- /dev/null
+++ b/pxterm/src/components/TerminalVoiceInput.vue
@@ -0,0 +1,1338 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pxterm/src/env.d.ts b/pxterm/src/env.d.ts
new file mode 100644
index 0000000..e09a6fe
--- /dev/null
+++ b/pxterm/src/env.d.ts
@@ -0,0 +1,11 @@
+///
+
+interface ImportMetaEnv {
+ readonly VITE_GATEWAY_URL?: string;
+ readonly VITE_GATEWAY_TOKEN?: string;
+ readonly VITE_ENABLE_PLUGIN_RUNTIME?: string;
+}
+
+interface ImportMeta {
+ readonly env: ImportMetaEnv;
+}
diff --git a/pxterm/src/main.ts b/pxterm/src/main.ts
new file mode 100644
index 0000000..0daaad3
--- /dev/null
+++ b/pxterm/src/main.ts
@@ -0,0 +1,87 @@
+import { createApp } from "vue";
+import { createPinia } from "pinia";
+import { createRouter, createWebHistory } from "vue-router";
+import App from "./App.vue";
+import { routes } from "./routes";
+import { installDynamicImportRecovery } from "./utils/dynamicImportGuard";
+import "./styles/main.css";
+
+/**
+ * 全局禁止双指缩放:
+ * - iOS Safari: 拦截 gesturestart/gesturechange/gestureend;
+ * - 触屏浏览器: 双触点 touchmove 时阻止默认缩放手势;
+ * - 桌面触控板: Ctrl + wheel 缩放时阻止默认行为。
+ */
+function installPinchZoomGuard(): void {
+ const options: AddEventListenerOptions = { passive: false };
+
+ const preventDefault = (event: Event): void => {
+ event.preventDefault();
+ };
+
+ const onTouchMove = (event: TouchEvent): void => {
+ if (event.touches.length > 1) {
+ event.preventDefault();
+ }
+ };
+
+ const onWheel = (event: WheelEvent): void => {
+ if (event.ctrlKey) {
+ event.preventDefault();
+ }
+ };
+
+ document.addEventListener("gesturestart", preventDefault, options);
+ document.addEventListener("gesturechange", preventDefault, options);
+ document.addEventListener("gestureend", preventDefault, options);
+ document.addEventListener("touchmove", onTouchMove, options);
+ document.addEventListener("wheel", onWheel, options);
+}
+
+/**
+ * 全局禁止双击放大:
+ * - 移动端:拦截短时间内连续 touchend(双击手势);
+ * - 桌面端:拦截 dblclick 默认缩放行为。
+ */
+function installDoubleTapZoomGuard(): void {
+ const options: AddEventListenerOptions = { passive: false };
+ let lastTouchEndAt = 0;
+ const DOUBLE_TAP_WINDOW_MS = 320;
+
+ document.addEventListener(
+ "touchend",
+ (event: TouchEvent): void => {
+ const now = Date.now();
+ if (now - lastTouchEndAt <= DOUBLE_TAP_WINDOW_MS) {
+ event.preventDefault();
+ }
+ lastTouchEndAt = now;
+ },
+ options
+ );
+
+ document.addEventListener(
+ "dblclick",
+ (event: MouseEvent): void => {
+ event.preventDefault();
+ },
+ options
+ );
+}
+
+installPinchZoomGuard();
+installDoubleTapZoomGuard();
+
+const app = createApp(App);
+const pinia = createPinia();
+const router = createRouter({
+ history: createWebHistory(),
+ routes
+});
+
+installDynamicImportRecovery(router);
+
+app.use(pinia);
+app.use(router);
+
+app.mount("#app");
diff --git a/pxterm/src/routes.ts b/pxterm/src/routes.ts
new file mode 100644
index 0000000..492667d
--- /dev/null
+++ b/pxterm/src/routes.ts
@@ -0,0 +1,42 @@
+import type { RouteRecordRaw } from "vue-router";
+
+const pluginRuntimeEnabled = import.meta.env.VITE_ENABLE_PLUGIN_RUNTIME !== "false";
+
+export const routes: RouteRecordRaw[] = [
+ {
+ path: "/",
+ redirect: "/connect"
+ },
+ {
+ path: "/connect",
+ component: () => import("./views/ConnectView.vue")
+ },
+ {
+ path: "/server/:id/settings",
+ component: () => import("./views/ServerSettingsView.vue")
+ },
+ {
+ path: "/terminal",
+ component: () => import("./views/TerminalView.vue")
+ },
+ {
+ path: "/logs",
+ component: () => import("./views/LogsView.vue")
+ },
+ {
+ path: "/records",
+ component: () => import("./views/RecordsView.vue")
+ },
+ {
+ path: "/settings",
+ component: () => import("./views/SettingsView.vue")
+ },
+ ...(pluginRuntimeEnabled
+ ? [
+ {
+ path: "/plugins",
+ component: () => import("./views/PluginsView.vue")
+ }
+ ]
+ : [])
+];
diff --git a/pxterm/src/services/security/credentialVault.ts b/pxterm/src/services/security/credentialVault.ts
new file mode 100644
index 0000000..01134bd
--- /dev/null
+++ b/pxterm/src/services/security/credentialVault.ts
@@ -0,0 +1,94 @@
+import type { EncryptedCredentialPayload } from "@/types/app";
+import { getSettings } from "@/services/storage/db";
+
+const SESSION_KEY_STORAGE = "remoteconn_crypto_key_session_v2";
+const PERSIST_KEY_STORAGE = "remoteconn_crypto_key_persist_v2";
+const LEGACY_SESSION_KEY_STORAGE = "remoteconn_crypto_key_v1";
+
+/**
+ * Web 端无法达到系统 Keychain 等级,这里采用会话密钥 + AES-GCM 做“受限存储”。
+ * 重点是避免明文直接落盘,并在 UI 中持续提示风险。
+ */
+async function getOrCreateSessionKey(): Promise {
+ const remember = await shouldRememberCredentialKey();
+ const encoded = readEncodedKey();
+
+ if (encoded) {
+ // 统一迁移到新 key 名,并按策略决定是否持久化。
+ sessionStorage.setItem(SESSION_KEY_STORAGE, encoded);
+ if (remember) {
+ localStorage.setItem(PERSIST_KEY_STORAGE, encoded);
+ } else {
+ localStorage.removeItem(PERSIST_KEY_STORAGE);
+ }
+
+ const raw = Uint8Array.from(atob(encoded), (s) => s.charCodeAt(0));
+ return await crypto.subtle.importKey("raw", raw, "AES-GCM", true, ["encrypt", "decrypt"]);
+ }
+
+ const raw = crypto.getRandomValues(new Uint8Array(32));
+ const nextEncoded = btoa(String.fromCharCode(...raw));
+ sessionStorage.setItem(SESSION_KEY_STORAGE, nextEncoded);
+ if (remember) {
+ localStorage.setItem(PERSIST_KEY_STORAGE, nextEncoded);
+ }
+ return await crypto.subtle.importKey("raw", raw, "AES-GCM", true, ["encrypt", "decrypt"]);
+}
+
+/**
+ * 读取当前凭据密钥保存策略:remember 时允许跨刷新/重开保留密钥。
+ * 若读取设置失败,默认走 remember,避免凭据“看似丢失”。
+ */
+async function shouldRememberCredentialKey(): Promise {
+ try {
+ const settings = await getSettings();
+ return (settings?.credentialMemoryPolicy ?? "remember") === "remember";
+ } catch {
+ return true;
+ }
+}
+
+function readEncodedKey(): string | null {
+ return (
+ sessionStorage.getItem(SESSION_KEY_STORAGE) ??
+ sessionStorage.getItem(LEGACY_SESSION_KEY_STORAGE) ??
+ localStorage.getItem(PERSIST_KEY_STORAGE)
+ );
+}
+
+function encodeBase64(source: Uint8Array): string {
+ return btoa(String.fromCharCode(...source));
+}
+
+function decodeBase64ToArrayBuffer(source: string): ArrayBuffer {
+ const bytes = Uint8Array.from(atob(source), (s) => s.charCodeAt(0));
+ return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
+}
+
+export async function encryptCredential(refId: string, value: unknown): Promise {
+ const key = await getOrCreateSessionKey();
+ const iv = crypto.getRandomValues(new Uint8Array(12));
+ const payload = new TextEncoder().encode(JSON.stringify(value));
+
+ const encrypted = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, key, payload));
+
+ const now = new Date().toISOString();
+ return {
+ id: `cred-${crypto.randomUUID()}`,
+ refId,
+ encrypted: encodeBase64(encrypted),
+ iv: encodeBase64(iv),
+ createdAt: now,
+ updatedAt: now
+ };
+}
+
+export async function decryptCredential(payload: EncryptedCredentialPayload): Promise {
+ const key = await getOrCreateSessionKey();
+ const decrypted = await crypto.subtle.decrypt(
+ { name: "AES-GCM", iv: decodeBase64ToArrayBuffer(payload.iv) },
+ key,
+ decodeBase64ToArrayBuffer(payload.encrypted)
+ );
+ return JSON.parse(new TextDecoder().decode(new Uint8Array(decrypted))) as T;
+}
diff --git a/pxterm/src/services/sessionEventBus.ts b/pxterm/src/services/sessionEventBus.ts
new file mode 100644
index 0000000..90e65dd
--- /dev/null
+++ b/pxterm/src/services/sessionEventBus.ts
@@ -0,0 +1,21 @@
+type EventName = "connected" | "disconnected" | "stdout" | "stderr" | "latency";
+
+type Handler = (payload: unknown) => void;
+
+const listeners = new Map>();
+
+export function onSessionEvent(eventName: EventName, handler: Handler): () => void {
+ if (!listeners.has(eventName)) {
+ listeners.set(eventName, new Set());
+ }
+ listeners.get(eventName)!.add(handler);
+ return () => listeners.get(eventName)?.delete(handler);
+}
+
+export function emitSessionEvent(eventName: EventName, payload: unknown): void {
+ const set = listeners.get(eventName);
+ if (!set) return;
+ for (const handler of set) {
+ handler(payload);
+ }
+}
diff --git a/pxterm/src/services/storage/db.ts b/pxterm/src/services/storage/db.ts
new file mode 100644
index 0000000..2466335
--- /dev/null
+++ b/pxterm/src/services/storage/db.ts
@@ -0,0 +1,95 @@
+import Dexie, { type EntityTable } from "dexie";
+import type { CredentialRef, ServerProfile, SessionLog } from "@remoteconn/shared";
+import type { EncryptedCredentialPayload, GlobalSettings, VoiceRecord } from "@/types/app";
+import type { PluginPackage, PluginRecord } from "@remoteconn/plugin-runtime";
+
+interface KnownHostEntity {
+ key: string;
+ fingerprint: string;
+ updatedAt: string;
+}
+
+interface SettingEntity {
+ key: string;
+ value: GlobalSettings;
+}
+
+interface PluginDataEntity {
+ key: string;
+ value: unknown;
+}
+
+class RemoteConnDb extends Dexie {
+ public servers!: EntityTable;
+ public credentialRefs!: EntityTable;
+ public credentials!: EntityTable;
+ public sessionLogs!: EntityTable;
+ public knownHosts!: EntityTable;
+ public settings!: EntityTable;
+ public pluginPackages!: EntityTable;
+ public pluginRecords!: EntityTable;
+ public pluginData!: EntityTable;
+ public voiceRecords!: EntityTable;
+
+ public constructor() {
+ super("remoteconn_db");
+
+ this.version(2).stores({
+ servers: "id, name, host, lastConnectedAt",
+ credentialRefs: "id, type, updatedAt",
+ credentials: "id, refId, updatedAt",
+ sessionLogs: "sessionId, serverId, startAt, status",
+ knownHosts: "key, updatedAt",
+ settings: "key",
+ pluginPackages: "id",
+ pluginRecords: "id, status",
+ pluginData: "key"
+ });
+
+ this.version(3).stores({
+ servers: "id, name, host, lastConnectedAt",
+ credentialRefs: "id, type, updatedAt",
+ credentials: "id, refId, updatedAt",
+ sessionLogs: "sessionId, serverId, startAt, status",
+ knownHosts: "key, updatedAt",
+ settings: "key",
+ pluginPackages: "id",
+ pluginRecords: "id, status",
+ pluginData: "key",
+ voiceRecords: "id, createdAt, serverId"
+ });
+ }
+}
+
+export const db = new RemoteConnDb();
+async function ensureDbOpen(): Promise {
+ if (!db.isOpen()) {
+ await db.open();
+ }
+}
+
+export async function getSettings(): Promise {
+ await ensureDbOpen();
+ const row = await db.settings.get("global");
+ return row?.value ?? null;
+}
+
+export async function setSettings(value: GlobalSettings): Promise {
+ await ensureDbOpen();
+ await db.settings.put({ key: "global", value });
+}
+
+export async function getKnownHosts(): Promise> {
+ await ensureDbOpen();
+ const rows = await db.knownHosts.toArray();
+ return Object.fromEntries(rows.map((row) => [row.key, row.fingerprint]));
+}
+
+export async function upsertKnownHost(key: string, fingerprint: string): Promise {
+ await ensureDbOpen();
+ await db.knownHosts.put({
+ key,
+ fingerprint,
+ updatedAt: new Date().toISOString()
+ });
+}
diff --git a/pxterm/src/services/storage/pluginFsAdapter.ts b/pxterm/src/services/storage/pluginFsAdapter.ts
new file mode 100644
index 0000000..1e9502b
--- /dev/null
+++ b/pxterm/src/services/storage/pluginFsAdapter.ts
@@ -0,0 +1,43 @@
+import type { PluginFsAdapter, PluginPackage } from "@remoteconn/plugin-runtime";
+import { db } from "./db";
+
+/**
+ * Web 端插件存储适配:
+ * - 插件包与记录保存在 IndexedDB
+ * - 提供与插件运行时一致的读写接口
+ */
+export class WebPluginFsAdapter implements PluginFsAdapter {
+ public async listPackages(): Promise {
+ const rows = await db.pluginPackages.toArray();
+ return rows.map(({ id: _id, ...pkg }) => pkg);
+ }
+
+ public async getPackage(pluginId: string): Promise {
+ const row = await db.pluginPackages.get(pluginId);
+ if (!row) {
+ return null;
+ }
+ const { id: _id, ...pkg } = row;
+ return pkg;
+ }
+
+ public async upsertPackage(pluginPackage: PluginPackage): Promise {
+ await db.pluginPackages.put({ id: pluginPackage.manifest.id, ...pluginPackage });
+ }
+
+ public async removePackage(pluginId: string): Promise {
+ await db.pluginPackages.delete(pluginId);
+ }
+
+ public async readStore(key: string, fallback: T): Promise {
+ const row = await db.pluginData.get(key);
+ if (!row) {
+ return fallback;
+ }
+ return row.value as T;
+ }
+
+ public async writeStore(key: string, value: T): Promise {
+ await db.pluginData.put({ key, value });
+ }
+}
diff --git a/pxterm/src/services/transport/factory.ts b/pxterm/src/services/transport/factory.ts
new file mode 100644
index 0000000..096e89c
--- /dev/null
+++ b/pxterm/src/services/transport/factory.ts
@@ -0,0 +1,16 @@
+import type { TerminalTransport } from "./terminalTransport";
+import { GatewayTransport } from "./gatewayTransport";
+import { IosNativeTransport } from "./iosNativeTransport";
+
+/**
+ * 统一传输工厂,屏蔽底层差异。
+ */
+export function createTransport(
+ mode: "gateway" | "ios-native",
+ options: { gatewayUrl: string; gatewayToken: string }
+): TerminalTransport {
+ if (mode === "ios-native") {
+ return new IosNativeTransport();
+ }
+ return new GatewayTransport(options.gatewayUrl, options.gatewayToken);
+}
diff --git a/pxterm/src/services/transport/gatewayTransport.ts b/pxterm/src/services/transport/gatewayTransport.ts
new file mode 100644
index 0000000..3430839
--- /dev/null
+++ b/pxterm/src/services/transport/gatewayTransport.ts
@@ -0,0 +1,335 @@
+import type { GatewayFrame, SessionState, StdinMeta } from "@remoteconn/shared";
+import type { ConnectParams, TerminalTransport, TransportEvent } from "./terminalTransport";
+
+/**
+ * 网关传输实现:Web/小程序共用。
+ */
+export class GatewayTransport implements TerminalTransport {
+ private static readonly CONNECT_TIMEOUT_MS = 12000;
+ private socket: WebSocket | null = null;
+ private listeners = new Set<(event: TransportEvent) => void>();
+ private pingAt = 0;
+ private heartbeatTimer: number | null = null;
+ private state: SessionState = "idle";
+
+ public constructor(
+ private readonly gatewayUrl: string,
+ private readonly token: string
+ ) {}
+
+ public async connect(params: ConnectParams): Promise {
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) {
+ throw new Error("会话已连接");
+ }
+
+ this.state = "connecting";
+
+ this.socket = await new Promise((resolve, reject) => {
+ const endpoints = this.buildEndpoints();
+ const reasons: string[] = [];
+ let index = 0;
+ const candidateHint = `候选地址: ${endpoints.join(", ")}`;
+
+ const tryConnect = (): void => {
+ const endpoint = endpoints[index];
+ if (!endpoint) {
+ reject(new Error(`无法连接网关: ${reasons.join(" | ") || "无可用网关地址"} | ${candidateHint}`));
+ return;
+ }
+ let settled = false;
+ let socket: WebSocket;
+ let timeoutTimer: number | null = null;
+
+ try {
+ socket = new WebSocket(endpoint);
+ } catch {
+ reasons.push(`地址无效: ${endpoint}`);
+ if (index < endpoints.length - 1) {
+ index += 1;
+ tryConnect();
+ return;
+ }
+ reject(new Error(`无法连接网关: ${reasons.join(" | ")} | ${candidateHint}`));
+ return;
+ }
+
+ timeoutTimer = window.setTimeout(() => {
+ fail(`连接超时>${GatewayTransport.CONNECT_TIMEOUT_MS}ms`);
+ }, GatewayTransport.CONNECT_TIMEOUT_MS);
+
+ const clearTimer = (): void => {
+ if (timeoutTimer !== null) {
+ window.clearTimeout(timeoutTimer);
+ timeoutTimer = null;
+ }
+ };
+
+ const fail = (reason: string): void => {
+ if (settled) return;
+ settled = true;
+ clearTimer();
+ reasons.push(`${reason}: ${endpoint}`);
+ try {
+ socket.close();
+ } catch {
+ // 忽略关闭阶段异常,继续下一个候选地址。
+ }
+
+ if (index < endpoints.length - 1) {
+ index += 1;
+ tryConnect();
+ return;
+ }
+
+ reject(new Error(`无法连接网关: ${reasons.join(" | ")} | ${candidateHint}`));
+ };
+
+ socket.onopen = () => {
+ if (settled) return;
+ settled = true;
+ clearTimer();
+ resolve(socket);
+ };
+ socket.onerror = () => fail("网络或协议错误");
+ socket.onclose = (event) => {
+ if (!settled) {
+ fail(`连接关闭 code=${event.code}`);
+ }
+ };
+ };
+
+ tryConnect();
+ });
+
+ this.socket.onmessage = (event) => {
+ const frame = JSON.parse(event.data as string) as GatewayFrame;
+ this.handleFrame(frame);
+ };
+
+ this.socket.onclose = () => {
+ this.stopHeartbeat();
+ this.state = "disconnected";
+ this.emit({ type: "disconnect", reason: "ws_closed" });
+ };
+
+ this.socket.onerror = () => {
+ this.stopHeartbeat();
+ this.state = "error";
+ this.emit({ type: "error", code: "WS_ERROR", message: "WebSocket 异常" });
+ };
+
+ const initFrame: GatewayFrame = {
+ type: "init",
+ payload: {
+ host: params.host,
+ port: params.port,
+ username: params.username,
+ ...(params.clientSessionKey ? { clientSessionKey: params.clientSessionKey } : {}),
+ credential: params.credential,
+ knownHostFingerprint: params.knownHostFingerprint,
+ pty: { cols: params.cols, rows: params.rows }
+ }
+ };
+
+ this.sendRaw(initFrame);
+ this.startHeartbeat();
+ this.state = "auth_pending";
+ }
+
+ public async send(data: string, meta?: StdinMeta): Promise {
+ this.sendRaw({
+ type: "stdin",
+ payload: {
+ data,
+ ...(meta ? { meta } : {})
+ }
+ });
+ }
+
+ public async resize(cols: number, rows: number): Promise {
+ this.sendRaw({ type: "resize", payload: { cols, rows } });
+ }
+
+ public async disconnect(reason = "manual"): Promise {
+ this.stopHeartbeat();
+ if (this.socket && this.socket.readyState === WebSocket.OPEN) {
+ this.sendRaw({ type: "control", payload: { action: "disconnect", reason } });
+ this.socket.close();
+ }
+ this.socket = null;
+ this.state = "disconnected";
+ }
+
+ public on(listener: (event: TransportEvent) => void): () => void {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+
+ public getState(): SessionState {
+ return this.state;
+ }
+
+ private sendRaw(frame: GatewayFrame): void {
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
+ throw new Error("网关连接未建立");
+ }
+ this.socket.send(JSON.stringify(frame));
+ }
+
+ private handleFrame(frame: GatewayFrame): void {
+ if (frame.type === "stdout") {
+ this.state = "connected";
+ this.emit({ type: "stdout", data: frame.payload.data });
+ return;
+ }
+
+ if (frame.type === "stderr") {
+ this.emit({ type: "stderr", data: frame.payload.data });
+ return;
+ }
+
+ if (frame.type === "error") {
+ this.state = "error";
+ this.emit({ type: "error", code: frame.payload.code, message: frame.payload.message });
+ return;
+ }
+
+ if (frame.type === "control") {
+ if (frame.payload.action === "ping") {
+ this.sendRaw({ type: "control", payload: { action: "pong" } });
+ return;
+ }
+
+ if (frame.payload.action === "pong") {
+ if (this.pingAt > 0) {
+ this.emit({ type: "latency", data: Date.now() - this.pingAt });
+ }
+ return;
+ }
+
+ if (frame.payload.action === "connected") {
+ this.state = "connected";
+ this.emit({ type: "connected", fingerprint: frame.payload.fingerprint });
+ return;
+ }
+
+ if (frame.payload.action === "disconnect") {
+ this.state = "disconnected";
+ this.stopHeartbeat();
+ this.emit({ type: "disconnect", reason: frame.payload.reason ?? "unknown" });
+ }
+ }
+ }
+
+ private emit(event: TransportEvent): void {
+ for (const listener of this.listeners) {
+ listener(event);
+ }
+ }
+
+ private startHeartbeat(): void {
+ this.stopHeartbeat();
+ this.heartbeatTimer = window.setInterval(() => {
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
+ return;
+ }
+ this.pingAt = Date.now();
+ this.sendRaw({ type: "control", payload: { action: "ping" } });
+ }, 10000);
+ }
+
+ private stopHeartbeat(): void {
+ if (this.heartbeatTimer) {
+ window.clearInterval(this.heartbeatTimer);
+ this.heartbeatTimer = null;
+ }
+ }
+
+ /**
+ * 统一网关地址构造(含容错候选):
+ * 1) 自动将 http/https 转换为 ws/wss;
+ * 2) 页面非本机访问时,避免把 localhost 误连到客户端本机;
+ * 3) https 页面下,补充 wss 与去端口候选,适配反向代理场景;
+ * 4) 统一补全 /ws/terminal?token=...
+ */
+ private buildEndpoints(): string[] {
+ const pageIsHttps = window.location.protocol === "https:";
+ const pageHost = window.location.hostname;
+ const pageProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ const rawInput = this.gatewayUrl.trim();
+ const fallback = `${pageProtocol}//${pageHost}`;
+ const input = rawInput.length > 0 ? rawInput : fallback;
+ const candidates: string[] = [];
+ const pushCandidate = (next: URL): void => {
+ if (pageIsHttps && next.protocol === "ws:") {
+ return;
+ }
+ candidates.push(finalizeEndpoint(next));
+ };
+
+ let url: URL;
+ try {
+ const maybeUrl = /^[a-zA-Z][a-zA-Z0-9+.-]*:\/\//.test(input) ? input : `${pageProtocol}//${input}`;
+ url = new URL(maybeUrl);
+ } catch {
+ url = new URL(fallback);
+ }
+
+ if (url.protocol === "http:") url.protocol = "ws:";
+ if (url.protocol === "https:") url.protocol = "wss:";
+
+ const localHosts = new Set(["localhost", "127.0.0.1", "::1"]);
+ const pageIsLocal = localHosts.has(pageHost);
+ const targetIsLocal = localHosts.has(url.hostname);
+ if (!pageIsLocal && targetIsLocal) {
+ url.hostname = pageHost;
+ }
+
+ const finalizeEndpoint = (source: URL): string => {
+ const next = new URL(source.toString());
+ const pathname = next.pathname.replace(/\/+$/, "");
+ next.pathname = pathname.endsWith("/ws/terminal") ? pathname : `${pathname}/ws/terminal`.replace(/\/{2,}/g, "/");
+ next.search = `token=${encodeURIComponent(this.token)}`;
+ return next.toString();
+ };
+
+ // 1) 优先使用用户配置原始地址。
+ pushCandidate(url);
+
+ // 2) 补充同主机不同协议候选(ws <-> wss)。
+ // HTTPS 页面禁止 ws://(混合内容会被浏览器直接拦截)。
+ if (!pageIsHttps && url.protocol === "ws:") {
+ const tlsUrl = new URL(url.toString());
+ tlsUrl.protocol = "wss:";
+ pushCandidate(tlsUrl);
+ } else if (url.protocol === "wss:") {
+ const plainUrl = new URL(url.toString());
+ if (!pageIsHttps) {
+ plainUrl.protocol = "ws:";
+ pushCandidate(plainUrl);
+ }
+ }
+
+ // 3) 远端主机时,始终补充“去端口走反向代理(80/443)”候选。
+ // 适配公网仅开放 443、Nginx 反代到内网端口的部署。
+ if (!targetIsLocal) {
+ const noPort = new URL(url.toString());
+ noPort.port = "";
+ pushCandidate(noPort);
+
+ if (!pageIsHttps && noPort.protocol === "ws:") {
+ const noPortTls = new URL(noPort.toString());
+ noPortTls.protocol = "wss:";
+ pushCandidate(noPortTls);
+ } else if (noPort.protocol === "wss:") {
+ if (!pageIsHttps) {
+ const noPortPlain = new URL(noPort.toString());
+ noPortPlain.protocol = "ws:";
+ pushCandidate(noPortPlain);
+ }
+ }
+ }
+
+ return [...new Set(candidates)];
+ }
+}
diff --git a/pxterm/src/services/transport/iosNativeTransport.ts b/pxterm/src/services/transport/iosNativeTransport.ts
new file mode 100644
index 0000000..85e55c9
--- /dev/null
+++ b/pxterm/src/services/transport/iosNativeTransport.ts
@@ -0,0 +1,170 @@
+import type { SessionState, StdinMeta } from "@remoteconn/shared";
+import type { ConnectParams, TerminalTransport, TransportEvent } from "./terminalTransport";
+
+declare global {
+ interface Window {
+ Capacitor?: {
+ Plugins?: {
+ RemoteConnSSH?: {
+ connect(options: ConnectParams): Promise;
+ send(options: { data: string }): Promise;
+ resize(options: { cols: number; rows: number }): Promise;
+ disconnect(options: { reason?: string }): Promise;
+ addListener(
+ eventName: "stdout" | "stderr" | "disconnect" | "latency" | "error" | "connected",
+ listener: (payload: unknown) => void
+ ): Promise<{ remove: () => void }>;
+ };
+ };
+ };
+ }
+}
+
+type NativeCredentialPayload =
+ | { type: "password"; password: string }
+ | { type: "privateKey"; privateKey: string; passphrase?: string }
+ | { type: "certificate"; privateKey: string; passphrase?: string; certificate: string };
+
+interface NativeConnectPayload {
+ host: string;
+ port: number;
+ username: string;
+ knownHostFingerprint?: string;
+ cols: number;
+ rows: number;
+ credential: NativeCredentialPayload;
+}
+
+/**
+ * 将 ConnectParams 规整为仅包含 JSON 原始类型的对象。
+ * 目的:Capacitor Bridge 在 iOS 侧会对参数做克隆/序列化,`undefined` 或代理对象可能触发 DataCloneError。
+ */
+function buildNativeConnectPayload(params: ConnectParams): NativeConnectPayload {
+ const base = {
+ host: String(params.host ?? ""),
+ port: Number(params.port ?? 22),
+ username: String(params.username ?? ""),
+ cols: Number(params.cols ?? 80),
+ rows: Number(params.rows ?? 24)
+ };
+
+ const knownHostFingerprint =
+ typeof params.knownHostFingerprint === "string" && params.knownHostFingerprint.trim().length > 0
+ ? params.knownHostFingerprint.trim()
+ : undefined;
+
+ if (params.credential.type === "password") {
+ return {
+ ...base,
+ ...(knownHostFingerprint ? { knownHostFingerprint } : {}),
+ credential: {
+ type: "password",
+ password: String(params.credential.password ?? "")
+ }
+ };
+ }
+
+ if (params.credential.type === "privateKey") {
+ return {
+ ...base,
+ ...(knownHostFingerprint ? { knownHostFingerprint } : {}),
+ credential: {
+ type: "privateKey",
+ privateKey: String(params.credential.privateKey ?? ""),
+ ...(params.credential.passphrase ? { passphrase: String(params.credential.passphrase) } : {})
+ }
+ };
+ }
+
+ return {
+ ...base,
+ ...(knownHostFingerprint ? { knownHostFingerprint } : {}),
+ credential: {
+ type: "certificate",
+ privateKey: String(params.credential.privateKey ?? ""),
+ certificate: String(params.credential.certificate ?? ""),
+ ...(params.credential.passphrase ? { passphrase: String(params.credential.passphrase) } : {})
+ }
+ };
+}
+
+/**
+ * iOS 原生 SSH 传输适配。
+ */
+export class IosNativeTransport implements TerminalTransport {
+ private state: SessionState = "idle";
+ private listeners = new Set<(event: TransportEvent) => void>();
+ private disposers: Array<() => void> = [];
+
+ public async connect(params: ConnectParams): Promise {
+ const plugin = window.Capacitor?.Plugins?.RemoteConnSSH;
+ if (!plugin) {
+ throw new Error("iOS 原生插件不可用");
+ }
+
+ this.state = "connecting";
+
+ const onStdout = await plugin.addListener("stdout", (payload) => {
+ this.state = "connected";
+ this.emit({ type: "stdout", data: (payload as { data: string }).data });
+ });
+ this.disposers.push(() => onStdout.remove());
+
+ const onStderr = await plugin.addListener("stderr", (payload) => {
+ this.emit({ type: "stderr", data: (payload as { data: string }).data });
+ });
+ this.disposers.push(() => onStderr.remove());
+
+ const onDisconnect = await plugin.addListener("disconnect", (payload) => {
+ this.state = "disconnected";
+ this.emit({ type: "disconnect", reason: (payload as { reason: string }).reason });
+ });
+ this.disposers.push(() => onDisconnect.remove());
+
+ const onLatency = await plugin.addListener("latency", (payload) => {
+ this.emit({ type: "latency", data: (payload as { latency: number }).latency });
+ });
+ this.disposers.push(() => onLatency.remove());
+
+ const onError = await plugin.addListener("error", (payload) => {
+ this.state = "error";
+ const error = payload as { code: string; message: string };
+ this.emit({ type: "error", code: error.code, message: error.message });
+ });
+ this.disposers.push(() => onError.remove());
+
+ await plugin.connect(buildNativeConnectPayload(params));
+ }
+
+ public async send(data: string, _meta?: StdinMeta): Promise {
+ await window.Capacitor?.Plugins?.RemoteConnSSH?.send({ data });
+ }
+
+ public async resize(cols: number, rows: number): Promise {
+ await window.Capacitor?.Plugins?.RemoteConnSSH?.resize({ cols, rows });
+ }
+
+ public async disconnect(reason?: string): Promise {
+ await window.Capacitor?.Plugins?.RemoteConnSSH?.disconnect({ reason });
+ for (const dispose of this.disposers) {
+ dispose();
+ }
+ this.disposers = [];
+ this.state = "disconnected";
+ }
+
+ public on(listener: (event: TransportEvent) => void): () => void {
+ this.listeners.add(listener);
+ return () => this.listeners.delete(listener);
+ }
+
+ public getState(): SessionState {
+ return this.state;
+ }
+
+ private emit(event: TransportEvent): void {
+ for (const listener of this.listeners) {
+ listener(event);
+ }
+ }
+}
diff --git a/pxterm/src/services/transport/terminalTransport.ts b/pxterm/src/services/transport/terminalTransport.ts
new file mode 100644
index 0000000..bd958cf
--- /dev/null
+++ b/pxterm/src/services/transport/terminalTransport.ts
@@ -0,0 +1,29 @@
+import type { ResolvedCredential, SessionState, StdinMeta } from "@remoteconn/shared";
+
+export type TransportEvent =
+ | { type: "stdout"; data: string }
+ | { type: "stderr"; data: string }
+ | { type: "latency"; data: number }
+ | { type: "disconnect"; reason: string }
+ | { type: "connected"; fingerprint?: string }
+ | { type: "error"; code: string; message: string };
+
+export interface ConnectParams {
+ host: string;
+ port: number;
+ username: string;
+ clientSessionKey?: string;
+ credential: ResolvedCredential;
+ knownHostFingerprint?: string;
+ cols: number;
+ rows: number;
+}
+
+export interface TerminalTransport {
+ connect(params: ConnectParams): Promise;
+ send(data: string, meta?: StdinMeta): Promise;
+ resize(cols: number, rows: number): Promise;
+ disconnect(reason?: string): Promise;
+ on(listener: (event: TransportEvent) => void): () => void;
+ getState(): SessionState;
+}
diff --git a/pxterm/src/stores/appStore.ts b/pxterm/src/stores/appStore.ts
new file mode 100644
index 0000000..790cbda
--- /dev/null
+++ b/pxterm/src/stores/appStore.ts
@@ -0,0 +1,27 @@
+import { defineStore } from "pinia";
+import { ref } from "vue";
+import type { AppToast } from "@/types/app";
+
+/**
+ * 全局消息中心。
+ */
+export const useAppStore = defineStore("app", () => {
+ const toasts = ref([]);
+
+ function notify(level: AppToast["level"], message: string): void {
+ const item: AppToast = {
+ id: crypto.randomUUID(),
+ level,
+ message
+ };
+ toasts.value.push(item);
+ window.setTimeout(() => {
+ toasts.value = toasts.value.filter((x) => x.id !== item.id);
+ }, level === "error" ? 5000 : 3000);
+ }
+
+ return {
+ toasts,
+ notify
+ };
+});
diff --git a/pxterm/src/stores/logStore.ts b/pxterm/src/stores/logStore.ts
new file mode 100644
index 0000000..30635a6
--- /dev/null
+++ b/pxterm/src/stores/logStore.ts
@@ -0,0 +1,117 @@
+import { defineStore } from "pinia";
+import { computed, toRaw, ref } from "vue";
+import { maskHost, maskSensitive, type CommandMarker, type SessionLog } from "@remoteconn/shared";
+import { db } from "@/services/storage/db";
+import { nowIso } from "@/utils/time";
+
+/**
+ * 会话日志存储与导出。
+ */
+export const useLogStore = defineStore("log", () => {
+ const logs = ref([]);
+ const loaded = ref(false);
+ let bootstrapPromise: Promise | null = null;
+
+ const latest = computed(() => [...logs.value].sort((a, b) => +new Date(b.startAt) - +new Date(a.startAt)).slice(0, 50));
+
+ async function ensureBootstrapped(): Promise {
+ if (loaded.value) return;
+ if (bootstrapPromise) {
+ await bootstrapPromise;
+ return;
+ }
+ bootstrapPromise = (async () => {
+ logs.value = await db.sessionLogs.toArray();
+ loaded.value = true;
+ })();
+
+ try {
+ await bootstrapPromise;
+ } finally {
+ bootstrapPromise = null;
+ }
+ }
+
+ async function bootstrap(): Promise {
+ await ensureBootstrapped();
+ }
+
+ async function startLog(serverId: string): Promise {
+ const log: SessionLog = {
+ sessionId: `sess-${crypto.randomUUID()}`,
+ serverId,
+ startAt: nowIso(),
+ status: "connecting",
+ commandMarkers: []
+ };
+ logs.value.unshift(log);
+ await db.sessionLogs.put(log);
+ return log.sessionId;
+ }
+
+ /**
+ * Dexie/IndexedDB 使用结构化克隆写入数据,Vue 响应式代理对象会触发 DataCloneError。
+ * 这里统一做实体快照,确保入库对象仅包含可序列化的普通 JSON 数据。
+ */
+ function toSessionLogEntity(log: SessionLog): SessionLog {
+ const raw = toRaw(log);
+ return {
+ ...raw,
+ commandMarkers: raw.commandMarkers.map((marker) => ({ ...marker }))
+ };
+ }
+
+ async function markStatus(sessionId: string, status: SessionLog["status"], error?: string): Promise {
+ const target = logs.value.find((item) => item.sessionId === sessionId);
+ if (!target) return;
+ target.status = status;
+ if (status === "disconnected" || status === "error") {
+ target.endAt = nowIso();
+ }
+ if (error) {
+ target.error = error;
+ }
+ await db.sessionLogs.put(toSessionLogEntity(target));
+ }
+
+ async function addMarker(sessionId: string, marker: Omit): Promise {
+ const target = logs.value.find((item) => item.sessionId === sessionId);
+ if (!target) return;
+ target.commandMarkers.push({ ...marker, at: nowIso() });
+ await db.sessionLogs.put(toSessionLogEntity(target));
+ }
+
+ function exportLogs(mask = true): string {
+ const rows = logs.value.map((log) => {
+ const commands = log.commandMarkers
+ .map((marker) => {
+ const cmd = mask ? maskSensitive(marker.command) : marker.command;
+ return ` - [${marker.at}] ${cmd} => code:${marker.code}`;
+ })
+ .join("\n");
+ return [
+ `## ${log.sessionId} [${log.status}]`,
+ `- server: ${log.serverId}`,
+ `- start: ${log.startAt}`,
+ `- end: ${log.endAt ?? "--"}`,
+ `- error: ${mask ? maskSensitive(log.error ?? "") : log.error ?? ""}`,
+ `- host: ${mask ? maskHost(log.serverId) : log.serverId}`,
+ "- commands:",
+ commands || " - 无"
+ ].join("\n");
+ });
+
+ return [`# RemoteConn Session Export ${nowIso()}`, "", ...rows].join("\n\n");
+ }
+
+ return {
+ logs,
+ latest,
+ ensureBootstrapped,
+ bootstrap,
+ startLog,
+ markStatus,
+ addMarker,
+ exportLogs
+ };
+});
diff --git a/pxterm/src/stores/pluginStore.ts b/pxterm/src/stores/pluginStore.ts
new file mode 100644
index 0000000..a2fed29
--- /dev/null
+++ b/pxterm/src/stores/pluginStore.ts
@@ -0,0 +1,189 @@
+import { defineStore } from "pinia";
+import { computed, ref } from "vue";
+import { PluginManager, type PluginPackage } from "@remoteconn/plugin-runtime";
+import { WebPluginFsAdapter } from "@/services/storage/pluginFsAdapter";
+import { onSessionEvent } from "@/services/sessionEventBus";
+import { useSessionStore } from "./sessionStore";
+import { useAppStore } from "./appStore";
+
+/**
+ * 插件运行时管理。
+ */
+export const usePluginStore = defineStore("plugin", () => {
+ const runtimeLogs = ref([]);
+ const initialized = ref(false);
+ let bootstrapPromise: Promise | null = null;
+
+ const fsAdapter = new WebPluginFsAdapter();
+ const eventUnsubscribers: Array<() => void> = [];
+
+ const manager = new PluginManager(fsAdapter, {
+ getAppMeta() {
+ return { version: "1.0.0", platform: "web" as const };
+ },
+ session: {
+ async send(input) {
+ const sessionStore = useSessionStore();
+ await sessionStore.sendCommand(input, "plugin", "manual");
+ },
+ on(eventName, handler) {
+ return onSessionEvent(eventName, handler);
+ }
+ },
+ showNotice(message, level) {
+ const appStore = useAppStore();
+ appStore.notify(level, message);
+ }
+ }, {
+ appVersion: "1.0.0",
+ mountStyle(pluginId, css) {
+ const style = document.createElement("style");
+ style.dataset.pluginId = pluginId;
+ style.textContent = css;
+ document.head.append(style);
+ return () => style.remove();
+ },
+ logger(level, pluginId, message) {
+ runtimeLogs.value.unshift(`[${new Date().toLocaleTimeString("zh-CN", { hour12: false })}] [${level}] [${pluginId}] ${message}`);
+ if (runtimeLogs.value.length > 300) {
+ runtimeLogs.value.splice(300);
+ }
+ }
+ });
+
+ const records = computed(() => manager.listRecords());
+
+ const commands = computed(() => {
+ const session = useSessionStore();
+ return manager.listCommands(session.connected ? "connected" : "disconnected");
+ });
+
+ async function ensureBootstrapped(): Promise {
+ if (initialized.value) return;
+ if (bootstrapPromise) {
+ await bootstrapPromise;
+ return;
+ }
+
+ bootstrapPromise = (async () => {
+
+ await manager.bootstrap();
+ await ensureSamplePlugin();
+
+ eventUnsubscribers.push(
+ onSessionEvent("connected", () => {
+ // 保持 computed 触发
+ runtimeLogs.value = [...runtimeLogs.value];
+ })
+ );
+
+ initialized.value = true;
+ })();
+
+ try {
+ await bootstrapPromise;
+ } finally {
+ bootstrapPromise = null;
+ }
+ }
+
+ async function bootstrap(): Promise {
+ await ensureBootstrapped();
+ }
+
+ async function ensureSamplePlugin(): Promise {
+ const packages = await fsAdapter.listPackages();
+ if (packages.length > 0) return;
+
+ await importPackages([
+ {
+ manifest: {
+ id: "codex-shortcuts",
+ name: "Codex Shortcuts",
+ version: "0.1.0",
+ minAppVersion: "0.1.0",
+ description: "提供常用 Codex 快捷命令",
+ entry: "main.js",
+ style: "styles.css",
+ permissions: ["commands.register", "session.write", "ui.notice"]
+ },
+ mainJs: `
+module.exports = {
+ onload(ctx) {
+ ctx.commands.register({
+ id: "codex-doctor",
+ title: "Codex Doctor",
+ when: "connected",
+ async handler() {
+ await ctx.session.send("codex --doctor");
+ }
+ });
+ ctx.ui.showNotice("插件 codex-shortcuts 已加载", "info");
+ }
+};
+ `.trim(),
+ stylesCss: `.plugin-chip[data-plugin-id="codex-shortcuts"] { border-color: rgba(95,228,255,0.7); }`
+ }
+ ]);
+ }
+
+ async function importPackages(payload: PluginPackage[]): Promise {
+ for (const pkg of payload) {
+ await manager.installPackage(pkg);
+ }
+ }
+
+ async function importJson(raw: string): Promise {
+ const parsed = JSON.parse(raw) as PluginPackage | PluginPackage[];
+ const items = Array.isArray(parsed) ? parsed : [parsed];
+ await importPackages(items);
+ }
+
+ async function exportJson(): Promise {
+ const packages = await fsAdapter.listPackages();
+ return JSON.stringify(packages, null, 2);
+ }
+
+ async function enable(pluginId: string): Promise {
+ await manager.enable(pluginId);
+ }
+
+ async function disable(pluginId: string): Promise {
+ await manager.disable(pluginId);
+ }
+
+ async function reload(pluginId: string): Promise {
+ await manager.reload(pluginId);
+ }
+
+ async function remove(pluginId: string): Promise {
+ await manager.remove(pluginId);
+ }
+
+ async function runCommand(commandId: string): Promise {
+ await manager.runCommand(commandId);
+ }
+
+ function dispose(): void {
+ for (const off of eventUnsubscribers) {
+ off();
+ }
+ eventUnsubscribers.length = 0;
+ }
+
+ return {
+ runtimeLogs,
+ records,
+ commands,
+ ensureBootstrapped,
+ bootstrap,
+ importJson,
+ exportJson,
+ enable,
+ disable,
+ reload,
+ remove,
+ runCommand,
+ dispose
+ };
+});
diff --git a/pxterm/src/stores/serverStore.test.ts b/pxterm/src/stores/serverStore.test.ts
new file mode 100644
index 0000000..f78eab0
--- /dev/null
+++ b/pxterm/src/stores/serverStore.test.ts
@@ -0,0 +1,156 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createPinia, setActivePinia } from "pinia";
+import type { ServerProfile } from "@/types/app";
+
+const { dbState, dbMock } = vi.hoisted(() => {
+ const state = {
+ servers: [] as ServerProfile[]
+ };
+
+ const cloneServer = (server: ServerProfile): ServerProfile => ({
+ ...server,
+ projectPresets: [...server.projectPresets],
+ tags: [...server.tags]
+ });
+
+ const upsertServer = (server: ServerProfile): void => {
+ const index = state.servers.findIndex((item) => item.id === server.id);
+ if (index >= 0) {
+ state.servers[index] = cloneServer(server);
+ } else {
+ state.servers.push(cloneServer(server));
+ }
+ };
+
+ const db = {
+ servers: {
+ toArray: vi.fn(async () => state.servers.map((item) => cloneServer(item))),
+ add: vi.fn(async (server: ServerProfile) => {
+ state.servers.push(cloneServer(server));
+ }),
+ put: vi.fn(async (server: ServerProfile) => {
+ upsertServer(server);
+ }),
+ bulkPut: vi.fn(async (servers: ServerProfile[]) => {
+ servers.forEach((server) => upsertServer(server));
+ }),
+ delete: vi.fn(async (serverId: string) => {
+ state.servers = state.servers.filter((item) => item.id !== serverId);
+ })
+ },
+ credentialRefs: {
+ toArray: vi.fn(async () => [])
+ },
+ credentials: {
+ where: vi.fn(() => ({
+ equals: vi.fn(() => ({
+ first: vi.fn(async () => null),
+ delete: vi.fn(async () => {})
+ }))
+ })),
+ put: vi.fn(async () => {})
+ }
+ };
+
+ return {
+ dbState: state,
+ dbMock: db
+ };
+});
+
+vi.mock("@/services/storage/db", () => ({
+ db: dbMock
+}));
+
+vi.mock("@/services/security/credentialVault", () => ({
+ decryptCredential: vi.fn(async () => ({})),
+ encryptCredential: vi.fn(async () => ({
+ id: "enc-1",
+ refId: "enc-1",
+ encrypted: "",
+ iv: "",
+ createdAt: "",
+ updatedAt: ""
+ }))
+}));
+
+import { useServerStore } from "./serverStore";
+
+function makeServer(id: string, sortOrder?: number): ServerProfile {
+ return {
+ id,
+ name: id,
+ host: "127.0.0.1",
+ port: 22,
+ username: "root",
+ authType: "password",
+ projectPath: "~",
+ projectPresets: [],
+ tags: [],
+ timeoutSeconds: 20,
+ heartbeatSeconds: 15,
+ transportMode: "gateway",
+ ...(sortOrder !== undefined ? { sortOrder } : {})
+ };
+}
+
+describe("serverStore", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ dbState.servers = [];
+ dbMock.servers.toArray.mockClear();
+ dbMock.servers.add.mockClear();
+ dbMock.servers.put.mockClear();
+ dbMock.servers.bulkPut.mockClear();
+ dbMock.servers.delete.mockClear();
+ dbMock.credentialRefs.toArray.mockClear();
+ });
+
+ it("启动时按 sortOrder 恢复顺序并回填连续排序值", async () => {
+ dbState.servers = [makeServer("srv-b", 2), makeServer("srv-a"), makeServer("srv-c", 0)];
+
+ const store = useServerStore();
+ await store.ensureBootstrapped();
+
+ expect(store.servers.map((item) => item.id)).toEqual(["srv-c", "srv-b", "srv-a"]);
+ expect(dbMock.servers.bulkPut).toHaveBeenCalledTimes(1);
+ expect(store.servers.map((item) => item.sortOrder)).toEqual([0, 1, 2]);
+ });
+
+ it("支持服务器上下移动并持久化顺序", async () => {
+ dbState.servers = [makeServer("srv-1", 0), makeServer("srv-2", 1), makeServer("srv-3", 2)];
+
+ const store = useServerStore();
+ await store.ensureBootstrapped();
+ expect(dbMock.servers.bulkPut).toHaveBeenCalledTimes(0);
+
+ const movedDown = await store.moveServerDown("srv-1");
+ expect(movedDown).toBe(true);
+ expect(store.servers.map((item) => item.id)).toEqual(["srv-2", "srv-1", "srv-3"]);
+ expect(store.servers.map((item) => item.sortOrder)).toEqual([0, 1, 2]);
+
+ const movedUp = await store.moveServerUp("srv-1");
+ expect(movedUp).toBe(true);
+ expect(store.servers.map((item) => item.id)).toEqual(["srv-1", "srv-2", "srv-3"]);
+
+ const topBoundary = await store.moveServerUp("srv-1");
+ const bottomBoundary = await store.moveServerDown("srv-3");
+ expect(topBoundary).toBe(false);
+ expect(bottomBoundary).toBe(false);
+ });
+
+ it("支持按指定 id 顺序重排", async () => {
+ dbState.servers = [makeServer("srv-1", 0), makeServer("srv-2", 1), makeServer("srv-3", 2)];
+
+ const store = useServerStore();
+ await store.ensureBootstrapped();
+
+ const changed = await store.applyServerOrder(["srv-3", "srv-1", "srv-2"]);
+ expect(changed).toBe(true);
+ expect(store.servers.map((item) => item.id)).toEqual(["srv-3", "srv-1", "srv-2"]);
+ expect(store.servers.map((item) => item.sortOrder)).toEqual([0, 1, 2]);
+
+ const unchanged = await store.applyServerOrder(["srv-3", "srv-1", "srv-2"]);
+ expect(unchanged).toBe(false);
+ });
+});
diff --git a/pxterm/src/stores/serverStore.ts b/pxterm/src/stores/serverStore.ts
new file mode 100644
index 0000000..78a046b
--- /dev/null
+++ b/pxterm/src/stores/serverStore.ts
@@ -0,0 +1,382 @@
+import { defineStore } from "pinia";
+import { computed, toRaw, ref } from "vue";
+import type { CredentialRef, ResolvedCredential, ServerProfile } from "@/types/app";
+import { db } from "@/services/storage/db";
+import { decryptCredential, encryptCredential } from "@/services/security/credentialVault";
+import { nowIso } from "@/utils/time";
+
+interface ServerCredentialInput {
+ type: CredentialRef["type"];
+ password?: string;
+ privateKey?: string;
+ passphrase?: string;
+ certificate?: string;
+}
+
+/**
+ * 服务器与凭据管理。
+ */
+export const useServerStore = defineStore("server", () => {
+ const servers = ref([]);
+ const credentialRefs = ref([]);
+ const selectedServerId = ref("");
+ const loaded = ref(false);
+ let bootstrapPromise: Promise | null = null;
+
+ const selectedServer = computed(() => servers.value.find((item) => item.id === selectedServerId.value));
+
+ /**
+ * 规范化排序值:
+ * - 非数字、NaN、负值都视为“缺失排序”;
+ * - 仅保留非负整数,避免浮点或异常值污染排序稳定性。
+ */
+ function normalizeSortOrder(value: unknown): number | null {
+ if (typeof value !== "number" || !Number.isFinite(value)) {
+ return null;
+ }
+ const normalized = Math.floor(value);
+ if (normalized < 0) {
+ return null;
+ }
+ return normalized;
+ }
+
+ /**
+ * 按持久化排序字段恢复列表顺序:
+ * - 优先按 sortOrder 升序;
+ * - 缺失 sortOrder 的历史数据保留原始读取顺序;
+ * - 排序值冲突时回退到原始顺序,保证稳定排序。
+ */
+ function sortServersByStoredOrder(input: ServerProfile[]): ServerProfile[] {
+ return input
+ .map((server, index) => ({
+ server,
+ index,
+ sortOrder: normalizeSortOrder(server.sortOrder)
+ }))
+ .sort((a, b) => {
+ if (a.sortOrder === null && b.sortOrder === null) {
+ return a.index - b.index;
+ }
+ if (a.sortOrder === null) {
+ return 1;
+ }
+ if (b.sortOrder === null) {
+ return -1;
+ }
+ if (a.sortOrder !== b.sortOrder) {
+ return a.sortOrder - b.sortOrder;
+ }
+ return a.index - b.index;
+ })
+ .map((entry) => entry.server);
+ }
+
+ /**
+ * 将当前数组顺序重写为连续 sortOrder,并回写到数据库。
+ * 约束:
+ * - 不改变入参数组的相对顺序;
+ * - 所有项强制回填 sortOrder,保证刷新后顺序稳定可恢复。
+ */
+ async function persistServerOrder(nextServers: ServerProfile[]): Promise {
+ const ordered = nextServers.map((server, index) => {
+ const entity = toServerEntity(server);
+ return {
+ ...entity,
+ sortOrder: index
+ };
+ });
+ servers.value = ordered;
+ await db.servers.bulkPut(ordered.map((item) => toServerEntity(item)));
+ }
+
+ async function ensureBootstrapped(): Promise {
+ if (loaded.value) return;
+ if (bootstrapPromise) {
+ await bootstrapPromise;
+ return;
+ }
+
+ bootstrapPromise = (async () => {
+ const storedServers = await db.servers.toArray();
+ credentialRefs.value = await db.credentialRefs.toArray();
+
+ if (storedServers.length === 0) {
+ const sample = buildDefaultServer();
+ await persistServerOrder([sample]);
+ } else {
+ const sortedServers = sortServersByStoredOrder(storedServers);
+ const needsPersist = sortedServers.some((server, index) => {
+ const current = storedServers[index];
+ return server.id !== current?.id || normalizeSortOrder(server.sortOrder) !== index;
+ });
+ if (needsPersist) {
+ await persistServerOrder(sortedServers);
+ } else {
+ servers.value = sortedServers.map((server) => toServerEntity(server));
+ }
+ }
+
+ selectedServerId.value = servers.value[0]?.id ?? "";
+ loaded.value = true;
+ })();
+
+ try {
+ await bootstrapPromise;
+ } finally {
+ bootstrapPromise = null;
+ }
+ }
+
+ async function bootstrap(): Promise {
+ await ensureBootstrapped();
+ }
+
+ function buildDefaultServer(): ServerProfile {
+ return {
+ id: `srv-${crypto.randomUUID()}`,
+ name: "新服务器",
+ host: "",
+ port: 22,
+ username: "root",
+ authType: "password",
+ projectPath: "~/workspace",
+ projectPresets: ["~/workspace"],
+ tags: [],
+ timeoutSeconds: 20,
+ heartbeatSeconds: 15,
+ transportMode: "gateway",
+ sortOrder: 0,
+ lastConnectedAt: ""
+ };
+ }
+
+ /**
+ * 仅创建“新服务器草稿”快照,不写入列表与数据库。
+ * 用于“新增服务器先进入配置页,保存后再落库”的流程。
+ */
+ function createServerDraft(): ServerProfile {
+ return buildDefaultServer();
+ }
+
+ /**
+ * 将服务器对象转换为可安全写入 IndexedDB 的纯数据实体。
+ * 目的:避免 Vue Proxy 透传到 Dexie 触发 DataCloneError。
+ */
+ function toServerEntity(server: ServerProfile): ServerProfile {
+ const raw = toRaw(server);
+ return {
+ ...raw,
+ projectPresets: [...raw.projectPresets],
+ tags: [...raw.tags]
+ };
+ }
+
+ async function createServer(): Promise {
+ const sample = createServerDraft();
+ await persistServerOrder([sample, ...servers.value]);
+ selectedServerId.value = sample.id;
+ }
+
+ async function saveServer(server: ServerProfile): Promise {
+ const nextServers = [...servers.value];
+ const index = servers.value.findIndex((item) => item.id === server.id);
+ if (index >= 0) {
+ nextServers[index] = server;
+ } else {
+ nextServers.unshift(server);
+ }
+ await persistServerOrder(nextServers);
+ }
+
+ async function deleteServer(serverId: string): Promise {
+ const nextServers = servers.value.filter((item) => item.id !== serverId);
+ await db.servers.delete(serverId);
+ await persistServerOrder(nextServers);
+ if (selectedServerId.value === serverId) {
+ selectedServerId.value = servers.value[0]?.id ?? "";
+ }
+ }
+
+ /**
+ * 将指定服务器上移一位。
+ * 返回:
+ * - true: 已成功移动并持久化;
+ * - false: 不存在或已在顶部,无需移动。
+ */
+ async function moveServerUp(serverId: string): Promise {
+ const index = servers.value.findIndex((item) => item.id === serverId);
+ if (index <= 0) {
+ return false;
+ }
+ const nextServers = [...servers.value];
+ const previous = nextServers[index - 1];
+ const current = nextServers[index];
+ if (!previous || !current) {
+ return false;
+ }
+ nextServers[index - 1] = current;
+ nextServers[index] = previous;
+ await persistServerOrder(nextServers);
+ return true;
+ }
+
+ /**
+ * 将指定服务器下移一位。
+ * 返回:
+ * - true: 已成功移动并持久化;
+ * - false: 不存在或已在底部,无需移动。
+ */
+ async function moveServerDown(serverId: string): Promise {
+ const index = servers.value.findIndex((item) => item.id === serverId);
+ if (index < 0 || index >= servers.value.length - 1) {
+ return false;
+ }
+ const nextServers = [...servers.value];
+ const current = nextServers[index];
+ const next = nextServers[index + 1];
+ if (!current || !next) {
+ return false;
+ }
+ nextServers[index] = next;
+ nextServers[index + 1] = current;
+ await persistServerOrder(nextServers);
+ return true;
+ }
+
+ /**
+ * 按传入 ID 顺序重排服务器列表并持久化。
+ * 规则:
+ * - `orderedIds` 中不存在/重复的项会被忽略;
+ * - 未出现在 `orderedIds` 的服务器按原顺序追加到末尾;
+ * - 若顺序无变化,返回 false。
+ */
+ async function applyServerOrder(orderedIds: string[]): Promise {
+ const byId = new Map(servers.value.map((server) => [server.id, server] as const));
+ const seen = new Set();
+ const head: ServerProfile[] = [];
+
+ for (const id of orderedIds) {
+ if (!id || seen.has(id)) {
+ continue;
+ }
+ const matched = byId.get(id);
+ if (!matched) {
+ continue;
+ }
+ seen.add(id);
+ head.push(matched);
+ }
+
+ const tail = servers.value.filter((server) => !seen.has(server.id));
+ const nextServers = [...head, ...tail];
+
+ if (
+ nextServers.length === servers.value.length &&
+ nextServers.every((server, index) => server.id === servers.value[index]?.id)
+ ) {
+ return false;
+ }
+
+ await persistServerOrder(nextServers);
+ return true;
+ }
+
+ async function saveCredential(refId: string, payload: ServerCredentialInput): Promise {
+ const exists = credentialRefs.value.find((item) => item.id === refId);
+ const now = nowIso();
+
+ const ref: CredentialRef = {
+ id: refId,
+ type: payload.type,
+ secureStoreKey: `web:credential:${refId}`,
+ createdAt: exists?.createdAt ?? now,
+ updatedAt: now
+ };
+
+ await db.credentialRefs.put(ref);
+ await db.credentials.where("refId").equals(refId).delete();
+ const encrypted = await encryptCredential(refId, payload);
+ await db.credentials.put(encrypted);
+
+ const idx = credentialRefs.value.findIndex((item) => item.id === refId);
+ if (idx >= 0) {
+ credentialRefs.value[idx] = ref;
+ } else {
+ credentialRefs.value.push(ref);
+ }
+
+ return ref;
+ }
+
+ async function resolveCredential(refId: string): Promise {
+ const ref = credentialRefs.value.find((item) => item.id === refId);
+ if (!ref) {
+ throw new Error("凭据引用不存在");
+ }
+
+ const payload = await db.credentials.where("refId").equals(refId).first();
+ if (!payload) {
+ throw new Error("未找到凭据内容");
+ }
+
+ const decrypted = await decryptCredential(payload);
+
+ if (ref.type === "password") {
+ return {
+ type: "password",
+ password: decrypted.password ?? ""
+ };
+ }
+
+ if (ref.type === "privateKey") {
+ return {
+ type: "privateKey",
+ privateKey: decrypted.privateKey ?? "",
+ passphrase: decrypted.passphrase
+ };
+ }
+
+ return {
+ type: "certificate",
+ privateKey: decrypted.privateKey ?? "",
+ passphrase: decrypted.passphrase,
+ certificate: decrypted.certificate ?? ""
+ };
+ }
+
+ async function getCredentialInput(refId: string): Promise {
+ const payload = await db.credentials.where("refId").equals(refId).first();
+ if (!payload) {
+ return null;
+ }
+ return await decryptCredential(payload);
+ }
+
+ async function markConnected(serverId: string): Promise {
+ const target = servers.value.find((item) => item.id === serverId);
+ if (!target) return;
+ target.lastConnectedAt = nowIso();
+ await db.servers.put(toServerEntity(target));
+ }
+
+ return {
+ servers,
+ credentialRefs,
+ selectedServerId,
+ selectedServer,
+ ensureBootstrapped,
+ bootstrap,
+ createServerDraft,
+ createServer,
+ saveServer,
+ deleteServer,
+ moveServerUp,
+ moveServerDown,
+ applyServerOrder,
+ saveCredential,
+ resolveCredential,
+ getCredentialInput,
+ markConnected
+ };
+});
diff --git a/pxterm/src/stores/sessionStore.test.ts b/pxterm/src/stores/sessionStore.test.ts
new file mode 100644
index 0000000..8956496
--- /dev/null
+++ b/pxterm/src/stores/sessionStore.test.ts
@@ -0,0 +1,494 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createPinia, setActivePinia } from "pinia";
+import type { ServerProfile } from "@/types/app";
+
+type MockTransportEvent = {
+ type: string;
+ [key: string]: unknown;
+};
+
+const {
+ settingsStoreMock,
+ serverStoreMock,
+ logStoreMock,
+ appStoreMock,
+ createTransportMock,
+ transportMock,
+ emitSessionEventMock,
+ listeners,
+ sessionStorageState
+} = vi.hoisted(() => {
+ const listenersRef: { value: ((event: MockTransportEvent) => Promise | void) | null } = { value: null };
+ const sessionStorageMap = new Map();
+
+ const transport = {
+ on: vi.fn((handler: (event: MockTransportEvent) => Promise | void) => {
+ listenersRef.value = handler;
+ return () => {
+ listenersRef.value = null;
+ };
+ }),
+ connect: vi.fn(async () => {}),
+ send: vi.fn(async () => {}),
+ disconnect: vi.fn(async () => {}),
+ resize: vi.fn(async () => {})
+ };
+
+ return {
+ settingsStoreMock: {
+ settings: {
+ autoReconnect: true,
+ reconnectLimit: 2,
+ terminalBufferMaxEntries: 5000,
+ terminalBufferMaxBytes: 4 * 1024 * 1024,
+ gatewayUrl: "ws://127.0.0.1:8787/ws/terminal",
+ gatewayToken: "dev-token"
+ },
+ gatewayUrl: "ws://127.0.0.1:8787/ws/terminal",
+ gatewayToken: "dev-token",
+ knownHosts: {},
+ verifyAndPersistHostFingerprint: vi.fn(async () => true)
+ },
+ serverStoreMock: {
+ servers: [] as ServerProfile[],
+ resolveCredential: vi.fn(async () => ({ type: "password", password: "secret" })),
+ markConnected: vi.fn(async () => {})
+ },
+ logStoreMock: {
+ startLog: vi.fn(async () => "session-log-1"),
+ markStatus: vi.fn(async () => {}),
+ addMarker: vi.fn(async () => {})
+ },
+ appStoreMock: {
+ notify: vi.fn()
+ },
+ createTransportMock: vi.fn(() => transport),
+ transportMock: transport,
+ emitSessionEventMock: vi.fn(),
+ listeners: listenersRef,
+ sessionStorageState: {
+ map: sessionStorageMap,
+ clear: () => sessionStorageMap.clear(),
+ getItem: (key: string) => sessionStorageMap.get(key) ?? null,
+ setItem: (key: string, value: string) => {
+ sessionStorageMap.set(key, value);
+ }
+ }
+ };
+});
+
+vi.mock("@remoteconn/shared", () => ({
+ allStates: () => ["idle", "connecting", "auth_pending", "connected", "reconnecting", "disconnected", "error"],
+ buildCodexPlan: (options: { projectPath: string; sandbox: "read-only" | "workspace-write" | "danger-full-access" }) => [
+ {
+ step: "cd",
+ command: `cd ${options.projectPath}`,
+ markerType: "cd"
+ },
+ {
+ step: "check",
+ command: "command -v codex",
+ markerType: "check"
+ },
+ {
+ step: "run",
+ command: `codex --sandbox ${options.sandbox}`,
+ markerType: "run"
+ }
+ ]
+}));
+
+vi.mock("./settingsStore", () => ({
+ useSettingsStore: () => settingsStoreMock
+}));
+
+vi.mock("./serverStore", () => ({
+ useServerStore: () => serverStoreMock
+}));
+
+vi.mock("./logStore", () => ({
+ useLogStore: () => logStoreMock
+}));
+
+vi.mock("./appStore", () => ({
+ useAppStore: () => appStoreMock
+}));
+
+vi.mock("@/services/transport/factory", () => ({
+ createTransport: createTransportMock
+}));
+
+vi.mock("@/services/sessionEventBus", () => ({
+ emitSessionEvent: emitSessionEventMock
+}));
+
+vi.mock("@/utils/feedback", () => ({
+ formatActionError: (_prefix: string, error: unknown) => String(error),
+ toFriendlyDisconnectReason: (reason: string) => reason,
+ toFriendlyError: (message: string) => message
+}));
+
+import { useSessionStore } from "./sessionStore";
+
+function setupWindowSessionStorage(): void {
+ const sessionStorage = {
+ getItem: (key: string) => sessionStorageState.getItem(key),
+ setItem: (key: string, value: string) => sessionStorageState.setItem(key, value)
+ };
+
+ const windowMock = {
+ sessionStorage,
+ setTimeout,
+ clearTimeout,
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn()
+ };
+
+ const documentMock = {
+ visibilityState: "visible",
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn()
+ };
+
+ Object.defineProperty(globalThis, "window", {
+ configurable: true,
+ value: windowMock
+ });
+
+ Object.defineProperty(globalThis, "document", {
+ configurable: true,
+ value: documentMock
+ });
+}
+
+describe("sessionStore", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ sessionStorageState.clear();
+ listeners.value = null;
+
+ transportMock.on.mockClear();
+ transportMock.connect.mockClear();
+ transportMock.send.mockClear();
+ transportMock.disconnect.mockClear();
+ transportMock.resize.mockClear();
+
+ createTransportMock.mockClear();
+ emitSessionEventMock.mockClear();
+
+ appStoreMock.notify.mockClear();
+ logStoreMock.startLog.mockClear();
+ logStoreMock.markStatus.mockClear();
+ serverStoreMock.resolveCredential.mockClear();
+ serverStoreMock.markConnected.mockClear();
+ settingsStoreMock.settings.autoReconnect = true;
+ settingsStoreMock.settings.reconnectLimit = 2;
+ settingsStoreMock.knownHosts = {};
+ serverStoreMock.servers = [];
+
+ setupWindowSessionStorage();
+ });
+
+ it("启动时恢复快照并自动重连", async () => {
+ const server: ServerProfile = {
+ id: "srv-1",
+ name: "mini",
+ host: "127.0.0.1",
+ port: 22,
+ username: "gavin",
+ authType: "password",
+ projectPath: "~",
+ projectPresets: [],
+ tags: [],
+ timeoutSeconds: 20,
+ heartbeatSeconds: 15,
+ transportMode: "gateway"
+ };
+
+ serverStoreMock.servers = [server];
+
+ sessionStorageState.setItem(
+ "remoteconn_session_snapshot_v1",
+ JSON.stringify({
+ version: 2,
+ savedAt: Date.now(),
+ activeConnectionKey: "srv-1::snapshot",
+ lines: ["restored-line"],
+ currentServerId: "srv-1",
+ reconnectServerId: "srv-1"
+ })
+ );
+
+ const store = useSessionStore();
+ await store.ensureBootstrapped();
+
+ expect(createTransportMock).toHaveBeenCalledTimes(1);
+ expect(transportMock.connect).toHaveBeenCalledTimes(1);
+ expect(store.currentServerId).toBe("srv-1");
+ expect(store.lines).toContain("restored-line");
+ });
+
+ it("刷新恢复连接不受 autoReconnect 开关影响", async () => {
+ const server: ServerProfile = {
+ id: "srv-reload",
+ name: "reload",
+ host: "127.0.0.1",
+ port: 22,
+ username: "gavin",
+ authType: "password",
+ projectPath: "~",
+ projectPresets: [],
+ tags: [],
+ timeoutSeconds: 20,
+ heartbeatSeconds: 15,
+ transportMode: "gateway"
+ };
+
+ serverStoreMock.servers = [server];
+ settingsStoreMock.settings.autoReconnect = false;
+
+ sessionStorageState.setItem(
+ "remoteconn_session_snapshot_v1",
+ JSON.stringify({
+ version: 2,
+ savedAt: Date.now(),
+ activeConnectionKey: "srv-reload::snapshot",
+ lines: ["reloaded"],
+ currentServerId: "srv-reload",
+ reconnectServerId: "srv-reload"
+ })
+ );
+
+ const store = useSessionStore();
+ await store.ensureBootstrapped();
+
+ expect(transportMock.connect).toHaveBeenCalledTimes(1);
+ expect(store.currentServerId).toBe("srv-reload");
+ });
+
+ it("ios-native 已完成兼容初始化后,中文输入不重复注入兼容命令", async () => {
+ const server: ServerProfile = {
+ id: "srv-2",
+ name: "ios",
+ host: "127.0.0.1",
+ port: 22,
+ username: "gavin",
+ authType: "password",
+ projectPath: "~",
+ projectPresets: [],
+ tags: [],
+ timeoutSeconds: 20,
+ heartbeatSeconds: 15,
+ transportMode: "ios-native"
+ };
+
+ serverStoreMock.servers = [server];
+
+ const store = useSessionStore();
+ await store.connect(server);
+
+ expect(listeners.value).toBeTypeOf("function");
+ await listeners.value?.({ type: "connected" });
+
+ const shellCompatCalls = transportMock.send.mock.calls.filter((args: unknown[]) =>
+ String(args.at(0) ?? "").includes("setopt MULTIBYTE PRINT_EIGHT_BIT")
+ );
+ expect(shellCompatCalls).toHaveLength(1);
+
+ await store.sendInput("中文");
+
+ const shellCompatCallsAfterInput = transportMock.send.mock.calls.filter((args: unknown[]) =>
+ String(args.at(0) ?? "").includes("setopt MULTIBYTE PRINT_EIGHT_BIT")
+ );
+ expect(shellCompatCallsAfterInput).toHaveLength(1);
+ expect(transportMock.send).toHaveBeenLastCalledWith("中文", undefined);
+ });
+
+ it("同服务器手动重连应保留输出历史;切换服务器应隔离历史", async () => {
+ const serverA: ServerProfile = {
+ id: "srv-a",
+ name: "A",
+ host: "10.0.0.1",
+ port: 22,
+ username: "gavin",
+ authType: "password",
+ projectPath: "~",
+ projectPresets: [],
+ tags: [],
+ timeoutSeconds: 20,
+ heartbeatSeconds: 15,
+ transportMode: "gateway"
+ };
+ const serverB = {
+ ...serverA,
+ id: "srv-b",
+ name: "B",
+ host: "10.0.0.2"
+ };
+
+ serverStoreMock.servers = [serverA, serverB];
+
+ const store = useSessionStore();
+ await store.connect(serverA);
+ await listeners.value?.({ type: "connected" });
+ await listeners.value?.({ type: "stdout", data: "history-from-a\r\n" });
+ expect(store.lines.join("")).toContain("history-from-a");
+
+ await store.disconnect("manual", true);
+ await store.connect(serverA);
+ await listeners.value?.({ type: "connected" });
+ expect(store.lines.join("")).toContain("history-from-a");
+
+ await store.connect(serverB);
+ await listeners.value?.({ type: "connected" });
+ expect(store.lines.join("")).not.toContain("history-from-a");
+ });
+
+ it("ws_closed 断开后应进入可续接态,并在手动断开时清除", async () => {
+ const server: ServerProfile = {
+ id: "srv-resume",
+ name: "resume",
+ host: "127.0.0.1",
+ port: 22,
+ username: "gavin",
+ authType: "password",
+ projectPath: "~",
+ projectPresets: [],
+ tags: [],
+ timeoutSeconds: 20,
+ heartbeatSeconds: 15,
+ transportMode: "gateway"
+ };
+
+ serverStoreMock.servers = [server];
+ settingsStoreMock.settings.autoReconnect = false;
+
+ const store = useSessionStore();
+ await store.connect(server);
+ await listeners.value?.({ type: "connected" });
+ expect(store.isServerResumable(server.id)).toBe(false);
+
+ await listeners.value?.({ type: "disconnect", reason: "ws_closed" });
+ expect(store.isServerResumable(server.id)).toBe(true);
+
+ await store.disconnect("manual", true);
+ expect(store.isServerResumable(server.id)).toBe(false);
+ });
+
+ it("Codex 预检命令回显包含 token 时不应误报目录不存在或未安装", async () => {
+ const server: ServerProfile = {
+ id: "srv-codex-ok",
+ name: "codex-ok",
+ host: "127.0.0.1",
+ port: 22,
+ username: "gavin",
+ authType: "password",
+ projectPath: "~/workspace",
+ projectPresets: [],
+ tags: [],
+ timeoutSeconds: 20,
+ heartbeatSeconds: 15,
+ transportMode: "gateway"
+ };
+
+ serverStoreMock.servers = [server];
+
+ const store = useSessionStore();
+ await store.connect(server);
+ await listeners.value?.({ type: "connected" });
+
+ const launchedPromise = store.runCodex(server.projectPath, "workspace-write");
+ const bootstrapCommand = String((transportMock.send.mock.calls.at(-1) ?? []).join(" "));
+ expect(bootstrapCommand.startsWith('sh -lc "')).toBe(true);
+
+ // 模拟 shell 回显“整条 bootstrap 命令”(包含 token 字面量),随后输出 READY。
+ await listeners.value?.({
+ type: "stdout",
+ data:
+ "__rc_codex_path_ok=1; __rc_codex_bin_ok=1; [ \"$__rc_codex_path_ok\" -eq 1 ] || printf '__RC_CODEX_DIR_MISSING__\\n'; " +
+ "[ \"$__rc_codex_bin_ok\" -eq 1 ] || printf '__RC_CODEX_BIN_MISSING__\\n';\r\n" +
+ "__RC_CODEX_READY__\r\nCodex started\r\n"
+ });
+
+ const launched = await launchedPromise;
+ expect(launched).toBe(true);
+
+ const warnMessages = appStoreMock.notify.mock.calls
+ .filter((args: unknown[]) => args[0] === "warn")
+ .map((args: unknown[]) => String(args[1] ?? ""));
+
+ expect(warnMessages.some((message) => message.includes("codex工作目录"))).toBe(false);
+ expect(warnMessages.some((message) => message.includes("服务器未装codex"))).toBe(false);
+ });
+
+ it("Codex 预检收到失败 token 行时应返回失败并提示原因", async () => {
+ const server: ServerProfile = {
+ id: "srv-codex-missing",
+ name: "codex-missing",
+ host: "127.0.0.1",
+ port: 22,
+ username: "gavin",
+ authType: "password",
+ projectPath: "~/workspace",
+ projectPresets: [],
+ tags: [],
+ timeoutSeconds: 20,
+ heartbeatSeconds: 15,
+ transportMode: "gateway"
+ };
+
+ serverStoreMock.servers = [server];
+
+ const store = useSessionStore();
+ await store.connect(server);
+ await listeners.value?.({ type: "connected" });
+
+ const launchedPromise = store.runCodex(server.projectPath, "workspace-write");
+ const bootstrapCommand = String((transportMock.send.mock.calls.at(-1) ?? []).join(" "));
+ expect(bootstrapCommand.startsWith('sh -lc "')).toBe(true);
+ await listeners.value?.({ type: "stdout", data: "__RC_CODEX_BIN_MISSING__\r\n" });
+
+ const launched = await launchedPromise;
+ expect(launched).toBe(false);
+ expect(appStoreMock.notify).toHaveBeenCalledWith("warn", "服务器未装codex");
+ });
+
+ it("命令回显包含 READY 字面量但无 READY token 行时,不应提前判定成功", async () => {
+ const server: ServerProfile = {
+ id: "srv-codex-ready-literal",
+ name: "codex-ready-literal",
+ host: "127.0.0.1",
+ port: 22,
+ username: "gavin",
+ authType: "password",
+ projectPath: "~",
+ projectPresets: [],
+ tags: [],
+ timeoutSeconds: 20,
+ heartbeatSeconds: 15,
+ transportMode: "gateway"
+ };
+
+ serverStoreMock.servers = [server];
+
+ const store = useSessionStore();
+ await store.connect(server);
+ await listeners.value?.({ type: "connected" });
+
+ const launchedPromise = store.runCodex(server.projectPath, "workspace-write");
+
+ // 仅回显脚本字面量(包含 READY token 文本,但不是独立 token 行)。
+ await listeners.value?.({
+ type: "stdout",
+ data:
+ "__rc_codex_path_ok=1; __rc_codex_bin_ok=1; if [ \"$__rc_codex_path_ok\" -eq 1 ] && [ \"$__rc_codex_bin_ok\" -eq 1 ]; " +
+ "then printf '__RC_CODEX_READY__\\n'; codex --sandbox workspace-write; fi\r\n"
+ });
+ // 随后给出真实失败 token 行,应返回失败并提示未安装。
+ await listeners.value?.({ type: "stdout", data: "__RC_CODEX_BIN_MISSING__\r\n" });
+
+ const launched = await launchedPromise;
+ expect(launched).toBe(false);
+ expect(appStoreMock.notify).toHaveBeenCalledWith("warn", "服务器未装codex");
+ });
+});
diff --git a/pxterm/src/stores/sessionStore.ts b/pxterm/src/stores/sessionStore.ts
new file mode 100644
index 0000000..a43f990
--- /dev/null
+++ b/pxterm/src/stores/sessionStore.ts
@@ -0,0 +1,1119 @@
+import { defineStore } from "pinia";
+import { computed, ref } from "vue";
+import type { SessionState, ServerProfile } from "@/types/app";
+import { allStates, buildCodexPlan } from "@remoteconn/shared";
+import type { StdinMeta } from "@remoteconn/shared";
+import { useSettingsStore } from "./settingsStore";
+import { useServerStore } from "./serverStore";
+import { useLogStore } from "./logStore";
+import { useAppStore } from "./appStore";
+import { createTransport } from "@/services/transport/factory";
+import type { TerminalTransport } from "@/services/transport/terminalTransport";
+import { emitSessionEvent } from "@/services/sessionEventBus";
+import { formatActionError, toFriendlyDisconnectReason, toFriendlyError } from "@/utils/feedback";
+
+interface StoredSessionSnapshotV1 {
+ version: 1;
+ savedAt: number;
+ lines: string[];
+ currentServerId: string;
+ reconnectServerId: string;
+}
+
+interface TerminalBufferBucket {
+ lines: string[];
+ chunkBytes: number[];
+ bufferedBytes: number;
+ updatedAt: number;
+}
+
+interface CodexBootstrapGuard {
+ active: boolean;
+ connectionKey: string;
+ projectPath: string;
+ buffer: string;
+ notifiedDirMissing: boolean;
+ notifiedCodexMissing: boolean;
+ releaseTimer: number | null;
+ timeoutTimer: number | null;
+ settleResult: (result: boolean) => void;
+ settleError: (error: Error) => void;
+}
+
+interface StoredSessionSnapshotV2 {
+ version: 2;
+ savedAt: number;
+ activeConnectionKey: string;
+ lines: string[];
+ currentServerId: string;
+ reconnectServerId: string;
+}
+
+type StoredSessionSnapshot = StoredSessionSnapshotV1 | StoredSessionSnapshotV2;
+
+/**
+ * 会话生命周期管理:连接、命令执行、重连、断开、Codex 编排。
+ */
+export const useSessionStore = defineStore("session", () => {
+ const SESSION_SNAPSHOT_STORAGE_KEY = "remoteconn_session_snapshot_v1";
+ const SESSION_SNAPSHOT_VERSION = 2;
+ const SESSION_SNAPSHOT_PERSIST_DELAY_MS = 120;
+ const LATENCY_SAMPLE_WINDOW = 6;
+ const MAX_BUFFER_BUCKETS = 10;
+ const DEFAULT_CONNECTION_KEY = "session::default";
+ const RESUME_HIGHLIGHT_WINDOW_MS = 20_000;
+
+ const settingsStore = useSettingsStore();
+ const state = ref("idle");
+ const activeConnectionKey = ref(DEFAULT_CONNECTION_KEY);
+ const resumableServerId = ref("");
+ const resumableExpiresAt = ref(0);
+ const buffersByKey = ref>({});
+ const lines = computed(() => getOrCreateBucket(activeConnectionKey.value).lines);
+ const outputRevision = ref(0);
+ const latencyMs = ref(0);
+ const reconnectAttempts = ref(0);
+ const currentSessionId = ref("");
+ const currentServerId = ref("");
+
+ let transport: TerminalTransport | null = null;
+ let offTransport: (() => void) | null = null;
+ let reconnectTimer: number | null = null;
+ let pendingLfAfterCr = false;
+ let shellCompatBootstrapped = false;
+ let ensureShellCompatibility: (() => Promise) | null = null;
+ const utf8Encoder = new TextEncoder();
+ const MIN_TERMINAL_BUFFER_MAX_ENTRIES = 200;
+ const MIN_TERMINAL_BUFFER_MAX_BYTES = 64 * 1024;
+ const CODEX_BOOTSTRAP_TOKEN_DIR_MISSING = "__RC_CODEX_DIR_MISSING__";
+ const CODEX_BOOTSTRAP_TOKEN_CODEX_MISSING = "__RC_CODEX_BIN_MISSING__";
+ const CODEX_BOOTSTRAP_TOKEN_READY = "__RC_CODEX_READY__";
+ const CODEX_BOOTSTRAP_WAIT_TIMEOUT_MS = 6000;
+ const CODEX_BOOTSTRAP_RELEASE_DELAY_MS = 260;
+ const CODEX_BOOTSTRAP_BUFFER_MAX_CHARS = 8192;
+ let snapshotPersistTimer: number | null = null;
+ let resumableExpireTimer: number | null = null;
+ let sessionBootstrapped = false;
+ let autoReconnectInFlight = false;
+ let autoReconnectSuppressed = false;
+ let onPageLifecyclePersist: (() => void) | null = null;
+ let onVisibilityPersist: (() => void) | null = null;
+ let bootstrapPromise: Promise | null = null;
+ const latencySamples: number[] = [];
+ let codexBootstrapGuard: CodexBootstrapGuard | null = null;
+
+ /**
+ * 转义正则元字符,避免 token 文本参与正则语义。
+ */
+ function escapeRegExpLiteral(value: string): string {
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ }
+
+ /**
+ * 仅匹配“独立一行”的 bootstrap token。
+ * 说明:预检命令回显里会包含 token 字符串(如 printf '__RC_CODEX_*'),
+ * 若用 includes 全文匹配会误判为失败。这里改为“按行精确匹配”。
+ */
+ function hasCodexBootstrapTokenLine(source: string, token: string): boolean {
+ const pattern = new RegExp(`(^|\\r?\\n)${escapeRegExpLiteral(token)}(?=\\r?\\n|$)`);
+ return pattern.test(source);
+ }
+
+ /**
+ * 删除独立 token 行,避免其残留到后续缓冲。
+ */
+ function stripCodexBootstrapTokenLine(source: string, token: string): string {
+ const pattern = new RegExp(`(^|\\r?\\n)${escapeRegExpLiteral(token)}(?=\\r?\\n|$)`, "g");
+ return source.replace(pattern, "$1");
+ }
+
+ /**
+ * 提取“首个 token 行”之后的内容:
+ * - 用于 READY 判定,避免命令回显中的 token 字面量触发“提前成功”;
+ * - 仅当 token 作为独立一行出现时才视为真实信号。
+ */
+ function extractAfterFirstCodexBootstrapTokenLine(
+ source: string,
+ token: string
+ ): { found: true; after: string } | { found: false } {
+ const pattern = new RegExp(`(^|\\r?\\n)${escapeRegExpLiteral(token)}(\\r?\\n|$)`);
+ const match = pattern.exec(source);
+ if (!match) {
+ return { found: false };
+ }
+ const prefix = match[1] ?? "";
+ const suffix = match[2] ?? "";
+ const tokenStart = match.index + prefix.length;
+ const tokenEnd = tokenStart + token.length + suffix.length;
+ return { found: true, after: source.slice(tokenEnd) };
+ }
+
+ /**
+ * 仅在“当前活跃连接”仍保持 connected 时提示 bootstrap 失败原因。
+ * 避免旧连接残留输出或状态切换期间触发误报 toast。
+ */
+ function shouldNotifyCodexBootstrapIssue(guard: CodexBootstrapGuard): boolean {
+ return state.value === "connected" && guard.connectionKey === activeConnectionKey.value;
+ }
+
+ /**
+ * 将脚本文本安全嵌入到 `sh -lc "..."` 的双引号参数中:
+ * - 统一转义双引号、反斜杠、变量符、反引号与 csh 历史展开符;
+ * - 目标是让“当前默认 shell(可能是 csh/tcsh)”只负责转发,不参与脚本语义。
+ */
+ function escapeForDoubleQuotedShellArg(script: string): string {
+ return script
+ .replace(/\\/g, "\\\\")
+ .replace(/"/g, '\\"')
+ .replace(/\$/g, "\\$")
+ .replace(/`/g, "\\`")
+ .replace(/!/g, "\\!");
+ }
+
+ /**
+ * 用于修复 zsh 中文输入回显乱码的会话初始化命令。
+ * 目标:
+ * 1) 强制 UTF-8 locale(LANG/LC_CTYPE/LC_ALL);
+ * 2) 开启 `stty iutf8`,让行编辑按 UTF-8 处理退格/宽字符;
+ * 3) 开启 zsh `MULTIBYTE` + `PRINT_EIGHT_BIT`;
+ * 4) 保留此前已验证的 `%` 行尾标记抑制。
+ */
+ const shellCompatInitCommand =
+ "if [ -n \"$ZSH_VERSION\" ]; then export LANG=\"${LANG:-zh_CN.UTF-8}\"; export LC_CTYPE=\"${LC_CTYPE:-$LANG}\"; if [ -z \"$LC_ALL\" ]; then export LC_ALL=\"$LANG\"; fi; stty iutf8 2>/dev/null; setopt MULTIBYTE PRINT_EIGHT_BIT 2>/dev/null; unsetopt PROMPT_SP 2>/dev/null; PROMPT_EOL_MARK=''; fi\r";
+
+ const connected = computed(() => state.value === "connected");
+
+ function createBufferBucket(initialLines: string[] = []): TerminalBufferBucket {
+ const chunkBytes = initialLines.map((chunk) => utf8Encoder.encode(chunk).byteLength);
+ const bufferedBytes = chunkBytes.reduce((sum, size) => sum + size, 0);
+ return {
+ lines: [...initialLines],
+ chunkBytes,
+ bufferedBytes,
+ updatedAt: Date.now()
+ };
+ }
+
+ /**
+ * 清理“可续接态”标记:
+ * - 用于手动断开、切换连接、续接成功后等场景;
+ * - 避免按钮长期停留在强调色造成状态误导。
+ */
+ function clearResumableState(): void {
+ if (resumableExpireTimer !== null && typeof window !== "undefined") {
+ window.clearTimeout(resumableExpireTimer);
+ resumableExpireTimer = null;
+ }
+ resumableServerId.value = "";
+ resumableExpiresAt.value = 0;
+ }
+
+ /**
+ * 标记“可续接态”窗口:
+ * - 与网关侧驻留窗口保持一致(默认 20s);
+ * - 仅在 WS 断开但可能仍可续接 SSH 的场景使用。
+ */
+ function markResumableState(serverId: string): void {
+ if (!serverId) {
+ clearResumableState();
+ return;
+ }
+ clearResumableState();
+ const expiresAt = Date.now() + RESUME_HIGHLIGHT_WINDOW_MS;
+ resumableServerId.value = serverId;
+ resumableExpiresAt.value = expiresAt;
+ if (typeof window !== "undefined") {
+ resumableExpireTimer = window.setTimeout(() => {
+ clearResumableState();
+ }, RESUME_HIGHLIGHT_WINDOW_MS);
+ }
+ }
+
+ /**
+ * 某服务器当前是否处于“可续接”窗口。
+ */
+ function isServerResumable(serverId: string): boolean {
+ return Boolean(serverId) && resumableServerId.value === serverId && resumableExpiresAt.value > Date.now();
+ }
+
+ function getOrCreateBucket(key: string): TerminalBufferBucket {
+ const existing = buffersByKey.value[key];
+ if (existing) {
+ return existing;
+ }
+ const next = createBufferBucket();
+ buffersByKey.value[key] = next;
+ return next;
+ }
+
+ function touchBucket(key: string): void {
+ const bucket = getOrCreateBucket(key);
+ bucket.updatedAt = Date.now();
+ }
+
+ function trimBucketCount(): void {
+ const keys = Object.keys(buffersByKey.value);
+ if (keys.length <= MAX_BUFFER_BUCKETS) {
+ return;
+ }
+ const candidates = keys
+ .filter((key) => key !== activeConnectionKey.value)
+ .sort((a, b) => (buffersByKey.value[a]?.updatedAt ?? 0) - (buffersByKey.value[b]?.updatedAt ?? 0));
+
+ while (Object.keys(buffersByKey.value).length > MAX_BUFFER_BUCKETS && candidates.length > 0) {
+ const removed = candidates.shift();
+ if (removed) {
+ delete buffersByKey.value[removed];
+ }
+ }
+ }
+
+ function createConnectionKey(serverId: string): string {
+ return `${serverId}::${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
+ }
+
+ function findLatestConnectionKeyForServer(serverId: string): string | null {
+ const prefix = `${serverId}::`;
+ const keys = Object.keys(buffersByKey.value)
+ .filter((key) => key.startsWith(prefix))
+ .sort((a, b) => (buffersByKey.value[b]?.updatedAt ?? 0) - (buffersByKey.value[a]?.updatedAt ?? 0));
+ return keys[0] ?? null;
+ }
+
+ /**
+ * 解析本次连接应使用的缓冲 key:
+ * 1) 自动重连:始终复用该服务器最近 key,保持上下文连续;
+ * 2) 手动连接同一服务器:复用最近 key,避免“重连后历史丢失”;
+ * 3) 手动连接不同服务器:创建新 key,保持跨服务器隔离。
+ */
+ function resolveConnectionKeyForConnect(
+ serverId: string,
+ isReconnectAttempt: boolean,
+ previousServerId: string
+ ): string {
+ const latest = findLatestConnectionKeyForServer(serverId);
+ const canReuseLatest =
+ Boolean(latest) && (isReconnectAttempt || previousServerId === serverId || previousServerId.length === 0);
+ if (canReuseLatest && latest) {
+ return latest;
+ }
+ return createConnectionKey(serverId);
+ }
+
+ function assertState(next: SessionState): void {
+ if (!allStates().includes(next)) {
+ throw new Error(`未知状态: ${next}`);
+ }
+ state.value = next;
+ }
+
+ function canUseStorage(): boolean {
+ return typeof window !== "undefined" && typeof window.sessionStorage !== "undefined";
+ }
+
+ function shouldReconnectAfterReload(): boolean {
+ return ["connecting", "auth_pending", "connected", "reconnecting"].includes(state.value) && Boolean(currentServerId.value);
+ }
+
+ function normalizeBufferLimit(value: number, fallback: number, min: number): number {
+ if (!Number.isFinite(value)) {
+ return fallback;
+ }
+ return Math.max(min, Math.round(value));
+ }
+
+ /**
+ * 从全局设置读取终端缓冲阈值:
+ * - 字节上限用于稳定控制内存占用;
+ * - 条目上限作为碎片化输出兜底;
+ * - 均做最小值收敛,避免异常配置导致“缓冲失控”。
+ */
+ function resolveTerminalBufferLimits(): { maxEntries: number; maxBytes: number } {
+ const maxEntries = normalizeBufferLimit(
+ settingsStore.settings.terminalBufferMaxEntries,
+ 5000,
+ MIN_TERMINAL_BUFFER_MAX_ENTRIES
+ );
+ const maxBytes = normalizeBufferLimit(
+ settingsStore.settings.terminalBufferMaxBytes,
+ 4 * 1024 * 1024,
+ MIN_TERMINAL_BUFFER_MAX_BYTES
+ );
+ return { maxEntries, maxBytes };
+ }
+
+ function trimByEntries(bucket: TerminalBufferBucket, maxEntries: number): void {
+ if (bucket.lines.length <= maxEntries) {
+ return;
+ }
+ const removeCount = bucket.lines.length - maxEntries;
+ const removedBytes = bucket.chunkBytes.splice(0, removeCount).reduce((sum, size) => sum + size, 0);
+ bucket.lines.splice(0, removeCount);
+ bucket.bufferedBytes = Math.max(0, bucket.bufferedBytes - removedBytes);
+ }
+
+ function trimByBytes(bucket: TerminalBufferBucket, maxBytes: number): void {
+ if (bucket.bufferedBytes <= maxBytes || bucket.lines.length <= 1) {
+ return;
+ }
+ let removeCount = 0;
+ let removedBytes = 0;
+ // 至少保留最新一条,避免在“单条超大输出”场景下出现空白闪烁。
+ while (bucket.bufferedBytes - removedBytes > maxBytes && removeCount < bucket.chunkBytes.length - 1) {
+ removedBytes += bucket.chunkBytes[removeCount] ?? 0;
+ removeCount += 1;
+ }
+ if (removeCount <= 0) {
+ return;
+ }
+ bucket.chunkBytes.splice(0, removeCount);
+ bucket.lines.splice(0, removeCount);
+ bucket.bufferedBytes = Math.max(0, bucket.bufferedBytes - removedBytes);
+ }
+
+ /**
+ * 终端原始输出缓冲:保持字节流语义,避免逐字符被当成“行”导致每键一换行。
+ * 裁剪策略:
+ * 1) 优先按条目上限兜底;
+ * 2) 再按 UTF-8 字节上限收敛内存;
+ * 3) 两个阈值均来自全局配置,可在设置页调整。
+ */
+ function appendTerminal(text: string): void {
+ const bucket = getOrCreateBucket(activeConnectionKey.value);
+ const { maxEntries, maxBytes } = resolveTerminalBufferLimits();
+ const chunkBytes = utf8Encoder.encode(text).byteLength;
+ bucket.lines.push(text);
+ bucket.chunkBytes.push(chunkBytes);
+ bucket.bufferedBytes += chunkBytes;
+ bucket.updatedAt = Date.now();
+ trimByEntries(bucket, maxEntries);
+ trimByBytes(bucket, maxBytes);
+ trimBucketCount();
+ outputRevision.value += 1;
+ persistSnapshotLater();
+ }
+
+ function buildSnapshot(linesForPersist = getOrCreateBucket(activeConnectionKey.value).lines): StoredSessionSnapshotV2 {
+ return {
+ version: SESSION_SNAPSHOT_VERSION,
+ savedAt: Date.now(),
+ activeConnectionKey: activeConnectionKey.value,
+ lines: [...linesForPersist],
+ currentServerId: currentServerId.value,
+ reconnectServerId: shouldReconnectAfterReload() ? currentServerId.value : ""
+ };
+ }
+
+ /**
+ * 写入会话快照:
+ * - 优先完整保存;
+ * - 若命中浏览器配额,自动退化到“后半段输出”重试,保证刷新后至少可恢复最近上下文。
+ */
+ function persistSnapshotNow(): void {
+ if (!canUseStorage()) {
+ return;
+ }
+ if (snapshotPersistTimer) {
+ window.clearTimeout(snapshotPersistTimer);
+ snapshotPersistTimer = null;
+ }
+
+ let candidateLines = getOrCreateBucket(activeConnectionKey.value).lines;
+ while (true) {
+ const snapshot = buildSnapshot(candidateLines);
+ try {
+ window.sessionStorage.setItem(SESSION_SNAPSHOT_STORAGE_KEY, JSON.stringify(snapshot));
+ return;
+ } catch {
+ if (candidateLines.length <= 200) {
+ return;
+ }
+ candidateLines = candidateLines.slice(Math.floor(candidateLines.length / 2));
+ }
+ }
+ }
+
+ function persistSnapshotLater(): void {
+ if (!canUseStorage()) {
+ return;
+ }
+ if (snapshotPersistTimer) {
+ window.clearTimeout(snapshotPersistTimer);
+ }
+ snapshotPersistTimer = window.setTimeout(() => {
+ persistSnapshotNow();
+ }, SESSION_SNAPSHOT_PERSIST_DELAY_MS);
+ }
+
+ /**
+ * 恢复刷新前的终端上下文:
+ * - 输出缓冲(lines);
+ * - 当前服务器 ID;
+ * - 自动重连意图(reconnectServerId)。
+ */
+ function restoreSnapshot(): string {
+ if (!canUseStorage()) {
+ return "";
+ }
+ try {
+ const raw = window.sessionStorage.getItem(SESSION_SNAPSHOT_STORAGE_KEY);
+ if (!raw) {
+ return "";
+ }
+ const parsed = JSON.parse(raw) as Partial;
+ if (parsed.version !== 1 && parsed.version !== 2) {
+ return "";
+ }
+ const restoredLines = Array.isArray(parsed.lines) ? parsed.lines.filter((item): item is string => typeof item === "string") : [];
+ const restoredKey =
+ parsed.version === 2 && typeof parsed.activeConnectionKey === "string" && parsed.activeConnectionKey
+ ? parsed.activeConnectionKey
+ : DEFAULT_CONNECTION_KEY;
+
+ activeConnectionKey.value = restoredKey;
+ buffersByKey.value[restoredKey] = createBufferBucket(restoredLines);
+ trimBucketCount();
+ if (restoredLines.length > 0) {
+ outputRevision.value += 1;
+ }
+ if (typeof parsed.currentServerId === "string") {
+ currentServerId.value = parsed.currentServerId;
+ }
+ if (typeof parsed.reconnectServerId === "string") {
+ return parsed.reconnectServerId;
+ }
+ } catch {
+ // 快照损坏时静默跳过,避免阻塞主流程。
+ }
+ return "";
+ }
+
+ /**
+ * 刷新恢复重连:
+ * - `fromReload=true` 时表示“页面刷新后的会话恢复”,不受 autoReconnect 开关影响;
+ * - 断线后的常规自动重连仍由 autoReconnect 开关控制(见 disconnect 事件分支)。
+ */
+ async function tryAutoReconnect(reconnectServerId: string, fromReload = false): Promise {
+ if (!reconnectServerId || autoReconnectInFlight) {
+ return;
+ }
+ if (!fromReload && !settingsStore.settings.autoReconnect) {
+ return;
+ }
+ if (!["idle", "disconnected", "error"].includes(state.value)) {
+ return;
+ }
+ const serverStore = useServerStore();
+ const appStore = useAppStore();
+ const target = serverStore.servers.find((item) => item.id === reconnectServerId);
+ if (!target) {
+ return;
+ }
+
+ autoReconnectInFlight = true;
+ try {
+ appStore.notify("info", `检测到页面刷新,正在自动重连:${target.username}@${target.host}:${target.port}`);
+ await connect({
+ ...target,
+ projectPresets: [...target.projectPresets],
+ tags: [...target.tags]
+ }, true);
+ } catch (error) {
+ appStore.notify("warn", formatActionError("刷新后自动重连失败", error));
+ } finally {
+ autoReconnectInFlight = false;
+ }
+ }
+
+ /**
+ * 会话层启动:
+ * 1) 恢复 sessionStorage 中的输出上下文;
+ * 2) 注册页面生命周期持久化(beforeunload/pagehide/hidden);
+ * 3) 根据快照自动发起重连。
+ */
+ async function ensureBootstrapped(): Promise {
+ if (sessionBootstrapped) {
+ return;
+ }
+ if (bootstrapPromise) {
+ await bootstrapPromise;
+ return;
+ }
+
+ bootstrapPromise = (async () => {
+ sessionBootstrapped = true;
+ const reconnectServerId = restoreSnapshot();
+ if (reconnectServerId) {
+ markResumableState(reconnectServerId);
+ } else {
+ clearResumableState();
+ }
+ persistSnapshotLater();
+ if (typeof window !== "undefined") {
+ onPageLifecyclePersist = () => {
+ persistSnapshotNow();
+ };
+ onVisibilityPersist = () => {
+ if (document.visibilityState === "hidden") {
+ persistSnapshotNow();
+ }
+ };
+ window.addEventListener("beforeunload", onPageLifecyclePersist, { capture: true });
+ window.addEventListener("pagehide", onPageLifecyclePersist, { capture: true });
+ document.addEventListener("visibilitychange", onVisibilityPersist, { capture: true });
+ }
+ await tryAutoReconnect(reconnectServerId, true);
+ })();
+
+ try {
+ await bootstrapPromise;
+ } finally {
+ bootstrapPromise = null;
+ }
+ }
+
+ async function bootstrap(): Promise {
+ await ensureBootstrapped();
+ }
+
+ /**
+ * 统一用户输入中的换行语义:
+ * - 终端交互协议更稳妥的是 CR(`\r`)作为回车;
+ * - 移动端/输入法可能产生 `\n` 或 `\r\n`,这里统一折叠为 `\r`,
+ * 避免在部分 shell + pty 组合下出现“按一次回车多一个空行”。
+ */
+ function normalizeEnter(input: string): string {
+ if (!input) return input;
+
+ let output = "";
+ let index = 0;
+
+ if (pendingLfAfterCr && input.startsWith("\n")) {
+ index = 1;
+ }
+ pendingLfAfterCr = false;
+
+ for (; index < input.length; index += 1) {
+ const ch = input[index];
+ if (ch === "\r") {
+ output += "\r";
+ pendingLfAfterCr = true;
+ continue;
+ }
+
+ if (ch === "\n") {
+ if (pendingLfAfterCr) {
+ pendingLfAfterCr = false;
+ continue;
+ }
+ output += "\r";
+ continue;
+ }
+
+ pendingLfAfterCr = false;
+ output += ch;
+ }
+
+ return output;
+ }
+
+ function clearCodexBootstrapGuard(target?: CodexBootstrapGuard): void {
+ const guard = target ?? codexBootstrapGuard;
+ if (!guard) return;
+ if (guard.releaseTimer !== null) {
+ window.clearTimeout(guard.releaseTimer);
+ guard.releaseTimer = null;
+ }
+ if (guard.timeoutTimer !== null) {
+ window.clearTimeout(guard.timeoutTimer);
+ guard.timeoutTimer = null;
+ }
+ if (codexBootstrapGuard === guard) {
+ codexBootstrapGuard = null;
+ }
+ }
+
+ function settleCodexBootstrapGuardAsResult(result: boolean, target?: CodexBootstrapGuard): void {
+ const guard = target ?? codexBootstrapGuard;
+ if (!guard) return;
+ clearCodexBootstrapGuard(guard);
+ guard.settleResult(result);
+ }
+
+ function settleCodexBootstrapGuardAsError(error: Error, target?: CodexBootstrapGuard): void {
+ const guard = target ?? codexBootstrapGuard;
+ if (!guard) return;
+ clearCodexBootstrapGuard(guard);
+ guard.settleError(error);
+ }
+
+ function scheduleCodexBootstrapGuardRelease(target?: CodexBootstrapGuard): void {
+ const guard = target ?? codexBootstrapGuard;
+ if (!guard || guard.releaseTimer !== null) return;
+ guard.releaseTimer = window.setTimeout(() => {
+ clearCodexBootstrapGuard(guard);
+ }, CODEX_BOOTSTRAP_RELEASE_DELAY_MS);
+ }
+
+ /**
+ * Codex 启动阶段输出拦截:
+ * 1) 在预检阶段吞掉命令回显与探测细节,避免终端出现“cd/command -v/codex not found”等噪音;
+ * 2) 仅通过“独立 token 行”触发业务提示(目录不存在、服务器未装 codex);
+ * 3) 收到 READY token 后解除拦截,并透传后续真实 Codex 输出。
+ */
+ function consumeCodexBootstrapOutput(data: string): string {
+ const guard = codexBootstrapGuard;
+ if (!guard?.active) {
+ return data;
+ }
+
+ guard.buffer = `${guard.buffer}${data}`;
+ if (guard.buffer.length > CODEX_BOOTSTRAP_BUFFER_MAX_CHARS) {
+ guard.buffer = guard.buffer.slice(-CODEX_BOOTSTRAP_BUFFER_MAX_CHARS);
+ }
+
+ let working = guard.buffer;
+ const hasDirMissing = hasCodexBootstrapTokenLine(working, CODEX_BOOTSTRAP_TOKEN_DIR_MISSING);
+ const hasCodexMissing = hasCodexBootstrapTokenLine(working, CODEX_BOOTSTRAP_TOKEN_CODEX_MISSING);
+ const shouldNotify = shouldNotifyCodexBootstrapIssue(guard);
+
+ if (hasDirMissing && !guard.notifiedDirMissing) {
+ guard.notifiedDirMissing = true;
+ if (shouldNotify) {
+ const appStore = useAppStore();
+ appStore.notify("warn", `codex工作目录${guard.projectPath}不存在`);
+ }
+ }
+
+ if (hasCodexMissing && !guard.notifiedCodexMissing) {
+ guard.notifiedCodexMissing = true;
+ if (shouldNotify) {
+ const appStore = useAppStore();
+ appStore.notify("warn", "服务器未装codex");
+ }
+ }
+
+ if (hasDirMissing) {
+ working = stripCodexBootstrapTokenLine(working, CODEX_BOOTSTRAP_TOKEN_DIR_MISSING);
+ }
+ if (hasCodexMissing) {
+ working = stripCodexBootstrapTokenLine(working, CODEX_BOOTSTRAP_TOKEN_CODEX_MISSING);
+ }
+
+ const readyLine = extractAfterFirstCodexBootstrapTokenLine(working, CODEX_BOOTSTRAP_TOKEN_READY);
+ if (readyLine.found) {
+ const afterReady = readyLine.after.replace(/^\r?\n/, "");
+ settleCodexBootstrapGuardAsResult(true, guard);
+ return afterReady;
+ }
+
+ // 任一失败 token 出现后即判定本次启动失败,并短暂维持拦截吞掉尾部提示符。
+ if (guard.notifiedDirMissing || guard.notifiedCodexMissing) {
+ guard.buffer = "";
+ guard.settleResult(false);
+ scheduleCodexBootstrapGuardRelease(guard);
+ return "";
+ }
+
+ guard.buffer = working;
+ return "";
+ }
+
+ function startCodexBootstrapGuard(projectPath: string): Promise {
+ if (codexBootstrapGuard?.active) {
+ throw new Error("Codex 正在启动中");
+ }
+
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ let guardRef: CodexBootstrapGuard | null = null;
+
+ const settleResult = (result: boolean): void => {
+ if (settled) return;
+ settled = true;
+ resolve(result);
+ };
+ const settleError = (error: Error): void => {
+ if (settled) return;
+ settled = true;
+ reject(error);
+ };
+
+ const timeoutTimer = window.setTimeout(() => {
+ settleCodexBootstrapGuardAsError(new Error("等待 Codex 启动结果超时"), guardRef ?? undefined);
+ }, CODEX_BOOTSTRAP_WAIT_TIMEOUT_MS);
+
+ const guard: CodexBootstrapGuard = {
+ active: true,
+ connectionKey: activeConnectionKey.value,
+ projectPath,
+ buffer: "",
+ notifiedDirMissing: false,
+ notifiedCodexMissing: false,
+ releaseTimer: null,
+ timeoutTimer,
+ settleResult,
+ settleError
+ };
+ guardRef = guard;
+ codexBootstrapGuard = guard;
+ });
+ }
+
+ async function connect(server: ServerProfile, isReconnectAttempt = false): Promise {
+ const serverStore = useServerStore();
+ const logStore = useLogStore();
+ const appStore = useAppStore();
+ const previousServerId = currentServerId.value;
+
+ clearResumableState();
+ await disconnect("switch", false);
+ assertState("connecting");
+
+ if (!isReconnectAttempt) {
+ reconnectAttempts.value = 0;
+ autoReconnectSuppressed = false;
+ }
+ activeConnectionKey.value = resolveConnectionKeyForConnect(server.id, isReconnectAttempt, previousServerId);
+ getOrCreateBucket(activeConnectionKey.value);
+
+ touchBucket(activeConnectionKey.value);
+ trimBucketCount();
+ latencyMs.value = 0;
+ latencySamples.length = 0;
+ currentServerId.value = server.id;
+ persistSnapshotLater();
+
+ const sessionId = await logStore.startLog(server.id);
+ currentSessionId.value = sessionId;
+
+ let credential: Awaited>;
+ try {
+ credential = await serverStore.resolveCredential(server.id);
+ } catch (error) {
+ const reason = `凭据读取失败,请在服务器设置页重新保存凭据: ${(error as Error).message}`;
+ assertState("error");
+ await logStore.markStatus(sessionId, "error", reason);
+ throw new Error(reason);
+ }
+
+ transport = createTransport(server.transportMode, {
+ gatewayUrl: settingsStore.gatewayUrl,
+ gatewayToken: settingsStore.gatewayToken
+ });
+ let markedConnected = false;
+ /**
+ * gateway 模式由网关侧静默初始化 shell 兼容项,不再由前端注入;
+ * ios-native 仍保留前端兜底注入。
+ */
+ shellCompatBootstrapped = server.transportMode === "gateway";
+
+ /**
+ * 终端连接建立后,自动执行一次 zsh 兼容初始化:
+ * 1) `MULTIBYTE` 确保 zle 以多字节模式处理中文输入;
+ * 2) `PRINT_EIGHT_BIT` 避免把高位字节渲染成 `\M-^X`;
+ * 3) 继续保留此前验证有效的 `%` 行尾标记抑制设置。
+ *
+ * 说明:该命令带 shell 条件判断,bash/fish 等非 zsh 环境会直接跳过。
+ */
+ ensureShellCompatibility = async (): Promise => {
+ if (!transport || shellCompatBootstrapped) {
+ return;
+ }
+ shellCompatBootstrapped = true;
+ try {
+ await transport.send(shellCompatInitCommand);
+ } catch {
+ // 不阻塞主连接:失败时仅回退为“不注入兼容命令”。
+ }
+ };
+
+ const markConnectedState = async (): Promise => {
+ if (markedConnected) return;
+ markedConnected = true;
+ clearResumableState();
+ reconnectAttempts.value = 0;
+ assertState("connected");
+ await logStore.markStatus(sessionId, "connected");
+ await serverStore.markConnected(server.id);
+ appStore.notify("info", "SSH 通道已建立");
+ emitSessionEvent("connected", { serverId: server.id, serverName: server.name });
+ await ensureShellCompatibility?.();
+ };
+
+ offTransport = transport.on(async (event) => {
+ if (event.type === "stdout") {
+ await markConnectedState();
+ const nextData = consumeCodexBootstrapOutput(event.data);
+ if (nextData) {
+ appendTerminal(nextData);
+ emitSessionEvent("stdout", { data: nextData, serverId: server.id });
+ }
+ }
+
+ if (event.type === "stderr") {
+ const nextData = consumeCodexBootstrapOutput(event.data);
+ if (nextData) {
+ appendTerminal(nextData);
+ emitSessionEvent("stderr", { data: nextData, serverId: server.id });
+ }
+ }
+
+ if (event.type === "latency") {
+ latencySamples.push(event.data);
+ if (latencySamples.length > LATENCY_SAMPLE_WINDOW) {
+ latencySamples.shift();
+ }
+ const average = Math.round(
+ latencySamples.reduce((sum, sample) => sum + sample, 0) / latencySamples.length
+ );
+ latencyMs.value = average;
+ emitSessionEvent("latency", { latency: average, serverId: server.id });
+ }
+
+ if (event.type === "disconnect") {
+ settleCodexBootstrapGuardAsResult(false);
+ assertState("disconnected");
+ latencyMs.value = 0;
+ latencySamples.length = 0;
+ if (event.reason === "ws_closed" && currentServerId.value) {
+ markResumableState(currentServerId.value);
+ } else {
+ clearResumableState();
+ }
+ appStore.notify("warn", toFriendlyDisconnectReason(event.reason));
+ await logStore.markStatus(sessionId, "disconnected", event.reason);
+ emitSessionEvent("disconnected", { reason: event.reason, serverId: server.id });
+
+ if (
+ settingsStore.settings.autoReconnect &&
+ !autoReconnectSuppressed &&
+ reconnectAttempts.value < settingsStore.settings.reconnectLimit
+ ) {
+ scheduleReconnect(server);
+ } else {
+ persistSnapshotLater();
+ }
+ }
+
+ if (event.type === "connected") {
+ if (event.fingerprint) {
+ const trusted = await settingsStore.verifyAndPersistHostFingerprint(
+ `${server.host}:${server.port}`,
+ event.fingerprint
+ );
+ if (!trusted) {
+ await disconnect("host_key_rejected", false);
+ const appStore = useAppStore();
+ appStore.notify("error", "主机指纹未被信任,连接已断开");
+ return;
+ }
+ return;
+ }
+ // 无指纹的 connected 事件表示网关侧 shell 已就绪。
+ await markConnectedState();
+ }
+
+ if (event.type === "error") {
+ settleCodexBootstrapGuardAsResult(false);
+ assertState("error");
+ latencyMs.value = 0;
+ latencySamples.length = 0;
+ clearResumableState();
+ appStore.notify("error", `连接错误:${toFriendlyError(event.message || event.code)}`);
+ await logStore.markStatus(sessionId, "error", event.message);
+ persistSnapshotLater();
+ }
+ });
+
+ try {
+ assertState("auth_pending");
+ await transport.connect({
+ host: server.host,
+ port: server.port,
+ username: server.username,
+ clientSessionKey: activeConnectionKey.value,
+ credential,
+ knownHostFingerprint: settingsStore.knownHosts[`${server.host}:${server.port}`],
+ cols: 140,
+ rows: 40
+ });
+ persistSnapshotLater();
+ } catch (error) {
+ assertState("error");
+ await logStore.markStatus(sessionId, "error", (error as Error).message);
+ persistSnapshotLater();
+ throw error;
+ }
+ }
+
+ async function disconnect(reason = "manual", userInitiated = true): Promise {
+ if (reconnectTimer) {
+ window.clearTimeout(reconnectTimer);
+ reconnectTimer = null;
+ }
+
+ if (transport) {
+ await transport.disconnect(reason);
+ transport = null;
+ }
+ pendingLfAfterCr = false;
+ shellCompatBootstrapped = false;
+ ensureShellCompatibility = null;
+
+ offTransport?.();
+ offTransport = null;
+ settleCodexBootstrapGuardAsResult(false);
+ clearResumableState();
+
+ if (userInitiated) {
+ assertState("disconnected");
+ const appStore = useAppStore();
+ appStore.notify("info", "已断开连接");
+ }
+ if (userInitiated && reason === "manual") {
+ currentServerId.value = "";
+ }
+ persistSnapshotLater();
+ }
+
+ async function sendCommand(command: string, source: "manual" | "codex" | "plugin" = "manual", markerType: "manual" | "cd" | "check" | "run" = "manual"): Promise {
+ if (!transport || state.value !== "connected") {
+ throw new Error("会话未连接");
+ }
+
+ const startedAt = performance.now();
+ await transport.send(`${command}\r`);
+ const elapsed = Math.round(performance.now() - startedAt);
+ /**
+ * 不再在客户端本地追加“$ 命令”行:
+ * 1) 远端 shell 本身会回显命令;
+ * 2) 本地注入额外文本会打乱“终端显示状态”与“远端 shell 认知状态”的一致性,
+ * 在 zsh 下可能表现为每次回车后出现额外 `%` 行尾标记。
+ * 命令审计信息仍通过 logStore.addMarker 保留,不影响日志能力。
+ */
+
+ const logStore = useLogStore();
+ if (currentSessionId.value) {
+ await logStore.addMarker(currentSessionId.value, {
+ command,
+ source,
+ markerType,
+ code: 0,
+ elapsedMs: elapsed
+ });
+ }
+ }
+
+ async function sendInput(input: string, meta?: StdinMeta): Promise {
+ if (!transport || state.value !== "connected") {
+ throw new Error("会话未连接");
+ }
+ /**
+ * 仅在包含非 ASCII 字符时,再次确保 shell UTF-8 初始化已执行。
+ * 这样可以覆盖“初次 connected 时机过早,兼容命令尚未生效”的场景,
+ * 尤其是输入法空格选词触发 composition commit 的路径。
+ */
+ if (/[^\p{ASCII}]/u.test(input)) {
+ await ensureShellCompatibility?.();
+ }
+ await transport.send(normalizeEnter(input), meta);
+ }
+
+ async function runCodex(projectPath: string, sandbox: "read-only" | "workspace-write" | "danger-full-access"): Promise {
+ const plan = buildCodexPlan({
+ projectPath,
+ sandbox
+ });
+ const cdStep = plan.find((step) => step.step === "cd");
+ const runStep = plan.find((step) => step.step === "run");
+ if (!cdStep || !runStep) {
+ throw new Error("Codex 启动计划不完整");
+ }
+
+ const normalizedPath = String(projectPath || "~").trim() || "~";
+ const bootstrapResultPromise = startCodexBootstrapGuard(normalizedPath);
+
+ const bootstrapScript =
+ `__rc_codex_path_ok=1; __rc_codex_bin_ok=1; ${cdStep.command} >/dev/null 2>&1 || __rc_codex_path_ok=0; ` +
+ `command -v codex >/dev/null 2>&1 || __rc_codex_bin_ok=0; ` +
+ `[ "$__rc_codex_path_ok" -eq 1 ] || printf '${CODEX_BOOTSTRAP_TOKEN_DIR_MISSING}\\n'; ` +
+ `[ "$__rc_codex_bin_ok" -eq 1 ] || printf '${CODEX_BOOTSTRAP_TOKEN_CODEX_MISSING}\\n'; ` +
+ `if [ "$__rc_codex_path_ok" -eq 1 ] && [ "$__rc_codex_bin_ok" -eq 1 ]; then printf '${CODEX_BOOTSTRAP_TOKEN_READY}\\n'; ${runStep.command}; fi`;
+ /**
+ * 强制在 POSIX sh 下执行 bootstrap:
+ * - 远端默认 shell 可能是 csh/tcsh,`>/dev/null 2>&1` 等重定向语法会报
+ * “Ambiguous output redirect.”,导致 token 无法产出;
+ * - 统一走 `sh -lc`,让预检与 token 协议稳定可解析。
+ */
+ const bootstrapCommand = `sh -lc "${escapeForDoubleQuotedShellArg(bootstrapScript)}"`;
+
+ try {
+ await sendCommand(bootstrapCommand, "codex", "run");
+ } catch (error) {
+ settleCodexBootstrapGuardAsError(error instanceof Error ? error : new Error(String(error)));
+ throw error;
+ }
+
+ return await bootstrapResultPromise;
+ }
+
+ function clearTerminal(): void {
+ buffersByKey.value[activeConnectionKey.value] = createBufferBucket();
+ outputRevision.value += 1;
+ persistSnapshotLater();
+ }
+
+ async function resize(cols: number, rows: number): Promise {
+ if (!transport || state.value !== "connected") return;
+ await transport.resize(cols, rows);
+ }
+
+ function scheduleReconnect(server: ServerProfile): void {
+ reconnectAttempts.value += 1;
+ assertState("reconnecting");
+ persistSnapshotLater();
+ const delay = Math.min(5000, reconnectAttempts.value * 1200);
+ reconnectTimer = window.setTimeout(() => {
+ connect(server, true).catch((error) => {
+ const appStore = useAppStore();
+ appStore.notify("error", formatActionError("自动重连失败", error));
+ });
+ }, delay);
+ }
+
+ function cancelReconnect(reason = "route_leave"): void {
+ autoReconnectSuppressed = true;
+ if (reconnectTimer) {
+ window.clearTimeout(reconnectTimer);
+ reconnectTimer = null;
+ }
+
+ if (["reconnecting", "connecting", "auth_pending"].includes(state.value)) {
+ assertState("disconnected");
+ const appStore = useAppStore();
+ appStore.notify("info", `已停止自动重连:${reason}`);
+ persistSnapshotLater();
+ }
+ }
+
+ return {
+ ensureBootstrapped,
+ bootstrap,
+ state,
+ lines,
+ outputRevision,
+ latencyMs,
+ connected,
+ currentServerId,
+ isServerResumable,
+ connect,
+ disconnect,
+ sendInput,
+ sendCommand,
+ runCodex,
+ clearTerminal,
+ resize,
+ cancelReconnect
+ };
+});
diff --git a/pxterm/src/stores/settingsStore.ts b/pxterm/src/stores/settingsStore.ts
new file mode 100644
index 0000000..733682e
--- /dev/null
+++ b/pxterm/src/stores/settingsStore.ts
@@ -0,0 +1,91 @@
+import { defineStore } from "pinia";
+import { computed, ref } from "vue";
+import { verifyHostKey } from "@remoteconn/shared";
+import type { GlobalSettings } from "@/types/app";
+import { defaultSettings, normalizeGlobalSettings, resolveGatewayUrl, resolveGatewayToken } from "@/utils/defaults";
+import { getKnownHosts, getSettings, setSettings, upsertKnownHost } from "@/services/storage/db";
+
+/**
+ * 设置与主题管理。
+ */
+export const useSettingsStore = defineStore("settings", () => {
+ const settings = ref(normalizeGlobalSettings(defaultSettings));
+ const knownHosts = ref>({});
+ const loaded = ref(false);
+ let bootstrapPromise: Promise | null = null;
+
+ const themeVars = computed(() => ({
+ "--bg": settings.value.uiBgColor,
+ "--accent": settings.value.uiAccentColor,
+ "--text": settings.value.uiTextColor,
+ "--btn": settings.value.uiBtnColor,
+ "--shell-bg": settings.value.shellBgColor,
+ "--shell-text": settings.value.shellTextColor,
+ "--shell-accent": settings.value.shellAccentColor
+ }));
+
+ async function ensureBootstrapped(): Promise {
+ if (loaded.value) return;
+ if (bootstrapPromise) {
+ await bootstrapPromise;
+ return;
+ }
+ bootstrapPromise = (async () => {
+ settings.value = normalizeGlobalSettings(await getSettings());
+ knownHosts.value = await getKnownHosts();
+ loaded.value = true;
+ })();
+
+ try {
+ await bootstrapPromise;
+ } finally {
+ bootstrapPromise = null;
+ }
+ }
+
+ async function bootstrap(): Promise {
+ await ensureBootstrapped();
+ }
+
+ async function save(next: GlobalSettings): Promise {
+ const normalized = normalizeGlobalSettings(next);
+ settings.value = normalized;
+ await setSettings({ ...normalized });
+ }
+
+ /** 运行时推导网关 URL,不从持久化设置读取 */
+ const gatewayUrl = computed(() => resolveGatewayUrl(settings.value));
+ /** 运行时推导网关 Token,不从持久化设置读取 */
+ const gatewayToken = computed(() => resolveGatewayToken(settings.value));
+
+ async function verifyAndPersistHostFingerprint(hostPort: string, incomingFingerprint: string): Promise {
+ const result = await verifyHostKey({
+ hostPort,
+ incomingFingerprint,
+ policy: settings.value.hostKeyPolicy,
+ knownHosts: knownHosts.value,
+ onConfirm: async ({ hostPort: host, fingerprint, reason }) => {
+ return window.confirm(`${reason}\n主机: ${host}\n指纹: ${fingerprint}\n是否信任并继续?`);
+ }
+ });
+
+ if (result.accepted && result.updated[hostPort]) {
+ knownHosts.value = { ...result.updated };
+ await upsertKnownHost(hostPort, result.updated[hostPort]);
+ }
+
+ return result.accepted;
+ }
+
+ return {
+ settings,
+ knownHosts,
+ themeVars,
+ gatewayUrl,
+ gatewayToken,
+ ensureBootstrapped,
+ bootstrap,
+ save,
+ verifyAndPersistHostFingerprint
+ };
+});
diff --git a/pxterm/src/stores/voiceRecordStore.test.ts b/pxterm/src/stores/voiceRecordStore.test.ts
new file mode 100644
index 0000000..99bdce6
--- /dev/null
+++ b/pxterm/src/stores/voiceRecordStore.test.ts
@@ -0,0 +1,94 @@
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { createPinia, setActivePinia } from "pinia";
+import type { VoiceRecord } from "@/types/app";
+
+const { dbState, dbMock } = vi.hoisted(() => {
+ const state = {
+ voiceRecords: [] as VoiceRecord[]
+ };
+
+ const cloneRecord = (item: VoiceRecord): VoiceRecord => ({
+ ...item
+ });
+
+ const upsertRecord = (item: VoiceRecord): void => {
+ const index = state.voiceRecords.findIndex((row) => row.id === item.id);
+ if (index >= 0) {
+ state.voiceRecords[index] = cloneRecord(item);
+ } else {
+ state.voiceRecords.push(cloneRecord(item));
+ }
+ };
+
+ const db = {
+ voiceRecords: {
+ toArray: vi.fn(async () => state.voiceRecords.map((row) => cloneRecord(row))),
+ put: vi.fn(async (item: VoiceRecord) => {
+ upsertRecord(item);
+ }),
+ delete: vi.fn(async (id: string) => {
+ state.voiceRecords = state.voiceRecords.filter((row) => row.id !== id);
+ })
+ }
+ };
+
+ return {
+ dbState: state,
+ dbMock: db
+ };
+});
+
+vi.mock("@/services/storage/db", () => ({
+ db: dbMock
+}));
+
+import { useVoiceRecordStore } from "./voiceRecordStore";
+
+describe("voiceRecordStore", () => {
+ beforeEach(() => {
+ setActivePinia(createPinia());
+ dbState.voiceRecords = [];
+ dbMock.voiceRecords.toArray.mockClear();
+ dbMock.voiceRecords.put.mockClear();
+ dbMock.voiceRecords.delete.mockClear();
+ });
+
+ it("启动后按 createdAt 倒序输出 latest", async () => {
+ dbState.voiceRecords = [
+ { id: "r1", content: "one", createdAt: "2026-02-27T00:00:01.000Z", serverId: "s1" },
+ { id: "r2", content: "two", createdAt: "2026-02-27T00:00:03.000Z", serverId: "s1" },
+ { id: "r3", content: "three", createdAt: "2026-02-27T00:00:02.000Z", serverId: "s2" }
+ ];
+
+ const store = useVoiceRecordStore();
+ await store.ensureBootstrapped();
+
+ expect(store.latest.map((item) => item.id)).toEqual(["r2", "r3", "r1"]);
+ });
+
+ it("addRecord 写入前会 trim,空文本不入库", async () => {
+ const store = useVoiceRecordStore();
+ await store.ensureBootstrapped();
+
+ const empty = await store.addRecord(" ", "s1");
+ expect(empty).toBeNull();
+ expect(dbMock.voiceRecords.put).toHaveBeenCalledTimes(0);
+
+ const created = await store.addRecord(" hello world ", "s1");
+ expect(created).not.toBeNull();
+ expect(created?.content).toBe("hello world");
+ expect(dbMock.voiceRecords.put).toHaveBeenCalledTimes(1);
+ expect(store.latest[0]?.content).toBe("hello world");
+ });
+
+ it("removeRecord 会更新内存并持久化删除", async () => {
+ dbState.voiceRecords = [{ id: "r1", content: "one", createdAt: "2026-02-27T00:00:01.000Z", serverId: "s1" }];
+
+ const store = useVoiceRecordStore();
+ await store.ensureBootstrapped();
+
+ await store.removeRecord("r1");
+ expect(dbMock.voiceRecords.delete).toHaveBeenCalledWith("r1");
+ expect(store.records.length).toBe(0);
+ });
+});
diff --git a/pxterm/src/stores/voiceRecordStore.ts b/pxterm/src/stores/voiceRecordStore.ts
new file mode 100644
index 0000000..f45df7e
--- /dev/null
+++ b/pxterm/src/stores/voiceRecordStore.ts
@@ -0,0 +1,93 @@
+import { defineStore } from "pinia";
+import { computed, ref, toRaw } from "vue";
+import type { VoiceRecord } from "@/types/app";
+import { db } from "@/services/storage/db";
+import { nowIso } from "@/utils/time";
+
+/**
+ * 闪念记录存储与导出。
+ */
+export const useVoiceRecordStore = defineStore("voiceRecord", () => {
+ const records = ref([]);
+ const loaded = ref(false);
+ let bootstrapPromise: Promise | null = null;
+
+ const latest = computed(() => [...records.value].sort((a, b) => +new Date(b.createdAt) - +new Date(a.createdAt)));
+
+ async function ensureBootstrapped(): Promise {
+ if (loaded.value) return;
+ if (bootstrapPromise) {
+ await bootstrapPromise;
+ return;
+ }
+ bootstrapPromise = (async () => {
+ records.value = await db.voiceRecords.toArray();
+ loaded.value = true;
+ })();
+
+ try {
+ await bootstrapPromise;
+ } finally {
+ bootstrapPromise = null;
+ }
+ }
+
+ async function bootstrap(): Promise {
+ await ensureBootstrapped();
+ }
+
+ /**
+ * 统一做实体快照,避免 Vue Proxy 直接写入 IndexedDB 触发 DataCloneError。
+ */
+ function toVoiceRecordEntity(item: VoiceRecord): VoiceRecord {
+ const raw = toRaw(item);
+ return {
+ id: String(raw.id),
+ content: String(raw.content),
+ createdAt: String(raw.createdAt),
+ serverId: String(raw.serverId ?? "")
+ };
+ }
+
+ async function addRecord(content: string, serverId = ""): Promise {
+ const normalized = content.trim();
+ if (!normalized) {
+ return null;
+ }
+ const next: VoiceRecord = {
+ id: `voice-${crypto.randomUUID()}`,
+ content: normalized,
+ createdAt: nowIso(),
+ serverId: String(serverId || "")
+ };
+ records.value.unshift(next);
+ await db.voiceRecords.put(toVoiceRecordEntity(next));
+ return next;
+ }
+
+ async function removeRecord(recordId: string): Promise {
+ const nextId = String(recordId || "");
+ if (!nextId) return;
+ records.value = records.value.filter((item) => item.id !== nextId);
+ await db.voiceRecords.delete(nextId);
+ }
+
+ function exportRecords(): string {
+ const rows = latest.value.map((item) => {
+ return [`## ${item.id}`, `- createdAt: ${item.createdAt}`, `- serverId: ${item.serverId || "--"}`, `- content:`, item.content].join(
+ "\n"
+ );
+ });
+ return [`# RemoteConn Voice Records Export ${nowIso()}`, "", ...rows].join("\n\n");
+ }
+
+ return {
+ records,
+ latest,
+ ensureBootstrapped,
+ bootstrap,
+ addRecord,
+ removeRecord,
+ exportRecords
+ };
+});
diff --git a/pxterm/src/styles/main.css b/pxterm/src/styles/main.css
new file mode 100644
index 0000000..7d7744d
--- /dev/null
+++ b/pxterm/src/styles/main.css
@@ -0,0 +1,1760 @@
+:root {
+ --bg: #192b4d;
+ --shell-bg: #192b4d;
+ --shell-text: #e6f0ff;
+ --shell-accent: #9ca9bf;
+ --surface: rgba(20, 32, 56, 0.64);
+ --surface-border: rgba(118, 156, 213, 0.2);
+ --bottom-bar: #1b335d;
+ --text: #e6f0ff;
+ --muted: #9cb1cf;
+ --accent: #67d1ff;
+ --danger: #ff7f92;
+ --success: #79f3bd;
+ --app-viewport-width: 100vw;
+ --app-viewport-height: 100dvh;
+ --app-viewport-offset-top: 0px;
+ --app-viewport-offset-left: 0px;
+ --focus-scroll-margin-top: 25vh;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body,
+#app {
+ height: 100%;
+ margin: 0;
+ touch-action: manipulation;
+}
+
+body {
+ color: var(--text);
+ font-family: "PingFang SC", "SF Pro Text", "Microsoft YaHei", sans-serif;
+ background: var(--bg);
+ overflow: hidden;
+}
+
+h3,
+h4 {
+ margin: 0;
+ padding: 1em;
+}
+
+h4 {
+ border-bottom: 1px solid var(--surface-border);
+}
+
+button {
+ position: relative;
+ overflow: visible;
+}
+
+button::before {
+ content: "";
+ position: absolute;
+ inset: -8px;
+}
+
+.app-shell {
+ /**
+ * 让应用根容器始终与 visualViewport 矩形重合:
+ * - top/left 跟随 offset;
+ * - width/height 跟随可视区尺寸。
+ * 这样键盘弹出时,底部工具栏会稳定贴在键盘上沿。
+ */
+ position: fixed;
+ left: var(--app-viewport-offset-left);
+ top: var(--app-viewport-offset-top);
+ width: var(--app-viewport-width);
+ height: var(--app-viewport-height);
+ overflow: hidden;
+}
+
+.app-canvas {
+ width: 100%;
+ height: 100%;
+ background: var(--bg);
+ overflow: hidden;
+ display: grid;
+ grid-template-rows: minmax(0, 1fr) 52px;
+}
+
+.app-canvas.without-bottom-bar {
+ grid-template-rows: minmax(0, 1fr);
+}
+
+/* 输入态隐藏底部框架时,强制移除底部层级的任何残留占位/覆盖。 */
+.app-canvas.bottom-frame-hidden {
+ grid-template-rows: minmax(0, 1fr) !important;
+}
+
+.app-canvas.bottom-frame-hidden .screen-content {
+ min-height: 100%;
+}
+
+.screen-content {
+ min-height: 0;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.screen-content > *,
+.server-list-scroll {
+ flex: 1;
+ min-height: 0;
+}
+
+/* 软键盘输入态:scrollIntoView(start) 时把目标保留在上到下约 1/4 位置。 */
+.viewport-focus-target {
+ scroll-margin-top: var(--focus-scroll-margin-top);
+}
+
+.bottom-bar {
+ background: var(--bg);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 32px 0 16px;
+}
+
+.bottom-right-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 12px;
+}
+
+.icon-btn {
+ width: 24px;
+ height: 24px;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ opacity: 0.95;
+}
+
+.icon-btn:hover {
+ background: rgba(110, 154, 216, 0.2);
+}
+
+.icon-btn:disabled,
+.connect-icon-btn:disabled,
+.server-tag-order-btn:disabled,
+.terminal-connection-switch:disabled {
+ opacity: 0.45;
+ cursor: not-allowed;
+}
+
+/* CSS mask icon: 用 --btn 变量染色 SVG 图标 */
+.icon-mask {
+ display: block;
+ width: 22px;
+ height: 22px;
+ background-color: var(--btn);
+ -webkit-mask: var(--icon) no-repeat center / contain;
+ mask: var(--icon) no-repeat center / contain;
+ flex-shrink: 0;
+}
+
+.bottom-nav-btn.active {
+ background: rgba(103, 209, 255, 0.2);
+}
+
+.page-root {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ height: 100%;
+ gap: 0;
+ padding-bottom: 0;
+}
+
+.page-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ min-height: 56px;
+ height: 56px;
+ padding: 0 16px;
+}
+
+.page-toolbar .toolbar-left,
+.page-toolbar .toolbar-right,
+.server-row-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.page-toolbar .toolbar-spacer {
+ flex: 1;
+}
+
+.settings-save-status {
+ font-size: 12px;
+ color: color-mix(in srgb, var(--text) 65%, transparent);
+}
+
+.page-title,
+.server-settings-section-title,
+.server-name {
+ margin: 0;
+ font-size: 16px;
+ line-height: 1;
+ font-weight: 600;
+ color: var(--text);
+}
+
+.page-panels {
+ flex: 1;
+ min-height: 0;
+ display: grid;
+ gap: 16px;
+}
+
+.page-panels.connect-panels {
+ grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
+}
+
+.surface-panel {
+ background: var(--surface);
+ border-top: 1px solid var(--surface-border);
+ border-bottom: 1px solid var(--surface-border);
+ padding: 0 12px 10px;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+ overflow: hidden;
+}
+
+/* 60:45 / 269:881 / 269:916 / 269:1261:页面主体为纯底色,不使用半透明面板。 */
+.settings-page .surface-panel,
+.logs-page .surface-panel,
+.plugins-page .surface-panel,
+.server-settings-page .surface-panel,
+.records-page .surface-panel {
+ background: var(--bg);
+ border-top-color: transparent;
+ border-bottom-color: transparent;
+}
+
+.surface-scroll {
+ min-height: 0;
+ overflow: auto;
+}
+
+.actions {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ align-items: center;
+}
+
+.btn {
+ border: 1px solid rgba(141, 187, 255, 0.4);
+ background: rgba(255, 255, 255, 0.03);
+ color: var(--text);
+ border-radius: 10px;
+ padding: 6px 10px;
+ cursor: pointer;
+}
+
+.btn.primary {
+ background: color-mix(in srgb, var(--btn) 30%, transparent);
+ border-color: color-mix(in srgb, var(--btn) 85%, transparent);
+}
+
+.btn.danger {
+ border-color: rgba(255, 122, 151, 0.6);
+ background: rgba(255, 122, 151, 0.16);
+}
+
+.input,
+.textarea,
+select.input {
+ width: 100%;
+ border-radius: 10px;
+ border: 1px solid rgba(141, 187, 255, 0.3);
+ background: color-mix(in srgb, var(--text) 30%, transparent);
+ color: var(--text);
+ padding: 8px 10px;
+}
+
+.input, select.input {
+ height: 32px;
+}
+
+.field-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 10px;
+}
+
+.field.wide {
+ grid-column: span 2;
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ font-size: 13px;
+}
+
+.field > span {
+ color: color-mix(in srgb, var(--text) 65%, transparent);
+}
+
+.list-stack {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-height: 0;
+ overflow: auto;
+}
+
+.list-item,
+.log-item,
+.plugin-item {
+ text-align: left;
+ border: 1px solid rgba(141, 187, 255, 0.25);
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 10px;
+ padding: 10px;
+}
+
+.list-item.active {
+ border-color: rgba(103, 209, 255, 0.8);
+}
+
+.records-page {
+ gap: 0;
+ padding-bottom: 0;
+}
+
+.records-panel {
+ padding: 0 12px 12px;
+}
+
+.records-actions {
+ justify-content: space-between;
+}
+
+.records-list-scroll {
+ flex: 1;
+ min-height: 0;
+}
+
+.records-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.records-item-shell {
+ position: relative;
+ overflow: hidden;
+ border-radius: 10px;
+}
+
+.records-item-delete-mobile {
+ position: absolute;
+ right: 0;
+ top: 0;
+ bottom: 0;
+ width: 72px;
+ border: 0;
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--accent) 28%, transparent);
+ color: var(--bg);
+ font-size: 12px;
+ line-height: 1;
+ cursor: pointer;
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.12s ease;
+}
+
+.records-item-shell-delete-visible .records-item-delete-mobile {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.records-item {
+ position: relative;
+ border: 1px solid color-mix(in srgb, var(--text) 22%, transparent);
+ background: color-mix(in srgb, var(--text) 5%, transparent);
+ border-radius: 10px;
+ padding: 10px;
+ transform: translateX(0);
+ transition: transform 0.16s ease;
+}
+
+.records-item-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ margin-bottom: 6px;
+}
+
+.records-item-time {
+ font-size: 12px;
+ color: color-mix(in srgb, var(--text) 70%, transparent);
+}
+
+.records-item-content {
+ margin: 0;
+ font-size: 13px;
+ line-height: 1.5;
+ color: var(--text);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.records-empty-tip {
+ margin: 0;
+ color: var(--muted);
+ font-size: 14px;
+ text-align: center;
+ padding: 16px 8px;
+}
+
+.records-pagination {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ padding-top: 8px;
+}
+
+.records-pagination-text {
+ min-width: 108px;
+ text-align: center;
+ font-size: 12px;
+ color: color-mix(in srgb, var(--text) 70%, transparent);
+}
+
+.item-selectors,
+.server-tag-order-actions {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+}
+
+.item-selectors input {
+ accent-color: var(--accent);
+}
+
+/* 32:52 服务器管理页:顶部工具栏 + 搜索框 + 单层服务器列表。 */
+.server-manager-page,
+.server-settings-page {
+ gap: 0;
+ padding-bottom: 0;
+}
+
+/* 服务器管理页文本禁止选择,避免拖拽与长按时出现选中文字高亮。 */
+.server-manager-page,
+.server-manager-page * {
+ -webkit-user-select: none;
+ user-select: none;
+}
+
+.server-manager-toolbar,
+.server-settings-topbar {
+ flex: 0 0 auto;
+ min-height: 56px;
+}
+
+.server-manager-content {
+ flex: 1;
+ min-height: 0;
+ padding: 0 16px 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+ overflow: hidden;
+}
+
+.server-search-wrap {
+ flex: 0 0 auto;
+ padding: 0 0 16px;
+}
+
+.server-search-shell {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) 34.4px;
+ align-items: center;
+ width: 100%;
+ height: 32px;
+ border: 0.8px solid #c5c5c7;
+ border-radius: 27.2px;
+ overflow: hidden;
+}
+
+.server-search-input {
+ width: 100%;
+ height: 100%;
+ border: 0;
+ border-radius: 0;
+ background: transparent;
+ color: var(--text);
+ font-size: 11.2px;
+ line-height: normal;
+ padding: 0 8px;
+}
+
+.server-search-input::placeholder {
+ color: #c5c5c7;
+}
+
+.server-search-input:focus {
+ outline: none;
+}
+
+.server-search-btn {
+ width: 100%;
+ height: 100%;
+ border: 0;
+ border-left: 0.8px solid #c5c5c7;
+ border-radius: 0 27.2px 27.2px 0;
+ background: var(--btn);
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+}
+
+.server-search-btn .icon-mask {
+ width: 14px;
+ height: 14px;
+ background-color: var(--bg);
+}
+
+.server-list-stack {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+.server-list-row {
+ display: grid;
+ grid-template-columns: 16px minmax(0, 1fr);
+ gap: 8px;
+ align-items: start;
+ padding-bottom: 16px;
+ border-bottom: 0.5px solid rgba(141, 187, 255, 0.35);
+ position: relative;
+ transition: transform 0.18s ease, box-shadow 0.18s ease, background-color 0.18s ease;
+}
+
+.server-list-row.is-drag-over {
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
+ border-bottom-color: color-mix(in srgb, var(--accent) 72%, transparent);
+}
+
+.server-list-row.is-dragging {
+ z-index: 8;
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--surface) 85%, var(--accent) 15%);
+ box-shadow:
+ 0 16px 36px color-mix(in srgb, #000000 34%, transparent),
+ inset 0 0 0 1px color-mix(in srgb, var(--accent) 68%, transparent);
+ transform: translate3d(var(--drag-x, 0px), var(--drag-y, 0px), 0) scale(1.03);
+ pointer-events: none;
+}
+
+.server-row-check {
+ width: 16px;
+ height: 16px;
+ margin-top: 1px;
+}
+
+.server-check-input {
+ appearance: none;
+ width: 14px;
+ height: 14px;
+ margin: 0;
+ border: 1px solid #ffffff;
+ border-radius: 4px;
+ background: #ffffff;
+ position: relative;
+ display: inline-block;
+ cursor: pointer;
+}
+
+.server-check-input:checked {
+ border-color: #5bd2ff;
+ background: #5bd2ff;
+}
+
+.server-check-input:checked::after {
+ content: "";
+ position: absolute;
+ left: 5px;
+ top: 2px;
+ width: 3px;
+ height: 7px;
+ border: solid #ffffff;
+ border-width: 0 2px 2px 0;
+ transform: rotate(45deg);
+}
+
+.server-info {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ min-width: 0;
+}
+
+.server-info-clickable {
+ cursor: pointer;
+}
+
+.server-info-top {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding-right: 0;
+}
+
+/* connect 与 move 的间距在基础 8px 上增加约 1/3(8 -> 11)。 */
+.server-row-actions .connect-icon-btn + .server-move-btn {
+ margin-left: 3px;
+}
+
+.server-ai-btn,
+.server-move-btn,
+.connect-icon-btn {
+ width: 22px;
+ height: 22px;
+ border: 0;
+ background: transparent;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: background-color 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease;
+}
+
+.server-ai-icon {
+ width: 22px;
+ height: 22px;
+ background-color: var(--btn);
+}
+
+.server-move-btn {
+ border-radius: 6px;
+ cursor: grab;
+ touch-action: none;
+ user-select: none;
+ -webkit-user-select: none;
+}
+
+.server-move-btn:active:not(:disabled) {
+ cursor: grabbing;
+}
+
+.server-move-btn .icon-mask {
+ width: 8px;
+ height: 14px;
+ background-color: var(--accent);
+}
+
+.server-move-btn:hover:not(:disabled) {
+ background: color-mix(in srgb, var(--accent) 16%, transparent);
+}
+
+.server-ai-btn:disabled,
+.server-move-btn:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.connect-icon-btn {
+ border-radius: 50%;
+}
+
+.connect-icon-btn.is-connected {
+ background: color-mix(in srgb, var(--accent) 28%, transparent);
+ box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 65%, transparent);
+}
+
+.connect-icon-btn.is-connected .icon-mask {
+ background-color: var(--accent);
+}
+
+.server-info-meta {
+ display: flex;
+ align-items: center;
+ justify-content: flex-start;
+ gap: 15px;
+ color: var(--text);
+}
+
+.server-main {
+ margin: 0;
+ font-size: 14px;
+ line-height: 1;
+ width: 140px;
+ max-width: 100%;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.server-auth {
+ margin: 0;
+ font-size: 12px;
+ line-height: 1;
+ opacity: 0.95;
+}
+
+.server-recent {
+ margin: 0;
+ color: var(--text);
+ font-size: 14px;
+ line-height: 1;
+}
+
+.server-tags {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ overflow: hidden;
+}
+
+.server-tag {
+ height: 16px;
+ padding: 0 6px;
+ border-radius: 8px;
+ background: rgba(91, 210, 255, 0.24);
+ color: var(--text);
+ font-size: 10px;
+ line-height: 16px;
+ white-space: nowrap;
+}
+
+.server-empty-tip {
+ margin: 0;
+ font-size: 14px;
+ color: var(--muted);
+}
+
+.server-tag-order-list {
+ margin-top: 8px;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.server-tag-order-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+ min-height: 26px;
+ padding: 4px 8px;
+ border: 1px solid color-mix(in srgb, var(--surface-border) 80%, transparent);
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--surface) 55%, transparent);
+}
+
+.server-tag-order-text {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ font-size: 12px;
+ color: var(--text);
+}
+
+.server-tag-order-btn {
+ border: 1px solid color-mix(in srgb, var(--surface-border) 85%, transparent);
+ border-radius: 6px;
+ background: color-mix(in srgb, var(--bg) 45%, transparent);
+ color: var(--text);
+ font-size: 11px;
+ line-height: 1;
+ padding: 4px 7px;
+ cursor: pointer;
+}
+
+.server-settings-bottom {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 32px 0 16px;
+ height: 52px;
+ flex: 0 0 auto;
+}
+
+.server-settings-bottom .bottom-right-actions {
+ gap: 12px;
+}
+
+.server-settings-layout {
+ flex: 1;
+ min-height: 0;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding-bottom: 0;
+}
+
+.server-settings-content {
+ flex: 1;
+ min-height: 0;
+ width: 100%;
+ padding: 0 16px;
+}
+
+.server-settings-form {
+ min-height: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ padding-bottom: 16px;
+}
+
+.item-title {
+ font-weight: 600;
+ margin-bottom: 0;
+}
+
+.item-sub {
+ font-size: 12px;
+ opacity: 0.9;
+}
+
+/* 终端页面去掉 page-root 的通用 gap,让工具栏与终端区域无缝衔接 */
+.terminal-page {
+ gap: 0;
+}
+
+.terminal-surface {
+ flex: 1;
+ min-height: 0;
+ background: transparent;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.terminal-card {
+ display: grid;
+ grid-template-rows: 1fr auto;
+ gap: 0;
+ height: 100%;
+ background: transparent;
+ border-top-color: transparent;
+ border-bottom-color: transparent;
+ padding: 0;
+}
+
+.terminal-loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 0;
+ height: 100%;
+ font-size: 14px;
+ color: var(--muted);
+ user-select: none;
+}
+
+.terminal-wrapper {
+ position: relative;
+ min-height: 0;
+ border: 0;
+ border-radius: 0;
+ background: rgba(0, 0, 0, 0);
+ /*
+ * xterm 会在右侧预留滚动条槽位。
+ * 为保证左右“视觉等效 8px”:
+ * - 左侧用容器 padding-left: 8px;
+ * - 右侧不再额外加容器 padding,交由 xterm 的滚动槽位承担。
+ */
+ padding: 0 0 0 8px;
+ overflow: hidden;
+}
+
+.terminal-disconnected-hint {
+ position: absolute;
+ left: 50%;
+ bottom: calc(10px + env(safe-area-inset-bottom, 0px));
+ transform: translateX(-50%);
+ z-index: 3;
+ max-width: calc(100% - 24px);
+ margin: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ pointer-events: none;
+ user-select: none;
+ font-size: 12px;
+ line-height: 1.3;
+ color: var(--bg);
+ border-radius: 8px;
+ padding: 4px;
+ /* 文案反色:文字=背景色,底框=文字色,透明度 80%。 */
+ background: color-mix(in srgb, var(--text) 80%, transparent);
+}
+
+.terminal-touch-tools {
+ position: absolute;
+ right: 12px;
+ bottom: 0;
+ width: 22px;
+ height: 22px;
+ padding: 0;
+ box-sizing: border-box;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 12px;
+ z-index: 4;
+ opacity: 0.8;
+}
+
+.terminal-touch-tools.is-expanded {
+ width: 97px;
+ /*
+ * 展开态工具项新增“粘贴”按钮后,总高度增加了 24px 按钮 + 12px 间距。
+ * 若仍为 205px,会把底部键盘切换按钮挤出可视区,导致“展开后看不到键盘按钮”。
+ */
+ height: 241px;
+ padding: 10px 12px 5px 9px;
+}
+
+.terminal-touch-tools-body {
+ width: 76px;
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 12px;
+}
+
+.terminal-touch-arrows {
+ width: 76px;
+ height: 48px;
+ position: relative;
+}
+
+.terminal-touch-arrows .icon-mask {
+ width: 100%;
+ height: 100%;
+ display: block;
+}
+
+.terminal-touch-arrows .icon-mask {
+ background-color: var(--shell-accent);
+}
+
+/* 方向键命中区覆盖在 Figma 图标上,保持视觉与交互一致。 */
+.terminal-touch-arrow {
+ position: absolute;
+ width: 20px;
+ height: 20px;
+ border: 0;
+ border-radius: 50%;
+ background: transparent;
+ padding: 0;
+ touch-action: manipulation;
+}
+
+.terminal-touch-arrow-up {
+ left: 28px;
+ top: 0;
+}
+
+.terminal-touch-arrow-right {
+ left: 50px;
+ top: 14px;
+}
+
+.terminal-touch-arrow-left {
+ left: 6px;
+ top: 14px;
+}
+
+.terminal-touch-arrow-down {
+ left: 28px;
+ top: 28px;
+}
+
+.terminal-touch-shortcut-btn,
+.terminal-touch-enter-btn,
+.terminal-touch-paste-btn {
+ width: 24px;
+ height: 24px;
+ border: 0;
+ border-radius: 6px;
+ /* 终端工具栏按钮统一使用终端强调色,确保和终端主题一致。 */
+ background: color-mix(in srgb, var(--shell-accent) 35%, transparent);
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ touch-action: manipulation;
+}
+
+.terminal-touch-shortcut-btn {
+ flex-direction: column;
+ color: var(--shell-accent);
+ font-family: "Play", "SF Pro Text", "PingFang SC", sans-serif;
+ font-size: 10px;
+ line-height: 0.8;
+}
+
+/* enter / paste 图标统一走 mask 染色,和终端强调色联动。 */
+.terminal-touch-icon {
+ background-color: var(--shell-accent);
+}
+
+.terminal-touch-shortcut-line {
+ display: block;
+ line-height: 0.8;
+}
+
+.terminal-touch-tab-btn {
+ font-size: 10px;
+}
+
+.terminal-touch-toggle-btn {
+ width: 22px;
+ height: 22px;
+ border: 0;
+ background: transparent;
+ padding: 0;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ touch-action: manipulation;
+ color: var(--shell-accent);
+}
+
+.terminal-touch-toggle-icon {
+ width: 22px;
+ height: 22px;
+ display: block;
+ background-color: currentcolor;
+ -webkit-mask: url("/icons/keyboard.svg") no-repeat center / contain;
+ mask: url("/icons/keyboard.svg") no-repeat center / contain;
+}
+
+.terminal-voice-layer {
+ position: absolute;
+ inset: 0;
+ z-index: 5;
+ pointer-events: none;
+ -webkit-user-select: none;
+ user-select: none;
+ -webkit-touch-callout: none;
+}
+
+.terminal-voice-hitbox {
+ position: absolute;
+ pointer-events: auto;
+ background: transparent;
+ z-index: 0;
+ touch-action: none;
+ -webkit-user-select: none;
+ user-select: none;
+ -webkit-touch-callout: none;
+}
+
+.terminal-voice-button {
+ position: absolute;
+ width: 28px;
+ height: 36px;
+ border: 0;
+ padding: 0;
+ background: transparent;
+ color: var(--shell-accent);
+ pointer-events: auto;
+ touch-action: none;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: grab;
+ opacity: 0.95;
+ filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.28));
+ transition: color 0.16s ease, opacity 0.16s ease, transform 0.16s ease;
+ z-index: 2;
+}
+
+.terminal-voice-button.panel-visible {
+ color: var(--shell-bg);
+ opacity: 1;
+}
+
+.terminal-voice-button::before {
+ /*
+ * 折叠态仅保护 voice.svg 本体,避免把周边 shell 区域也变成“不可点击保护区”。
+ * 这里覆盖全局 button::before 的命中扩展,把语音按钮命中区收回到自身尺寸。
+ */
+ inset: 0;
+}
+
+.terminal-voice-button:active {
+ cursor: grabbing;
+}
+
+.terminal-voice-button.is-recording:not(.panel-visible) {
+ color: color-mix(in srgb, var(--shell-accent) 92%, #ffffff 8%);
+ opacity: 1;
+}
+
+.terminal-voice-button-icon {
+ width: 27px;
+ height: 36px;
+ display: block;
+ opacity: 0.9;
+ background-color: currentcolor;
+ -webkit-mask: url("/assets/icons/voice.svg") no-repeat center / contain;
+ mask: url("/assets/icons/voice.svg") no-repeat center / contain;
+}
+
+.terminal-voice-panel {
+ position: absolute;
+ width: 408px;
+ height: 220px;
+ box-sizing: border-box;
+ pointer-events: auto;
+ background: transparent;
+ z-index: 1;
+}
+
+.terminal-voice-frame2256-bg {
+ position: absolute;
+ border-radius: 16px;
+ background: var(--shell-text);
+ opacity: 0.4;
+ pointer-events: none;
+ z-index: 1;
+}
+
+.terminal-voice-panel.is-recording .terminal-voice-frame2256-bg {
+ opacity: 1;
+}
+
+.terminal-voice-input-wrap {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: var(--voice-input-height, 156px);
+ border-radius: 16px;
+ overflow: hidden;
+
+ background: #fff;
+ box-shadow:
+ 0 10px 20px rgba(0, 0, 0, 0.2),
+ inset 0 0 0 1px rgba(255, 255, 255, 0.65);
+ z-index: 2;
+}
+
+.terminal-voice-input-wrap::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ border-radius: inherit;
+ pointer-events: none;
+ opacity: 0;
+ transition: opacity 0.18s ease;
+}
+
+.terminal-voice-panel.is-recording .terminal-voice-input-wrap {
+ box-shadow:
+ 0 12px 24px rgba(0, 0, 0, 0.22),
+ 0 0 24px color-mix(in srgb, var(--accent) 48%, rgba(255, 255, 255, 0.38)),
+ inset 0 1px 0 rgba(255, 255, 255, 0.9),
+ inset 0 -12px 28px color-mix(in srgb, var(--accent) 28%, rgba(255, 255, 255, 0.9));
+ animation: terminal-voice-liquid-border-pulse 1.2s ease-in-out infinite;
+}
+
+.terminal-voice-panel.is-recording .terminal-voice-input-wrap::before {
+ opacity: 1;
+ inset: 0;
+ border: 2px solid color-mix(in srgb, var(--accent) 72%, #ffffff 28%);
+ box-shadow:
+ 0 0 16px color-mix(in srgb, var(--accent) 48%, rgba(255, 255, 255, 0.38)),
+ inset 0 0 12px color-mix(in srgb, var(--accent) 22%, rgba(255, 255, 255, 0.52));
+}
+
+@keyframes terminal-voice-liquid-border-pulse {
+ 0% {
+ transform: translateZ(0) scale(1);
+ }
+ 50% {
+ transform: translateZ(0) scale(1.006);
+ }
+ 100% {
+ transform: translateZ(0) scale(1);
+ }
+}
+
+.terminal-voice-input {
+ position: absolute;
+ inset: 0;
+ width: 100%;
+ height: 100%;
+ min-height: 100%;
+ border: 0;
+ resize: none;
+ outline: none;
+ padding: 14px 16px;
+ box-sizing: border-box;
+ border-radius: 16px;
+ background: #fff;
+ color: #4e5969;
+ font-size: 14px;
+ line-height: 1.4;
+ font-family: "SFMono-Regular", Menlo, Monaco, Consolas, "Liberation Mono", monospace;
+ -webkit-user-select: text;
+ user-select: text;
+ -webkit-touch-callout: default;
+}
+
+.terminal-voice-input::placeholder {
+ color: rgba(78, 89, 105, 0.6);
+}
+
+.terminal-voice-input:read-only {
+ opacity: 0.86;
+}
+
+.terminal-voice-actions {
+ position: absolute;
+ left: 0;
+ right: 0;
+ top: var(--voice-actions-top, 164px);
+ height: 24px;
+ z-index: 3;
+}
+
+.terminal-voice-actions-left,
+.terminal-voice-actions-right {
+ position: absolute;
+ top: 0;
+ display: inline-flex;
+ align-items: center;
+}
+
+.terminal-voice-actions-left {
+ gap: 15px;
+}
+
+.terminal-voice-actions-right {
+ right: 0;
+ width: 62px;
+ justify-content: space-between;
+}
+
+.terminal-voice-action-btn {
+ min-width: 24px;
+ height: 24px;
+ border: 0;
+ padding: 0;
+ background: transparent;
+ color: var(--shell-bg);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ touch-action: manipulation;
+ opacity: 1;
+ transition: opacity 0.16s ease, color 0.16s ease;
+}
+
+.terminal-voice-action-btn::before {
+ inset: -10px;
+}
+
+.terminal-voice-action-btn:disabled {
+ opacity: 0.42;
+ cursor: not-allowed;
+}
+
+.terminal-voice-action-btn:not(:disabled):hover {
+ color: var(--shell-bg);
+}
+
+.terminal-voice-action-icon {
+ display: block;
+ background-color: currentcolor;
+ -webkit-mask-repeat: no-repeat;
+ mask-repeat: no-repeat;
+ -webkit-mask-position: center;
+ mask-position: center;
+ -webkit-mask-size: contain;
+ mask-size: contain;
+}
+
+.terminal-voice-action-icon-clear {
+ width: 22px;
+ height: 24px;
+ -webkit-mask-image: url("/assets/icons/clear-input.svg");
+ mask-image: url("/assets/icons/clear-input.svg");
+}
+
+.terminal-voice-action-icon-cancel {
+ width: 24px;
+ height: 24px;
+ -webkit-mask-image: url("/assets/icons/cancel.svg");
+ mask-image: url("/assets/icons/cancel.svg");
+}
+
+.terminal-voice-action-icon-record {
+ width: 24px;
+ height: 24px;
+ -webkit-mask-image: url("/assets/icons/record.svg?v=20260227-3");
+ mask-image: url("/assets/icons/record.svg?v=20260227-3");
+}
+
+.terminal-voice-action-icon-send {
+ width: 24px;
+ height: 24px;
+ -webkit-mask-image: url("/assets/icons/sent.svg");
+ mask-image: url("/assets/icons/sent.svg");
+}
+
+.terminal-voice-arrow {
+ position: absolute;
+ left: var(--voice-arrow-left, 26px);
+ top: var(--voice-arrow-top, 141px);
+ width: 20px;
+ height: 20px;
+ box-sizing: border-box;
+ border: 1px solid transparent;
+ background: #fff;
+ transform: rotate(45deg);
+ pointer-events: none;
+ z-index: 1;
+}
+
+.terminal-container {
+ width: 100%;
+ height: 100%;
+ /* 移动端性能优化 */
+ -webkit-transform: translateZ(0);
+ transform: translateZ(0);
+ will-change: scroll-position;
+}
+
+/* 触屏端启用原生文本选择:支持 iOS 长按工具条与双端拖拽手柄。 */
+.terminal-container.native-touch-selection .xterm,
+.terminal-container.native-touch-selection .xterm * {
+ -webkit-user-select: text;
+ user-select: text;
+ -webkit-touch-callout: default;
+}
+
+/* 移动端字体渲染优化 */
+@media (pointer: coarse) {
+ .terminal-container .xterm {
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ text-rendering: optimizeSpeed;
+ }
+}
+
+/* 强制保持 xterm helper textarea 为隐藏输入锚点,避免浏览器把它渲染成左上角可见文本框。 */
+.terminal-wrapper .xterm .xterm-helper-textarea {
+ position: fixed !important;
+ left: -9999px !important;
+ top: 0 !important;
+ width: 1px !important;
+ height: 1px !important;
+ opacity: 0 !important;
+ color: transparent !important;
+ caret-color: transparent !important;
+ border: 0 !important;
+ padding: 0 !important;
+ margin: 0 !important;
+ resize: none !important;
+}
+
+/* 终端滚动条默认隐藏,仅在鼠标悬停或聚焦终端时显示。 */
+.terminal-wrapper .xterm .xterm-viewport {
+ overflow-y: auto !important;
+ scrollbar-width: thin;
+ scrollbar-color: transparent transparent;
+ -webkit-overflow-scrolling: touch;
+ overscroll-behavior: contain;
+}
+
+.terminal-wrapper .xterm .xterm-viewport::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+.terminal-wrapper .xterm .xterm-viewport::-webkit-scrollbar-thumb {
+ border-radius: 999px;
+ background: transparent;
+}
+
+.terminal-wrapper:hover .xterm .xterm-viewport,
+.terminal-wrapper:focus-within .xterm .xterm-viewport {
+ scrollbar-color: rgba(141, 187, 255, 0.45) transparent;
+}
+
+.terminal-wrapper:hover .xterm .xterm-viewport::-webkit-scrollbar-thumb,
+.terminal-wrapper:focus-within .xterm .xterm-viewport::-webkit-scrollbar-thumb {
+ background: rgba(141, 187, 255, 0.45);
+}
+
+.plugin-chips {
+ display: flex;
+ gap: 8px;
+ flex-wrap: wrap;
+ padding: 8px 12px 0;
+}
+
+.terminal-toolbar {
+ gap: 12px;
+}
+
+.terminal-toolbar-actions {
+ display: inline-flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 8px;
+ min-width: 0;
+}
+
+.terminal-title {
+ max-width: 88px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.terminal-toolbar-actions .state-chip {
+ white-space: nowrap;
+ padding: 2px 8px;
+ font-size: 10px;
+}
+
+.terminal-connection-switch {
+ height: 20px;
+ min-width: 56px;
+ border: 0;
+ border-radius: 999px;
+ padding: 0 6px 0 5px;
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ overflow: hidden;
+ font-size: 11px;
+ line-height: 1;
+ color: #ffffff;
+ cursor: pointer;
+ transition:
+ background-color 0.18s ease,
+ color 0.18s ease;
+}
+
+.terminal-connection-switch-label {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0 15px 0 0;
+ white-space: nowrap;
+ position: relative;
+ z-index: 1;
+ pointer-events: none;
+ transition: padding 0.18s ease;
+}
+
+.terminal-connection-switch-knob {
+ width: 14px;
+ height: 14px;
+ border-radius: 50%;
+ background: #ffffff;
+ position: absolute;
+ top: 3px;
+ left: 3px;
+ z-index: 2;
+ transition: left 0.18s ease;
+}
+
+.terminal-connection-switch.is-disconnect {
+ background: #00b42a;
+}
+
+.terminal-connection-switch.is-disconnect .terminal-connection-switch-knob {
+ left: calc(100% - 17px);
+}
+
+.terminal-connection-switch.is-reconnect {
+ background: rgba(91, 210, 255, 0.24);
+ color: #e6f0ff;
+}
+
+.terminal-connection-switch.is-reconnect .terminal-connection-switch-label {
+ padding: 0 0 0 15px;
+}
+
+.terminal-toolbar-divider {
+ width: 1px;
+ height: 16px;
+ background: rgba(141, 187, 255, 0.55);
+}
+
+.plugin-chip {
+ border: 1px solid rgba(141, 187, 255, 0.3);
+ background: rgba(255, 255, 255, 0.03);
+ color: var(--btn);
+ border-radius: 999px;
+ padding: 6px 12px;
+ cursor: pointer;
+}
+
+/* Settings tab 按钮 */
+.settings-tabs {
+ display: flex;
+ gap: 4px;
+ padding: 0 16px;
+ flex-shrink: 0;
+}
+
+.settings-tab-btn {
+ border: 1px solid rgba(141, 187, 255, 0.25);
+ background: transparent;
+ color: var(--text);
+ border-radius: 8px;
+ padding: 5px 12px;
+ font-size: 13px;
+ cursor: pointer;
+ opacity: 0.75;
+ transition: opacity 0.15s, background 0.15s, color 0.15s;
+}
+
+.settings-tab-btn.active {
+ background: color-mix(in srgb, var(--btn) 22%, transparent);
+ border-color: color-mix(in srgb, var(--btn) 70%, transparent);
+ color: var(--btn);
+ opacity: 1;
+}
+
+.shell-style-preview {
+ margin-bottom: 10px;
+ display: block;
+ flex: 0 0 auto;
+ min-height: 0;
+ font-variant-ligatures: none;
+ /* 当终端背景与页面背景接近时,用极轻纹理与内阴影保留“面板存在感”。 */
+ background-image:
+ linear-gradient(to bottom, rgb(255 255 255 / 0.025), rgb(255 255 255 / 0)),
+ repeating-linear-gradient(
+ to bottom,
+ rgb(255 255 255 / 0.012) 0,
+ rgb(255 255 255 / 0.012) 1px,
+ transparent 1px,
+ transparent 24px
+ );
+ box-shadow: inset 0 0 0 1px rgb(0 0 0 / 0.12);
+}
+
+.shell-style-preview-content {
+ margin: 0;
+ font-family: inherit;
+ font-size: inherit;
+ line-height: inherit;
+ color: inherit;
+ white-space: pre-wrap;
+ word-break: break-word;
+ overflow-wrap: anywhere;
+ overflow: visible;
+ text-overflow: clip;
+}
+
+.codex-dialog-mask {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.48);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 16px;
+ z-index: 30;
+}
+
+.codex-dialog {
+ width: min(420px, calc(100vw - 32px));
+ border: 1px solid rgba(141, 187, 255, 0.35);
+ border-radius: 12px;
+ background: rgba(8, 18, 32, 0.95);
+ box-shadow: 0 18px 48px rgba(0, 0, 0, 0.35);
+ padding: 14px;
+ display: flex;
+ flex-direction: column;
+ gap: 10px;
+}
+
+.codex-dialog-title {
+ border-bottom: 0;
+ margin: 0;
+ font-size: 16px;
+ line-height: 1.2;
+ font-weight: 600;
+ color: var(--text);
+}
+
+.codex-dialog-hint {
+ margin: 0;
+ font-size: 12px;
+ opacity: 0.82;
+}
+
+.ai-launch-card {
+ border: 1px solid rgba(141, 187, 255, 0.24);
+ border-radius: 10px;
+ padding: 10px;
+ background: rgba(10, 24, 42, 0.6);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.ai-launch-card-title {
+ margin: 0;
+ font-size: 14px;
+ line-height: 1.25;
+ font-weight: 600;
+}
+
+.ai-launch-actions {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.ai-launch-actions .btn {
+ flex: 1 1 160px;
+}
+
+.codex-dialog-actions {
+ display: flex;
+ justify-content: center;
+ gap: 8px;
+}
+
+.log-box {
+ min-height: 0;
+ max-height: 220px;
+ overflow: auto;
+ background: rgba(8, 18, 32, 0.65);
+ border: 1px solid rgba(141, 187, 255, 0.25);
+ border-radius: 10px;
+ padding: 10px;
+ white-space: pre-wrap;
+}
+
+.state-chip {
+ border-radius: 999px;
+ padding: 4px 10px;
+ border: 1px solid rgba(141, 187, 255, 0.35);
+ font-size: 12px;
+}
+
+.state-connected {
+ border-color: rgba(113, 240, 178, 0.7);
+}
+
+.state-error {
+ border-color: rgba(255, 122, 151, 0.7);
+}
+
+.toast-stack {
+ position: fixed;
+ left: 50%;
+ bottom: 82px;
+ transform: translateX(-50%);
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ width: min(360px, calc(100vw - 24px));
+ z-index: 20;
+}
+
+.toast-item {
+ background: var(--text);
+ border: 1px solid rgba(141, 187, 255, 0.45);
+ border-left: 4px solid rgba(103, 209, 255, 0.85);
+ border-radius: 12px;
+ padding: 10px 12px;
+ box-shadow: 0 10px 30px rgba(4, 10, 20, 0.35);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+}
+
+.toast-title {
+ margin: 0;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ color: var(--bg);
+}
+
+.toast-message {
+ margin: 4px 0 0;
+ font-size: 13px;
+ line-height: 1.4;
+ color: var(--bg);
+ white-space: pre-wrap;
+}
+
+.toast-info {
+ border-left-color: rgba(103, 209, 255, 0.9);
+}
+
+.toast-warn {
+ border-color: rgba(255, 193, 110, 0.7);
+ border-left-color: rgba(255, 193, 110, 0.9);
+}
+
+.toast-error {
+ border-color: rgba(255, 122, 151, 0.7);
+ border-left-color: rgba(255, 122, 151, 0.95);
+}
+
+.toast-stack.terminal-toast .toast-item {
+ background: var(--shell-text);
+}
+
+.toast-stack.terminal-toast .toast-title,
+.toast-stack.terminal-toast .toast-message {
+ color: var(--shell-bg);
+}
+
+@media (max-width: 780px) {
+ .field-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .field.wide {
+ grid-column: span 1;
+ }
+}
+
+
diff --git a/pxterm/src/types/app.ts b/pxterm/src/types/app.ts
new file mode 100644
index 0000000..ec95863
--- /dev/null
+++ b/pxterm/src/types/app.ts
@@ -0,0 +1,131 @@
+import type {
+ CommandMarker,
+ CredentialRef,
+ HostKeyPolicy,
+ ResolvedCredential,
+ ServerProfile,
+ SessionLog,
+ SessionState,
+ ThemePreset
+} from "@remoteconn/shared";
+
+export type { ServerProfile, CredentialRef, SessionLog, SessionState, ResolvedCredential, CommandMarker, HostKeyPolicy, ThemePreset };
+
+/**
+ * \u5168\u5c40\u8bbe\u7f6e\uff08\u57df\u6536\u655b\u7248\uff09\u3002
+ */
+export interface GlobalSettings {
+ // \u2500\u2500 UI \u5916\u89c2 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
+ uiThemePreset: ThemePreset;
+ /** 界面明暗模式,影响预设色板的 dark/light 变体选择 */
+ uiThemeMode: "dark" | "light";
+ uiAccentColor: string;
+ uiBgColor: string;
+ uiTextColor: string;
+ uiBtnColor: string;
+
+ // \u2500\u2500 Shell \u663e\u793a \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
+ shellThemePreset: ThemePreset;
+ /** 终端明暗模式,影响终端预设色板的 dark/light 变体选择 */
+ shellThemeMode: "dark" | "light";
+ shellBgColor: string;
+ shellTextColor: string;
+ shellAccentColor: string;
+ shellFontFamily: string;
+ shellFontSize: number;
+ shellLineHeight: number;
+ unicode11: boolean;
+
+ // \u2500\u2500 \u7ec8\u7aef\u7f13\u51b2 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
+ terminalBufferMaxEntries: number;
+ terminalBufferMaxBytes: number;
+
+ // \u2500\u2500 \u8fde\u63a5\u7b56\u7565 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
+ autoReconnect: boolean;
+ reconnectLimit: number;
+ hostKeyPolicy: HostKeyPolicy;
+ credentialMemoryPolicy: "remember" | "forget";
+ gatewayConnectTimeoutMs: number;
+ waitForConnectedTimeoutMs: number;
+
+ // \u2500\u2500 \u670d\u52a1\u5668\u914d\u7f6e\u9884\u586b \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
+ defaultAuthType: "password" | "key";
+ defaultPort: number;
+ defaultProjectPath: string;
+ defaultTimeoutSeconds: number;
+ defaultHeartbeatSeconds: number;
+ defaultTransportMode: "gateway" | string;
+
+ // \u2500\u2500 \u65e5\u5fd7 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
+ logRetentionDays: number;
+ maskSecrets: boolean;
+
+ // \u2500\u2500 \u5df2\u5e9f\u5f03\u5b57\u6bb5\uff08\u517c\u5bb9\u4fdd\u7559\uff0c\u4e0b\u4e00\u4e2a\u7248\u672c\u7a97\u53e3\u5220\u9664\uff09\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
+ /** @deprecated \u8bf7\u4f7f\u7528 shellFontFamily */
+ fontFamily?: string;
+ /** @deprecated \u8bf7\u4f7f\u7528 shellFontSize */
+ fontSize?: number;
+ /** @deprecated \u8bf7\u4f7f\u7528 shellLineHeight */
+ lineHeight?: number;
+ /** @deprecated \u8bf7\u4f7f\u7528 uiThemePreset / shellThemePreset */
+ themePreset?: string;
+ /** @deprecated \u8bf7\u4f7f\u7528 uiAccentColor / shellAccentColor */
+ accentColor?: string;
+ /** @deprecated \u8bf7\u4f7f\u7528 uiBgColor / shellBgColor */
+ bgColor?: string;
+ /** @deprecated \u8bf7\u4f7f\u7528 uiTextColor / shellTextColor */
+ textColor?: string;
+ /** @deprecated UI \u52a8\u6548\u53c2\u6570\u5df2\u79fb\u9664\uff0c\u6682\u4fdd\u7559\u907f\u514d\u65e7\u6570\u636e\u62a5\u9519 */
+ liquidAlpha?: number;
+ /** @deprecated UI \u52a8\u6548\u53c2\u6570\u5df2\u79fb\u9664\uff0c\u6682\u4fdd\u7559\u907f\u514d\u65e7\u6570\u636e\u62a5\u9519 */
+ blurRadius?: number;
+ /** @deprecated UI \u52a8\u6548\u53c2\u6570\u5df2\u79fb\u9664\uff0c\u6682\u4fdd\u7559\u907f\u514d\u65e7\u6570\u636e\u62a5\u9519 */
+ motionDuration?: number;
+ /** @deprecated \u7f51\u5173 URL \u5df2\u4ece\u7528\u6237\u914d\u7f6e\u79fb\u9664\uff0c\u6539\u7531\u6784\u5efa\u65f6\u6ce8\u5165\u6216\u8fd0\u7ef4\u4e0b\u53d1 */
+ gatewayUrl?: string;
+ /** @deprecated \u7f51\u5173 Token \u5df2\u4ece\u7528\u6237\u914d\u7f6e\u79fb\u9664\uff0c\u6539\u7531\u6784\u5efa\u65f6\u6ce8\u5165\u6216\u8fd0\u7ef4\u4e0b\u53d1 */
+ gatewayToken?: string;
+
+}
+
+/**
+ * 凭据密文。
+ */
+export interface EncryptedCredentialPayload {
+ id: string;
+ refId: string;
+ encrypted: string;
+ iv: string;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface AppToast {
+ id: string;
+ level: "info" | "warn" | "error";
+ message: string;
+}
+
+export interface SessionCommandResult {
+ code: number;
+ stdout: string;
+ stderr: string;
+}
+
+export interface SessionContext {
+ state: SessionState;
+ currentServerId?: string;
+ currentSessionId?: string;
+ latencyMs?: number;
+ connectedAt?: string;
+}
+
+/**
+ * 闪念记录(语音输入区 record 按钮写入)。
+ */
+export interface VoiceRecord {
+ id: string;
+ content: string;
+ createdAt: string;
+ serverId: string;
+}
diff --git a/pxterm/src/utils/defaults.test.ts b/pxterm/src/utils/defaults.test.ts
new file mode 100644
index 0000000..3893b26
--- /dev/null
+++ b/pxterm/src/utils/defaults.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from "vitest";
+import { defaultSettings, normalizeGlobalSettings, resolveGatewayUrl, resolveGatewayToken } from "./defaults";
+
+describe("default settings", () => {
+ it("包含 UI/Shell 域前缀字段、终端缓冲阈值", () => {
+ expect(defaultSettings.uiBgColor.length).toBeGreaterThan(0);
+ expect(defaultSettings.uiAccentColor.length).toBeGreaterThan(0);
+ expect(defaultSettings.shellFontFamily.length).toBeGreaterThan(0);
+ expect(["dark", "light"]).toContain(defaultSettings.shellThemeMode);
+ expect(defaultSettings.shellFontSize).toBeGreaterThanOrEqual(12);
+ expect(defaultSettings.terminalBufferMaxBytes).toBeGreaterThan(0);
+ expect(defaultSettings.terminalBufferMaxEntries).toBeGreaterThan(0);
+ });
+
+ it("resolveGatewayUrl 返回非空字符串", () => {
+ expect(resolveGatewayUrl().length).toBeGreaterThan(0);
+ });
+
+ it("resolveGatewayToken 返回非空字符串", () => {
+ expect(resolveGatewayToken().length).toBeGreaterThan(0);
+ });
+
+ it("可将旧版 fontFamily 迁移到 shellFontFamily", () => {
+ const normalized = normalizeGlobalSettings({
+ fontFamily: "Menlo",
+ terminalBufferMaxBytes: Number.NaN,
+ terminalBufferMaxEntries: 1
+ });
+ expect(normalized.shellFontFamily).toBe("Menlo");
+ expect(normalized.terminalBufferMaxBytes).toBe(defaultSettings.terminalBufferMaxBytes);
+ expect(normalized.terminalBufferMaxEntries).toBeGreaterThanOrEqual(200);
+ });
+
+ it("可将旧版颜色字段迁移到域前缀字段", () => {
+ const normalized = normalizeGlobalSettings({
+ bgColor: "#112233",
+ textColor: "#aabbcc",
+ accentColor: "#ff0000"
+ });
+ expect(normalized.uiBgColor).toBe("#112233");
+ expect(normalized.shellBgColor).toBe("#112233");
+ expect(normalized.uiTextColor).toBe("#aabbcc");
+ expect(normalized.shellTextColor).toBe("#aabbcc");
+ expect(normalized.uiAccentColor).toBe("#ff0000");
+ expect(normalized.shellAccentColor).toBe("#ff0000");
+ });
+
+ it("可将旧版 credentialMemoryPolicy=session 迁移到 forget", () => {
+ const normalized = normalizeGlobalSettings({
+ credentialMemoryPolicy: "session" as "remember"
+ });
+ expect(normalized.credentialMemoryPolicy).toBe("forget");
+ });
+
+ it("可将旧版 themePreset 映射到新 ThemePreset", () => {
+ const normalized = normalizeGlobalSettings({ themePreset: "sunrise" });
+ expect(normalized.uiThemePreset).toBe("焰岩");
+ expect(normalized.shellThemePreset).toBe("焰岩");
+ });
+
+ it("shellThemeMode 非法值会回退到 dark", () => {
+ const normalized = normalizeGlobalSettings({ shellThemeMode: "invalid" as "dark" });
+ expect(normalized.shellThemeMode).toBe("dark");
+ });
+});
diff --git a/pxterm/src/utils/defaults.ts b/pxterm/src/utils/defaults.ts
new file mode 100644
index 0000000..23af562
--- /dev/null
+++ b/pxterm/src/utils/defaults.ts
@@ -0,0 +1,206 @@
+import type { GlobalSettings, ThemePreset } from "@/types/app";
+import { pickShellAccentColor } from "@remoteconn/shared";
+
+const MIN_TERMINAL_BUFFER_MAX_ENTRIES = 200;
+const MAX_TERMINAL_BUFFER_MAX_ENTRIES = 50_000;
+const MIN_TERMINAL_BUFFER_MAX_BYTES = 64 * 1024;
+const MAX_TERMINAL_BUFFER_MAX_BYTES = 64 * 1024 * 1024;
+const DEFAULT_SHELL_BG_COLOR = "#192b4d";
+const DEFAULT_SHELL_TEXT_COLOR = "#e6f0ff";
+
+function normalizeInteger(value: number, fallback: number, min: number, max: number): number {
+ if (!Number.isFinite(value)) {
+ return fallback;
+ }
+ const normalized = Math.round(value);
+ if (normalized < min) {
+ return min;
+ }
+ if (normalized > max) {
+ return max;
+ }
+ return normalized;
+}
+
+/**
+ * 推导默认网关地址:
+ * 1) 若显式配置了 VITE_GATEWAY_URL,优先使用;
+ * 2) 浏览器环境下根据当前站点自动推导;
+ * 3) 默认走同域 80/443(由反向代理承接)。
+ */
+function resolveDefaultGatewayUrl(): string {
+ const envGateway = import.meta.env.VITE_GATEWAY_URL?.trim();
+ if (envGateway) {
+ return envGateway;
+ }
+
+ if (typeof window === "undefined") {
+ return "ws://localhost:8787";
+ }
+
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ const hostname = window.location.hostname;
+ return `${protocol}//${hostname}`;
+}
+
+export const defaultSettings: GlobalSettings = {
+ // ── UI 外观 ──────────────────────────────────────────────────────────────
+ uiThemePreset: "tide",
+ uiThemeMode: "dark" as "dark" | "light",
+ uiAccentColor: "#5bd2ff",
+ uiBgColor: "#192b4d",
+ uiTextColor: "#e6f0ff",
+ uiBtnColor: "#adb9cd",
+
+ // ── Shell 显示 ────────────────────────────────────────────────────────────
+ shellThemePreset: "tide",
+ shellThemeMode: "dark" as "dark" | "light",
+ shellBgColor: DEFAULT_SHELL_BG_COLOR,
+ shellTextColor: DEFAULT_SHELL_TEXT_COLOR,
+ shellAccentColor: pickShellAccentColor(DEFAULT_SHELL_BG_COLOR, DEFAULT_SHELL_TEXT_COLOR),
+ shellFontFamily: "JetBrains Mono",
+ shellFontSize: 15,
+ shellLineHeight: 1.4,
+ unicode11: true,
+
+ // ── 终端缓冲 ─────────────────────────────────────────────────────────────
+ terminalBufferMaxEntries: 5000,
+ terminalBufferMaxBytes: 4 * 1024 * 1024,
+
+ // ── 连接策略 ─────────────────────────────────────────────────────────────
+ autoReconnect: true,
+ reconnectLimit: 3,
+ hostKeyPolicy: "strict",
+ credentialMemoryPolicy: "remember",
+ gatewayConnectTimeoutMs: 12000,
+ waitForConnectedTimeoutMs: 15000,
+
+ // ── 服务器配置预填 ────────────────────────────────────────────────────────
+ defaultAuthType: "password",
+ defaultPort: 22,
+ defaultProjectPath: "~/workspace",
+ defaultTimeoutSeconds: 20,
+ defaultHeartbeatSeconds: 15,
+ defaultTransportMode: "gateway",
+
+ // ── 日志 ─────────────────────────────────────────────────────────────────
+ logRetentionDays: 30,
+ maskSecrets: true
+};
+
+/**
+ * 全局配置归一化:
+ * - 为历史版本缺失字段补齐默认值;
+ * - 将旧版废弃字段迁移到新域前缀字段(仅首次,不覆盖已有新字段);
+ * - 对终端缓冲阈值做边界收敛,避免 NaN/异常值导致缓冲策略失效。
+ */
+export function normalizeGlobalSettings(raw: Partial | null | undefined): GlobalSettings {
+ const r = raw ?? {};
+ const merged: GlobalSettings = {
+ ...defaultSettings,
+ ...r
+ };
+
+ // ── 旧字段迁移(取旧值兜底,不覆盖已存在的新字段)────────────────────────
+ // fontFamily → shellFontFamily
+ if (!r.shellFontFamily && r.fontFamily) {
+ merged.shellFontFamily = r.fontFamily;
+ }
+ // fontSize → shellFontSize
+ if (!r.shellFontSize && r.fontSize !== undefined) {
+ merged.shellFontSize = r.fontSize;
+ }
+ // lineHeight → shellLineHeight
+ if (!r.shellLineHeight && r.lineHeight !== undefined) {
+ merged.shellLineHeight = r.lineHeight;
+ }
+ // accentColor → uiAccentColor / shellAccentColor
+ if (!r.uiAccentColor && r.accentColor) {
+ merged.uiAccentColor = r.accentColor;
+ }
+ if (!r.shellAccentColor && r.accentColor) {
+ merged.shellAccentColor = r.accentColor;
+ }
+ // bgColor → uiBgColor / shellBgColor
+ if (!r.uiBgColor && r.bgColor) {
+ merged.uiBgColor = r.bgColor;
+ }
+ if (!r.shellBgColor && r.bgColor) {
+ merged.shellBgColor = r.bgColor;
+ }
+ // textColor → uiTextColor / shellTextColor
+ if (!r.uiTextColor && r.textColor) {
+ merged.uiTextColor = r.textColor;
+ }
+ if (!r.shellTextColor && r.textColor) {
+ merged.shellTextColor = r.textColor;
+ }
+ // themePreset → uiThemePreset / shellThemePreset(仅映射合法值)
+ const legacyThemeMap: Record = {
+ tide: "tide",
+ mint: "tide", // mint 无对应新预设,兜底 tide
+ sunrise: "焰岩" // sunrise 映射到焰岩暖色系
+ };
+ if (!r.uiThemePreset && r.themePreset) {
+ merged.uiThemePreset = legacyThemeMap[r.themePreset] ?? "tide";
+ }
+ if (!r.shellThemePreset && r.themePreset) {
+ merged.shellThemePreset = legacyThemeMap[r.themePreset] ?? "tide";
+ }
+ // shellThemeMode 非法值兜底
+ if (merged.shellThemeMode !== "dark" && merged.shellThemeMode !== "light") {
+ merged.shellThemeMode = "dark";
+ }
+ // credentialMemoryPolicy: "session" → "forget"(旧枚举值 session 对应 forget)
+ if ((merged.credentialMemoryPolicy as string) === "session") {
+ merged.credentialMemoryPolicy = "forget";
+ }
+ // uiThemeMode 非法值兜底
+ if (merged.uiThemeMode !== "dark" && merged.uiThemeMode !== "light") {
+ merged.uiThemeMode = "dark";
+ }
+
+ // ── 数值边界收敛 ─────────────────────────────────────────────────────────
+ merged.terminalBufferMaxEntries = normalizeInteger(
+ merged.terminalBufferMaxEntries,
+ defaultSettings.terminalBufferMaxEntries,
+ MIN_TERMINAL_BUFFER_MAX_ENTRIES,
+ MAX_TERMINAL_BUFFER_MAX_ENTRIES
+ );
+ merged.terminalBufferMaxBytes = normalizeInteger(
+ merged.terminalBufferMaxBytes,
+ defaultSettings.terminalBufferMaxBytes,
+ MIN_TERMINAL_BUFFER_MAX_BYTES,
+ MAX_TERMINAL_BUFFER_MAX_BYTES
+ );
+
+ return merged;
+}
+
+/**
+ * 根据设置推导 gatewayUrl(运行时,不持久化到用户设置)。
+ * 优先级:构建时 VITE_GATEWAY_URL > 旧版持久化数据残留 > 自动推导同域地址。
+ */
+export function resolveGatewayUrl(settings?: Pick): string {
+ // 兼容旧版持久化数据中残留的 gatewayUrl 字段
+ if (settings?.gatewayUrl) {
+ return settings.gatewayUrl;
+ }
+ return resolveDefaultGatewayUrl();
+}
+
+/**
+ * 根据设置推导 gatewayToken(运行时,不持久化到用户设置)。
+ * 优先级:构建时 VITE_GATEWAY_TOKEN > 旧版持久化数据残留 > 开发占位符。
+ */
+export function resolveGatewayToken(settings?: Pick): string {
+ const envToken = import.meta.env.VITE_GATEWAY_TOKEN?.trim();
+ if (envToken) {
+ return envToken;
+ }
+ // 兼容旧版持久化数据中残留的 gatewayToken 字段
+ if (settings?.gatewayToken) {
+ return settings.gatewayToken;
+ }
+ return "remoteconn-dev-token";
+}
diff --git a/pxterm/src/utils/dynamicImportGuard.test.ts b/pxterm/src/utils/dynamicImportGuard.test.ts
new file mode 100644
index 0000000..ea33115
--- /dev/null
+++ b/pxterm/src/utils/dynamicImportGuard.test.ts
@@ -0,0 +1,60 @@
+import { describe, expect, it } from "vitest";
+import {
+ clearDynamicImportRetryMark,
+ isDynamicImportFailure,
+ shouldRetryDynamicImportReload
+} from "./dynamicImportGuard";
+
+interface MemoryStorage {
+ map: Map;
+ getItem(key: string): string | null;
+ setItem(key: string, value: string): void;
+ removeItem(key: string): void;
+}
+
+function createMemoryStorage(): MemoryStorage {
+ const map = new Map();
+ return {
+ map,
+ getItem: (key: string) => map.get(key) ?? null,
+ setItem: (key: string, value: string) => {
+ map.set(key, value);
+ },
+ removeItem: (key: string) => {
+ map.delete(key);
+ }
+ };
+}
+
+describe("dynamicImportGuard", () => {
+ it("可识别 Safari 的模块脚本导入失败文案", () => {
+ expect(isDynamicImportFailure(new TypeError("Importing a module script failed."))).toBe(true);
+ });
+
+ it("可识别 Chromium 的动态模块加载失败文案", () => {
+ expect(isDynamicImportFailure(new Error("Failed to fetch dynamically imported module"))).toBe(true);
+ });
+
+ it("非动态导入错误不应误判", () => {
+ expect(isDynamicImportFailure(new Error("network timeout"))).toBe(false);
+ expect(isDynamicImportFailure("")).toBe(false);
+ });
+
+ it("重试窗口内仅允许一次自动刷新", () => {
+ const storage = createMemoryStorage();
+ const now = 1_000_000;
+
+ expect(shouldRetryDynamicImportReload(storage, now, 15_000)).toBe(true);
+ expect(shouldRetryDynamicImportReload(storage, now + 5_000, 15_000)).toBe(false);
+ expect(shouldRetryDynamicImportReload(storage, now + 16_000, 15_000)).toBe(true);
+ });
+
+ it("清理重试标记后可重新允许刷新", () => {
+ const storage = createMemoryStorage();
+ const now = 2_000_000;
+
+ expect(shouldRetryDynamicImportReload(storage, now, 15_000)).toBe(true);
+ clearDynamicImportRetryMark(storage);
+ expect(shouldRetryDynamicImportReload(storage, now + 1_000, 15_000)).toBe(true);
+ });
+});
diff --git a/pxterm/src/utils/dynamicImportGuard.ts b/pxterm/src/utils/dynamicImportGuard.ts
new file mode 100644
index 0000000..db6c41b
--- /dev/null
+++ b/pxterm/src/utils/dynamicImportGuard.ts
@@ -0,0 +1,118 @@
+import type { Router } from "vue-router";
+
+const RETRY_MARK_KEY = "remoteconn:dynamic-import-retry-at";
+const RETRY_WINDOW_MS = 15_000;
+
+interface SessionStorageLike {
+ getItem(key: string): string | null;
+ setItem(key: string, value: string): void;
+ removeItem(key: string): void;
+}
+
+/**
+ * 判断是否属于“动态模块脚本加载失败”:
+ * - Safari 常见文案:Importing a module script failed;
+ * - Chromium 常见文案:Failed to fetch dynamically imported module;
+ * - Firefox 常见文案:error loading dynamically imported module。
+ */
+export function isDynamicImportFailure(error: unknown): boolean {
+ const message = extractErrorMessage(error).toLowerCase();
+ if (!message) {
+ return false;
+ }
+
+ const patterns = [
+ "importing a module script failed",
+ "failed to fetch dynamically imported module",
+ "error loading dynamically imported module"
+ ];
+
+ return patterns.some((pattern) => message.includes(pattern));
+}
+
+/**
+ * 决定是否允许本次自动刷新:
+ * - 15 秒窗口内仅允许一次,避免 chunk 持续不可用时陷入刷新循环;
+ * - 超过窗口后允许再次尝试,适配短时发布抖动场景。
+ */
+export function shouldRetryDynamicImportReload(
+ storage: SessionStorageLike,
+ now = Date.now(),
+ retryWindowMs = RETRY_WINDOW_MS
+): boolean {
+ const raw = storage.getItem(RETRY_MARK_KEY);
+ const lastRetryAt = Number(raw);
+ if (Number.isFinite(lastRetryAt) && now - lastRetryAt < retryWindowMs) {
+ return false;
+ }
+ storage.setItem(RETRY_MARK_KEY, String(now));
+ return true;
+}
+
+/**
+ * 在路由成功就绪后清理重试标记:
+ * - 让后续真实的新一轮发布故障仍可触发一次自动恢复;
+ * - 避免旧标记长期驻留导致后续无法自动恢复。
+ */
+export function clearDynamicImportRetryMark(storage: SessionStorageLike): void {
+ storage.removeItem(RETRY_MARK_KEY);
+}
+
+/**
+ * 安装动态导入失败恢复逻辑:
+ * - 监听 router.onError;
+ * - 命中动态模块加载失败时自动刷新当前目标路由;
+ * - sessionStorage 不可用时降级为仅输出错误,不抛出二次异常。
+ */
+export function installDynamicImportRecovery(router: Router): void {
+ router.onError((error, to) => {
+ if (!isDynamicImportFailure(error)) {
+ return;
+ }
+
+ const storage = safeSessionStorage();
+ if (!storage) {
+ console.error("[router] 动态模块加载失败,且 sessionStorage 不可用", error);
+ return;
+ }
+
+ if (!shouldRetryDynamicImportReload(storage)) {
+ clearDynamicImportRetryMark(storage);
+ console.error("[router] 动态模块加载失败,已达单次自动恢复上限", error);
+ return;
+ }
+
+ const fallbackUrl = `${window.location.pathname}${window.location.search}${window.location.hash}`;
+ const target = typeof to.fullPath === "string" && to.fullPath.length > 0 ? to.fullPath : fallbackUrl;
+ window.location.assign(target);
+ });
+
+ void router.isReady().then(() => {
+ const storage = safeSessionStorage();
+ if (storage) {
+ clearDynamicImportRetryMark(storage);
+ }
+ });
+}
+
+function extractErrorMessage(error: unknown): string {
+ if (error instanceof Error) {
+ return error.message ?? "";
+ }
+ if (typeof error === "string") {
+ return error;
+ }
+ if (error && typeof error === "object" && "message" in error) {
+ const maybeMessage = (error as { message?: unknown }).message;
+ return typeof maybeMessage === "string" ? maybeMessage : "";
+ }
+ return "";
+}
+
+function safeSessionStorage(): SessionStorageLike | null {
+ try {
+ return window.sessionStorage;
+ } catch {
+ return null;
+ }
+}
diff --git a/pxterm/src/utils/feedback.ts b/pxterm/src/utils/feedback.ts
new file mode 100644
index 0000000..fac587f
--- /dev/null
+++ b/pxterm/src/utils/feedback.ts
@@ -0,0 +1,131 @@
+function asMessage(error: unknown): string {
+ if (error instanceof Error) {
+ return String(error.message || "");
+ }
+ if (typeof error === "string") {
+ return error;
+ }
+ try {
+ return JSON.stringify(error);
+ } catch {
+ return "";
+ }
+}
+
+function normalizeText(input: string): string {
+ return input.trim().toLowerCase();
+}
+
+export function toFriendlyDisconnectReason(reason: string | undefined): string {
+ const raw = String(reason ?? "").trim();
+ if (!raw) return "连接已关闭";
+
+ const map: Record = {
+ manual: "你已主动断开连接",
+ switch: "切换连接目标,已断开当前会话",
+ host_key_rejected: "主机指纹未被信任,连接已断开",
+ auth_failed: "认证失败,连接被服务器拒绝",
+ rate_limit: "连接过于频繁,请稍后重试",
+ shell_closed: "远端 Shell 已关闭",
+ connection_closed: "服务器连接已关闭",
+ ws_error: "网关连接异常",
+ ws_closed: "网关连接已断开",
+ ws_peer_normal_close: "客户端已关闭连接",
+ unknown: "连接已关闭"
+ };
+
+ return map[raw] ?? `连接已关闭(${raw})`;
+}
+
+export function toFriendlyConnectionError(error: unknown): string {
+ const message = asMessage(error);
+ const lower = normalizeText(message);
+
+ if (lower.includes("rate_limit") || message.includes("连接过于频繁")) {
+ return "连接过于频繁,请稍后重试。";
+ }
+
+ if (lower.includes("auth_failed") || message.includes("token 无效")) {
+ return "网关鉴权失败,请联系管理员检查网关令牌。";
+ }
+
+ if (message.includes("SSH 认证失败")) {
+ return "SSH 认证失败。请检查账号/凭据,若服务器仅允许公钥认证,请改用私钥方式。";
+ }
+
+ if (message.includes("主机指纹") && message.includes("信任")) {
+ return "主机指纹校验未通过,请确认主机身份后重试。";
+ }
+
+ if (message.includes("Timed out while waiting for handshake") || message.includes("连接超时") || lower.includes("timeout")) {
+ return "连接超时。请检查服务器地址、端口和网络连通性。";
+ }
+
+ if (message.includes("无法连接网关") || lower.includes("ws_closed") || lower.includes("websocket")) {
+ return "无法连接网关,请检查网关地址、服务状态与网络策略。";
+ }
+
+ if (message.includes("凭据读取失败")) {
+ return "凭据读取失败,请在服务器设置页重新保存后重试。";
+ }
+
+ if (!message) {
+ return "连接失败,请稍后重试。";
+ }
+
+ return message;
+}
+
+export function toFriendlyError(error: unknown): string {
+ const message = asMessage(error);
+ const lower = normalizeText(message);
+
+ if (!message) {
+ return "操作失败,请稍后重试。";
+ }
+
+ if (
+ lower.includes("ws_") ||
+ lower.includes("websocket") ||
+ lower.includes("auth_failed") ||
+ lower.includes("rate_limit") ||
+ message.includes("连接") ||
+ message.includes("网关") ||
+ message.includes("SSH")
+ ) {
+ return toFriendlyConnectionError(message);
+ }
+
+ if (message.includes("密码不能为空") || message.includes("私钥内容不能为空") || message.includes("证书模式下")) {
+ return message;
+ }
+
+ if (lower.includes("json") && (lower.includes("parse") || lower.includes("unexpected token"))) {
+ return "配置内容不是有效的 JSON,请检查格式后重试。";
+ }
+
+ if (message.includes("会话未连接")) {
+ return "当前会话未连接,请先建立连接。";
+ }
+
+ if (message.includes("凭据读取失败")) {
+ return "凭据读取失败,请在服务器设置页重新保存后重试。";
+ }
+
+ if (message.includes("未找到凭据内容") || message.includes("凭据引用不存在")) {
+ return "未找到可用凭据,请在服务器设置页重新填写并保存。";
+ }
+
+ return message;
+}
+
+export function formatActionError(action: string, error: unknown): string {
+ const detail = toFriendlyError(error);
+ return `${action}:${detail}`;
+}
+
+export function toToastTitle(level: "info" | "warn" | "error"): string {
+ if (level === "error") return "错误";
+ if (level === "warn") return "注意";
+ return "提示";
+}
diff --git a/pxterm/src/utils/rememberedState.test.ts b/pxterm/src/utils/rememberedState.test.ts
new file mode 100644
index 0000000..2193457
--- /dev/null
+++ b/pxterm/src/utils/rememberedState.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vitest";
+import { readRememberedEnum, writeRememberedEnum } from "./rememberedState";
+
+describe("rememberedState", () => {
+ it("可读取允许列表内的持久化值", () => {
+ const storage = {
+ getItem: (key: string) => (key === "k" ? "shell" : null)
+ } as unknown as Storage;
+ Object.defineProperty(globalThis, "localStorage", {
+ configurable: true,
+ value: storage
+ });
+
+ const value = readRememberedEnum("k", ["ui", "shell", "log"] as const);
+ expect(value).toBe("shell");
+ });
+
+ it("读取到不在允许列表中的值时返回 null", () => {
+ const storage = {
+ getItem: () => "unknown"
+ } as unknown as Storage;
+ Object.defineProperty(globalThis, "localStorage", {
+ configurable: true,
+ value: storage
+ });
+
+ const value = readRememberedEnum("k", ["ui", "shell", "log"] as const);
+ expect(value).toBeNull();
+ });
+
+ it("localStorage 不可用时应静默降级", () => {
+ Object.defineProperty(globalThis, "localStorage", {
+ configurable: true,
+ get() {
+ throw new Error("storage blocked");
+ }
+ });
+
+ expect(readRememberedEnum("k", ["ui", "shell"] as const)).toBeNull();
+ expect(() => writeRememberedEnum("k", "ui")).not.toThrow();
+ });
+});
diff --git a/pxterm/src/utils/rememberedState.ts b/pxterm/src/utils/rememberedState.ts
new file mode 100644
index 0000000..c1931bb
--- /dev/null
+++ b/pxterm/src/utils/rememberedState.ts
@@ -0,0 +1,20 @@
+export function readRememberedEnum(
+ storageKey: string,
+ allowedValues: readonly T[]
+): T | null {
+ try {
+ const raw = localStorage.getItem(storageKey);
+ if (!raw) return null;
+ return allowedValues.includes(raw as T) ? (raw as T) : null;
+ } catch {
+ return null;
+ }
+}
+
+export function writeRememberedEnum(storageKey: string, value: string): void {
+ try {
+ localStorage.setItem(storageKey, value);
+ } catch {
+ // 忽略本地存储不可用场景(如隐私模式限制)
+ }
+}
diff --git a/pxterm/src/utils/time.ts b/pxterm/src/utils/time.ts
new file mode 100644
index 0000000..e771423
--- /dev/null
+++ b/pxterm/src/utils/time.ts
@@ -0,0 +1,17 @@
+export function nowIso(): string {
+ return new Date().toISOString();
+}
+
+export function formatDateTime(input: string | Date): string {
+ const date = input instanceof Date ? input : new Date(input);
+ return date.toLocaleString("zh-CN", { hour12: false });
+}
+
+export function formatDurationMs(ms: number): string {
+ if (!Number.isFinite(ms) || ms <= 0) return "0s";
+ const seconds = Math.round(ms / 1000);
+ const m = Math.floor(seconds / 60);
+ const s = seconds % 60;
+ if (m <= 0) return `${s}s`;
+ return `${m}m ${s}s`;
+}
diff --git a/pxterm/src/utils/useRememberedEnumRef.ts b/pxterm/src/utils/useRememberedEnumRef.ts
new file mode 100644
index 0000000..3313119
--- /dev/null
+++ b/pxterm/src/utils/useRememberedEnumRef.ts
@@ -0,0 +1,23 @@
+import { onMounted, watch, type Ref } from "vue";
+import { readRememberedEnum, writeRememberedEnum } from "@/utils/rememberedState";
+
+interface UseRememberedEnumRefOptions {
+ storageKey: string;
+ allowedValues: readonly T[];
+ target: Ref;
+}
+
+export function useRememberedEnumRef(options: UseRememberedEnumRefOptions): void {
+ const { storageKey, allowedValues, target } = options;
+
+ onMounted(() => {
+ const saved = readRememberedEnum(storageKey, allowedValues);
+ if (saved) {
+ target.value = saved;
+ }
+ });
+
+ watch(target, (value) => {
+ writeRememberedEnum(storageKey, value);
+ });
+}
diff --git a/pxterm/src/views/ConnectView.vue b/pxterm/src/views/ConnectView.vue
new file mode 100644
index 0000000..20d92a3
--- /dev/null
+++ b/pxterm/src/views/ConnectView.vue
@@ -0,0 +1,506 @@
+
+
+
+
+
+
+
+
+
+
我的服务器
+
+
+
+
+
+
+
diff --git a/pxterm/src/views/LogsView.vue b/pxterm/src/views/LogsView.vue
new file mode 100644
index 0000000..633e1d2
--- /dev/null
+++ b/pxterm/src/views/LogsView.vue
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+ 共 {{ totalLogs }} 条
+
+
+
+
+
+
+
+
diff --git a/pxterm/src/views/PluginsView.vue b/pxterm/src/views/PluginsView.vue
new file mode 100644
index 0000000..25db604
--- /dev/null
+++ b/pxterm/src/views/PluginsView.vue
@@ -0,0 +1,113 @@
+
+
+
+
+
+ 插件列表
+
+
+ {{ item.id }} · {{ item.status }}
+ errorCount: {{ item.errorCount }} · {{ item.lastError || '-' }}
+
+
+
+
+
+
+
+
+
+ 导入插件 JSON
+
+
+
+
+
+
+ 运行时日志
+ {{ pluginStore.runtimeLogs.join('\n') }}
+
+
+
+
+
diff --git a/pxterm/src/views/RecordsView.vue b/pxterm/src/views/RecordsView.vue
new file mode 100644
index 0000000..0e29712
--- /dev/null
+++ b/pxterm/src/views/RecordsView.vue
@@ -0,0 +1,253 @@
+
+
+
+
+
+
+
+ 共 {{ totalRecords }} 条
+
+
+
+
+
+
+
+
+
+
diff --git a/pxterm/src/views/ServerSettingsView.vue b/pxterm/src/views/ServerSettingsView.vue
new file mode 100644
index 0000000..c1b4978
--- /dev/null
+++ b/pxterm/src/views/ServerSettingsView.vue
@@ -0,0 +1,656 @@
+
+
+
+
+
+
+
服务器设置
+
+
+
+
+
+
+
+ 未找到服务器,请返回上一页或使用右下角导航按钮重新选择。
+
+
+
+
+
+
+
+
diff --git a/pxterm/src/views/SettingsView.vue b/pxterm/src/views/SettingsView.vue
new file mode 100644
index 0000000..e576c39
--- /dev/null
+++ b/pxterm/src/views/SettingsView.vue
@@ -0,0 +1,644 @@
+
+
+
+
+
diff --git a/pxterm/src/views/TerminalView.vue b/pxterm/src/views/TerminalView.vue
new file mode 100644
index 0000000..fdd76e6
--- /dev/null
+++ b/pxterm/src/views/TerminalView.vue
@@ -0,0 +1,339 @@
+
+
+
+
+
+
+
+
+
+
{{ terminalTitle }}
+ {{ sessionStore.state }}
+ {{ sessionStore.latencyMs }}ms
+
+
+
+
+
+
+
+
+
+
+
+
+ 正在初始化终端…
+
+
+
+
+
+
+
+
+
+
+
+
AI 快速启动
+
点击按钮自动切换项目目录启动AICoding
+
+
+ Codex
+
+
+
+
+
+
+ Copilot
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/pxterm/terminal.config.json b/pxterm/terminal.config.json
new file mode 100644
index 0000000..e16328b
--- /dev/null
+++ b/pxterm/terminal.config.json
@@ -0,0 +1,4 @@
+{
+ "gatewayUrl": "wss://shell.biboer.cn:5173/ws/terminal",
+ "gatewayToken": "remoteconn-dev-token"
+}
diff --git a/pxterm/test.js b/pxterm/test.js
new file mode 100644
index 0000000..b02295a
--- /dev/null
+++ b/pxterm/test.js
@@ -0,0 +1,2 @@
+const regex = /xterm-viewport/;
+console.dir(regex.exec(''));
diff --git a/pxterm/test_global_capture.cjs b/pxterm/test_global_capture.cjs
new file mode 100644
index 0000000..6a9b858
--- /dev/null
+++ b/pxterm/test_global_capture.cjs
@@ -0,0 +1,23 @@
+const fs = require('fs');
+let code = fs.readFileSync('src/components/TerminalPanel.vue', 'utf8');
+
+const globalTracker = `
+ // --- 极致全局监听器,抓出是谁吃掉了 move ---
+ const globalEventDebug = (e: Event) => {
+ // 若这是消失的 move 事件,打出到底在哪被拦截了
+ if (e.type === "touchmove" || e.type === "pointermove" || e.type === "touchcancel" || e.type === "pointercancel") {
+ // 为了不刷屏,如果是 pointermove 我们只在 touch 活跃时打印
+ if (e.type === "pointermove" && (e as PointerEvent).pointerType !== "touch") return;
+
+ console.log(\`[GLOBAL INTERCEPT] \${e.type} | Phase: \${e.eventPhase} | Cancelable: \${e.cancelable} | Target: \${(e.target as Element)?.className || (e.target as Element)?.tagName}\`);
+ }
+ };
+
+ ['touchmove', 'touchstart', 'touchend', 'touchcancel', 'pointerdown', 'pointermove', 'pointerup', 'pointercancel'].forEach(type => {
+ // 用最顶级的 window capture 拦截,这样在任何人(包括 xterm 内部那些黑盒代码)前面执行
+ window.addEventListener(type, globalEventDebug, { capture: true, passive: false });
+ });
+`;
+
+code = code.replace(/function initTerminal\(\)\: void \{/, `function initTerminal(): void {\n${globalTracker}`);
+fs.writeFileSync('src/components/TerminalPanel.vue', code);
diff --git a/pxterm/test_script.js b/pxterm/test_script.js
new file mode 100644
index 0000000..d940252
--- /dev/null
+++ b/pxterm/test_script.js
@@ -0,0 +1 @@
+console.log("ready");
diff --git a/pxterm/todo.md b/pxterm/todo.md
new file mode 100644
index 0000000..0fd5a69
--- /dev/null
+++ b/pxterm/todo.md
@@ -0,0 +1,11 @@
+网关使用~/remoteconn现有网关,gateway在本地,wss协议,使用8787端口。外部访问web使用https://shell.biboer.cn:5173。
+
+使用shell.biboer.cn这个域名, npm run dev, 使用https://shell.biboer.cn:5173访问。
+
+Vite 开发服务本身走 HTTPS。
+
+证书和私钥信息:
+const DEV_PUBLIC_HOST = "shell.biboer.cn";
+const DEV_CERT_PATH = "/Users/gavin/.acme.sh/shell.biboer.cn_ecc/fullchain.cer";
+const DEV_KEY_PATH = "/Users/gavin/.acme.sh/shell.biboer.cn_ecc/shell.biboer.cn.key";
+
diff --git a/pxterm/tsconfig.base.json b/pxterm/tsconfig.base.json
new file mode 100644
index 0000000..95ff2a3
--- /dev/null
+++ b/pxterm/tsconfig.base.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "resolveJsonModule": true,
+ "allowSyntheticDefaultImports": true,
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "forceConsistentCasingInFileNames": true,
+ "noImplicitOverride": true,
+ "noUncheckedIndexedAccess": true,
+ "declaration": true,
+ "sourceMap": true,
+ "types": ["node"]
+ }
+}
diff --git a/pxterm/tsconfig.json b/pxterm/tsconfig.json
new file mode 100644
index 0000000..43da06c
--- /dev/null
+++ b/pxterm/tsconfig.json
@@ -0,0 +1,15 @@
+{
+ "extends": "./tsconfig.base.json",
+ "compilerOptions": {
+ "jsx": "preserve",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "types": ["vite/client", "node"],
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"],
+ "@remoteconn/shared": ["./packages/shared/src/index.ts"],
+ "@remoteconn/plugin-runtime": ["./packages/plugin-runtime/src/index.ts"]
+ }
+ },
+ "include": ["src/**/*.ts", "src/**/*.vue", "src/**/*.d.ts"]
+}
diff --git a/pxterm/vite.config.ts b/pxterm/vite.config.ts
new file mode 100644
index 0000000..42ac75b
--- /dev/null
+++ b/pxterm/vite.config.ts
@@ -0,0 +1,43 @@
+import { defineConfig } from "vite";
+import vue from "@vitejs/plugin-vue";
+import { fileURLToPath, URL } from "node:url";
+import fs from "node:fs";
+
+const DEV_PUBLIC_HOST = "shell.biboer.cn";
+const DEV_CERT_PATH = "/Users/gavin/.acme.sh/shell.biboer.cn_ecc/fullchain.cer";
+const DEV_KEY_PATH = "/Users/gavin/.acme.sh/shell.biboer.cn_ecc/shell.biboer.cn.key";
+
+export default defineConfig({
+ plugins: [vue()],
+ server: {
+ port: 5173,
+ host: DEV_PUBLIC_HOST,
+ https: {
+ cert: fs.readFileSync(DEV_CERT_PATH),
+ key: fs.readFileSync(DEV_KEY_PATH)
+ },
+ proxy: {
+ '/gateway-health': {
+ target: 'http://localhost:8787',
+ changeOrigin: true,
+ rewrite: (path) => path.replace(/^\/gateway-health/, '/health')
+ },
+ '/ws': {
+ target: 'http://localhost:8787',
+ ws: true,
+ changeOrigin: true,
+ secure: false
+ }
+ }
+ },
+ optimizeDeps: {
+ exclude: ['@remoteconn/shared', '@remoteconn/plugin-runtime']
+ },
+ resolve: {
+ alias: {
+ "@": fileURLToPath(new URL("./src", import.meta.url)),
+ "@remoteconn/shared": fileURLToPath(new URL("./packages/shared/src/index.ts", import.meta.url)),
+ "@remoteconn/plugin-runtime": fileURLToPath(new URL("./packages/plugin-runtime/src/index.ts", import.meta.url))
+ }
+ }
+});
diff --git a/wetty/src/assets/scss/styles.scss b/wetty/src/assets/scss/styles.scss
index a134e10..c161768 100644
--- a/wetty/src/assets/scss/styles.scss
+++ b/wetty/src/assets/scss/styles.scss
@@ -20,8 +20,6 @@ body {
.xterm {
.xterm-viewport {
- overscroll-behavior-y: contain !important;
- -webkit-overflow-scrolling: touch !important;
- will-change: transform, scroll-position;
+ overflow-y: hidden;
}
}
diff --git a/wetty/src/assets/scss/terminal.scss b/wetty/src/assets/scss/terminal.scss
index b83c5d9..c447840 100644
--- a/wetty/src/assets/scss/terminal.scss
+++ b/wetty/src/assets/scss/terminal.scss
@@ -1,5 +1,4 @@
#terminal {
- box-sizing: border-box;
display: flex;
height: 100%;
position: relative;
diff --git a/wetty/src/client/wetty.ts b/wetty/src/client/wetty.ts
index 141d577..c03f1dd 100644
--- a/wetty/src/client/wetty.ts
+++ b/wetty/src/client/wetty.ts
@@ -8,7 +8,7 @@ import { disconnect } from './wetty/disconnect';
import { overlay } from './wetty/disconnect/elements';
import { verifyPrompt } from './wetty/disconnect/verify';
import { FileDownloader } from './wetty/download';
-import { mobileKeyboard, initMobileViewport } from './wetty/mobile';
+import { mobileKeyboard } from './wetty/mobile';
import { socket } from './wetty/socket';
import { terminal, Term } from './wetty/term';
@@ -33,7 +33,6 @@ if (_.isUndefined(term)) {
term.resizeTerm();
term.focus();
mobileKeyboard();
- initMobileViewport(term);
const fileDownloader = new FileDownloader();
socket
diff --git a/wetty/src/client/wetty/mobile.ts b/wetty/src/client/wetty/mobile.ts
index 6147bd0..3d9922b 100644
--- a/wetty/src/client/wetty/mobile.ts
+++ b/wetty/src/client/wetty/mobile.ts
@@ -9,38 +9,3 @@ export function mobileKeyboard(): void {
screen.setAttribute('autocomplete', 'false');
screen.setAttribute('autocapitalize', 'false');
}
-
-export function initMobileViewport(term: any): void {
- if (window.visualViewport) {
- const handleResize = () => {
- // 类似 xterminal,给软键盘和页面重排一点时间
- setTimeout(() => {
- let bottomInset = 0;
- if (window.visualViewport) {
- bottomInset = Math.max(
- 0,
- window.innerHeight - (window.visualViewport.height + window.visualViewport.offsetTop)
- );
- }
-
- const termWrapper = document.getElementById('terminal'); // 外层容器
- if (termWrapper) {
- if (bottomInset > 0) {
- termWrapper.style.paddingBottom = `${bottomInset}px`;
- } else {
- termWrapper.style.paddingBottom = '0px';
- }
- }
-
- // 通知 xterm 重新按新的可用高度进行行列计算,类似于 xterminal 重新适配行
- if (term && term.fitAddon) {
- term.fitAddon.fit();
- term.scrollToBottom();
- }
- }, 100);
- };
-
- window.visualViewport.addEventListener('resize', handleResize);
- window.visualViewport.addEventListener('scroll', handleResize);
- }
-}
diff --git a/wetty/todo.md b/wetty/todo.md
index fd71943..dd3ae9b 100644
--- a/wetty/todo.md
+++ b/wetty/todo.md
@@ -17,3 +17,12 @@ const DEV_KEY_PATH = "/Users/gavin/.acme.sh/shell.biboer.cn_ecc/shell.biboer.cn.
参考../xterminal,增加:
1. 软键盘弹出和折叠,行的位置计算参考xtermal。 -- 这点重要
2. 滚动容器保持原生丝滑。现在xterm太卡。
+
+
+
+---
+
+
+结论: 使用xterm.js,手机体验不好.
+
+
diff --git a/xterminal/package.json b/xterminal/package.json
index b8308c3..91a05db 100644
--- a/xterminal/package.json
+++ b/xterminal/package.json
@@ -49,8 +49,7 @@
"build:js": "rollup -c",
"build:css": "postcss theme/index.css > dist/xterminal.css",
"build": "pnpm clean && pnpm build:ts && pnpm build:js && pnpm build:css",
- "dev": "concurrently \"tsc -w\" \"vite demo\"",
- "dev:https": "concurrently \"tsc -w\" \"vite --config demo/vite.https.config.mjs\"",
+ "dev": "concurrently \"tsc -w\" \"vite --config demo/vite.https.config.mjs\"",
"lint": "eslint source/ tests/ && prettier source/ --check",
"lint:fix": "eslint --fix source/ tests/ && prettier source/ tests/ --write",
"prepack": "pnpm build",