update at 2026-03-03 21:19:52

This commit is contained in:
douboer@gmail.com
2026-03-03 21:19:52 +08:00
parent 3dc4144007
commit e4987a2d77
139 changed files with 21522 additions and 43 deletions

View File

@@ -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. 顺势收回键盘,**验证**:屏幕重新展平,最后一行依然保留在屏幕最下方,并未丢失位置。

View File

@@ -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);

7
pxterm/fix_css.cjs Normal file
View File

@@ -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);

13
pxterm/fix_handlers.cjs Normal file
View File

@@ -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);

7
pxterm/fix_handlers2.cjs Normal file
View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

13
pxterm/fix_touch_css.cjs Normal file
View File

@@ -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!

View File

@@ -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);

8
pxterm/fix_ts.cjs Normal file
View File

@@ -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);

8
pxterm/fix_ts_2.cjs Normal file
View File

@@ -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);

View File

@@ -0,0 +1 @@
// Pseudo script, we can't get actual DOM tree of a running client easily.

View File

@@ -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);

15
pxterm/index.html Normal file
View File

@@ -0,0 +1,15 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no"
/>
<title>RemoteConn</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@@ -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. **软键盘完整兼容**:没有使用全局焦点的强行剥夺,不阻碍日常的点击输入与键盘显隐。

2041
pxterm/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

34
pxterm/package.json Normal file
View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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> | void;
onunload?: () => Promise<void> | void;
};
}
/**
* 生产可用插件管理器:
* 1. 安装校验
* 2. 生命周期
* 3. 单插件熔断
* 4. 权限最小化
*/
export class PluginManager {
private readonly records = new Map<string, PluginRecord>();
private readonly runtime = new Map<string, RuntimeSlot>();
private readonly commands = new Map<string, PluginCommand>();
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<void> {
const stored = await this.fs.readStore<PluginRecord[]>("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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<Record<string, unknown>>(`plugin_data_${manifest.id}`, {});
return value[key];
},
set: async (key: string, value: unknown) => {
assertPermission("storage.write");
const previous = await this.fs.readStore<Record<string, unknown>>(`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<T>(promise: Promise<T>, timeoutMs: number, message: string): Promise<T> {
let timer: NodeJS.Timeout | undefined;
const timeout = new Promise<never>((_, 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<void> {
await this.fs.writeStore("plugin_records", Array.from(this.records.values()));
}
}

View File

@@ -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();
});
});

View File

@@ -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;
}

View File

@@ -0,0 +1,3 @@
export * from "./types/plugin";
export * from "./core/pluginManager";
export * from "./core/validator";

View File

@@ -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> | void;
}
export interface PluginFsAdapter {
listPackages(): Promise<PluginPackage[]>;
getPackage(pluginId: string): Promise<PluginPackage | null>;
upsertPackage(pluginPackage: PluginPackage): Promise<void>;
removePackage(pluginId: string): Promise<void>;
readStore<T>(key: string, fallback: T): Promise<T>;
writeStore<T>(key: string, value: T): Promise<void>;
}
export interface PluginSessionApi {
send(input: string): Promise<void>;
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;
}

View File

@@ -0,0 +1,12 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"baseUrl": ".",
"paths": {
"@remoteconn/shared": ["../shared/src/index.ts"]
}
},
"include": ["src/**/*.ts"]
}

View File

@@ -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"
}
}

View File

@@ -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");
});
});

View File

@@ -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"
}
];
}

View File

@@ -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";

View File

@@ -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}/, "***.***.***.***");
}

View File

@@ -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<boolean>;
}
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 };
}

View File

@@ -0,0 +1,28 @@
import type { SessionState } from "../types/models";
/**
* 会话状态机,确保连接生命周期可预测。
*/
const transitions: Record<SessionState, SessionState[]> = {
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[];
}

View File

@@ -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");
});
});

View File

@@ -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");
}

View File

@@ -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");
});
});

View File

@@ -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<ThemePreset, ThemeDefinition> = {
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[];

View File

@@ -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;
};
};

View File

@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src/**/*.ts"]
}

106
pxterm/patch_deep_log.cjs Normal file
View File

@@ -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);

View File

@@ -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);

View File

@@ -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.

View File

@@ -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);

View File

@@ -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.

View File

@@ -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!

93
pxterm/patch_touch.cjs Normal file
View File

@@ -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);

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
<path fill="#FFC16E" d="M10.962 0a10.909 10.909 0 0 0-6.083 1.845A10.988 10.988 0 0 0 .84 6.775a11.05 11.05 0 0 0-.633 6.353 11.017 11.017 0 0 0 2.985 5.636 10.93 10.93 0 0 0 5.599 3.02c2.122.429 4.323.216 6.324-.613a10.958 10.958 0 0 0 4.92-4.04 11.038 11.038 0 0 0 1.858-6.107v-.015A11.033 11.033 0 0 0 21.07 6.8a10.988 10.988 0 0 0-2.364-3.57A10.928 10.928 0 0 0 15.16.842 10.885 10.885 0 0 0 10.975 0h-.013Zm.614 14.925-.772-1.833H7.8l-.772 1.833H5.053l3.276-7.96h1.935l3.278 7.96h-1.966Zm4.511 0H14.4v-7.96h1.687v7.96Z"/>
<path fill="#FFC16E" d="M8.18 11.668h2.255l-1.127-2.78-1.128 2.78Z"/>
</svg>

After

Width:  |  Height:  |  Size: 704 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
<path fill="#FFC16E" d="M10.937 0C4.897 0 0 4.896 0 10.937c0 6.04 4.896 10.936 10.937 10.936 6.04 0 10.936-4.896 10.936-10.936S16.977 0 10.937 0Zm5.694 14.507a1.364 1.364 0 0 1 0 1.923l-.481.48a1.364 1.364 0 0 1-1.923 0l-3.43-3.43-3.43 3.43a1.364 1.364 0 0 1-1.924 0l-.48-.48a1.364 1.364 0 0 1 0-1.923l3.43-3.43-3.71-3.71a1.364 1.364 0 0 1 0-1.924l.48-.48a1.364 1.364 0 0 1 1.924 0l3.71 3.71 3.71-3.71a1.364 1.364 0 0 1 1.923 0l.48.48a1.364 1.364 0 0 1 0 1.923l-3.71 3.71 3.43 3.43Z"/>
</svg>

After

Width:  |  Height:  |  Size: 591 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="24" fill="none" viewBox="0 0 22 24">
<path fill="#FFC16E" d="M14.071.652a3.268 3.268 0 0 0-1.694-.614c-.444-.03-.89-.042-1.335-.036h-.27c-.43-.008-.862.004-1.292.036a3.266 3.266 0 0 0-1.726.64c-.346.276-.633.62-.841 1.011-.2.35-.398.786-.622 1.28l-.39.85h-4.81a1.091 1.091 0 1 0 0 2.183h.818V19.91A4.09 4.09 0 0 0 6 24h9.818a4.09 4.09 0 0 0 4.09-4.09V6.002h.818a1.092 1.092 0 0 0 0-2.18h-4.71l-.465-.961c-.195-.42-.408-.833-.638-1.235a3.254 3.254 0 0 0-.841-.974Zm-.48 3.17H8.3c.154-.358.323-.708.507-1.051a1.003 1.003 0 0 1 .87-.56c.291-.026.67-.026 1.27-.026.586 0 .955 0 1.237.026a1.004 1.004 0 0 1 .859.539c.144.237.303.56.55 1.071Zm-5.41 14.41a.818.818 0 0 1-.818-.817V10.87a.818.818 0 0 1 1.636 0v6.545a.818.818 0 0 1-.818.818Zm6.273-7.362v6.545a.818.818 0 0 1-1.636 0V10.87a.818.818 0 0 1 1.636 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 876 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="21" fill="none" viewBox="0 0 12 21">
<path fill="#FFC16E" d="M2.098 4.195a2.099 2.099 0 1 1 .001-4.197 2.099 2.099 0 0 1-.001 4.197Zm0-1.399a.699.699 0 1 0 0-1.397.699.699 0 0 0 0 1.397Zm6.993 1.4a2.099 2.099 0 1 1 .002-4.198 2.099 2.099 0 0 1-.002 4.197Zm0-1.4a.699.699 0 1 0 0-1.397.699.699 0 0 0 0 1.397Zm-6.993 9.79a2.099 2.099 0 1 1 .001-4.197 2.099 2.099 0 0 1-.001 4.197Zm0-1.4a.699.699 0 1 0 0-1.397.699.699 0 0 0 0 1.398Zm6.993 1.4a2.099 2.099 0 1 1 .002-4.197 2.099 2.099 0 0 1-.002 4.197Zm0-1.4a.699.699 0 1 0 0-1.397.699.699 0 0 0 0 1.398Zm-6.993 9.79a2.099 2.099 0 0 1 0-4.195 2.099 2.099 0 0 1 0 4.196Zm0-1.399a.699.699 0 1 0 0-1.397.699.699 0 0 0 0 1.397Zm6.993 1.4a2.099 2.099 0 0 1 0-4.196 2.099 2.099 0 0 1 0 4.196Zm0-1.4a.699.699 0 1 0 0-1.397.699.699 0 0 0 0 1.397Z"/>
</svg>

After

Width:  |  Height:  |  Size: 857 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="3.2 4.8 17.6 17" xmlns="http://www.w3.org/2000/svg">
<path fill="#FFC16E" d="M4 20h5.2l10-10a1.8 1.8 0 0 0 0-2.55l-2.65-2.64a1.8 1.8 0 0 0-2.55 0L4 14.8V20zm1.8-4.47l8.92-8.92 1.67 1.67-8.92 8.92H5.8v-1.67zm10.2-9.8l.9-.9.95.95-.9.9-.95-.95zM3.2 21.8h17.6v-1.8H3.2v1.8z"/>
</svg>

After

Width:  |  Height:  |  Size: 319 B

View File

@@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="#000" d="M3 5.2h11.2V7H3V5.2zm0 5h8.6V12H3v-1.8zm0 5h7V17H3v-1.8zm9.9 5.3h2.8l5.34-5.34a1.45 1.45 0 0 0 0-2.05l-1.72-1.72a1.45 1.45 0 0 0-2.05 0l-5.35 5.34v2.77zm1.2-2.26l4.58-4.58 1.2 1.2-4.58 4.58h-1.2v-1.2z"/>
</svg>

After

Width:  |  Height:  |  Size: 318 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
<path fill="#FFC16E" d="M20.977 9.325a.963.963 0 0 0-.963.965v8.815a.966.966 0 0 1-.963.965H4.538a.966.966 0 0 1-.963-.965v-4.897h.685a.963.963 0 0 0 .963-.965.963.963 0 0 0-.963-.965H.963a.963.963 0 0 0-.963.965c0 .534.43.965.963.965h.685v4.897A2.897 2.897 0 0 0 4.538 22H19.05c1.592 0 2.89-1.3 2.89-2.895V10.29a.963.963 0 0 0-.964-.965ZM8.164 9.61l-1.278 3.419c-.218.583-.066 1.233.396 1.696.323.324.736.496 1.154.496.182 0 .364-.033.54-.1l3.411-1.28c.33-.124.621-.31.867-.557l8.153-8.17c.405-.405.613-.982.568-1.578a2.358 2.358 0 0 0-.694-1.486L19.935.7A2.35 2.35 0 0 0 18.45.007a2.006 2.006 0 0 0-1.575.568L8.72 8.741a2.427 2.427 0 0 0-.556.869Zm1.804.678a.487.487 0 0 1 .114-.18l8.155-8.172a.118.118 0 0 1 .052-.008h.017a.447.447 0 0 1 .265.135l1.347 1.349c.079.08.128.178.134.266.003.043-.006.066-.008.068L11.89 11.92a.505.505 0 0 1-.18.114L8.924 13.08l1.044-2.792ZM.963 6.9H4.26a.963.963 0 0 0 .963-.965.963.963 0 0 0-.963-.965h-.685V2.934c0-.532.432-.966.963-.966h7.256a.963.963 0 0 0 .963-.965.963.963 0 0 0-.963-.965H4.538c-1.593 0-2.89 1.3-2.89 2.896V4.97H.963A.963.963 0 0 0 0 5.936c0 .534.43.965.963.965Zm0 3.653H4.26a.963.963 0 0 0 .963-.965.963.963 0 0 0-.963-.966H.963A.963.963 0 0 0 0 9.59c0 .534.43.965.963.965Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="#FFC16E" d="m0 24 2.496-11.112 13.8-.864-13.8-.936L0 0l24 12L0 24Z"/>
</svg>

After

Width:  |  Height:  |  Size: 187 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="27" height="36" fill="none" viewBox="0 0 27 36">
<g opacity="1">
<path fill="#FFC16E" d="M13.326 26.661a8.254 8.254 0 0 0 8.246-8.246V8.245a8.246 8.246 0 1 0-16.491 0v10.17a8.254 8.254 0 0 0 8.245 8.246Z"/>
<path fill="#FFC16E" d="M22.759 26.932a13.487 13.487 0 0 0 3.894-9.47V11.64a1.47 1.47 0 1 0-2.941 0v5.822a10.453 10.453 0 0 1-10.386 10.513A10.458 10.458 0 0 1 2.941 17.462V11.64a1.47 1.47 0 0 0-2.941 0v5.822a13.483 13.483 0 0 0 3.898 9.47 13.242 13.242 0 0 0 7.966 3.882v2.245h-6.16a1.47 1.47 0 0 0 0 2.941h15.254a1.47 1.47 0 1 0 0-2.94h-6.161v-2.246a13.263 13.263 0 0 0 7.962-3.882Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 666 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="#fff" d="M17.053 10.8h-7.24l3.043-3.045a1.216 1.216 0 0 0 .024-1.718 1.216 1.216 0 0 0-1.72.02l-5.064 5.066c-.011.01-.026.013-.037.024a1.185 1.185 0 0 0-.342.866c0 .305.112.611.344.841.01.01.022.013.032.021l5.067 5.067c.482.48 1.251.493 1.72.024a1.216 1.216 0 0 0-.024-1.718L9.811 13.2h7.242c.68 0 1.232-.538 1.232-1.2 0-.662-.552-1.2-1.232-1.2Z"/>
<path fill="#fff" d="M12 0A11.998 11.998 0 0 0 0 12c0 6.629 5.371 12 12 12s12-5.371 12-12S18.629 0 12 0Zm0 21.6a9.599 9.599 0 1 1 0-19.198A9.599 9.599 0 0 1 12 21.6Z"/>
</svg>

After

Width:  |  Height:  |  Size: 637 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
<path fill="#FFC16E" d="M10.937 0C4.897 0 0 4.896 0 10.937c0 6.04 4.896 10.936 10.937 10.936 6.04 0 10.936-4.896 10.936-10.936S16.977 0 10.937 0Zm5.694 14.507a1.364 1.364 0 0 1 0 1.923l-.481.48a1.364 1.364 0 0 1-1.923 0l-3.43-3.43-3.43 3.43a1.364 1.364 0 0 1-1.924 0l-.48-.48a1.364 1.364 0 0 1 0-1.923l3.43-3.43-3.71-3.71a1.364 1.364 0 0 1 0-1.924l.48-.48a1.364 1.364 0 0 1 1.924 0l3.71 3.71 3.71-3.71a1.364 1.364 0 0 1 1.923 0l.48.48a1.364 1.364 0 0 1 0 1.923l-3.71 3.71 3.43 3.43Z"/>
</svg>

After

Width:  |  Height:  |  Size: 591 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="24" fill="none" viewBox="0 0 22 24">
<path fill="#FFC16E" d="M14.071.652a3.268 3.268 0 0 0-1.694-.614c-.444-.03-.89-.042-1.335-.036h-.27c-.43-.008-.862.004-1.292.036a3.266 3.266 0 0 0-1.726.64c-.346.276-.633.62-.841 1.011-.2.35-.398.786-.622 1.28l-.39.85h-4.81a1.091 1.091 0 1 0 0 2.183h.818V19.91A4.09 4.09 0 0 0 6 24h9.818a4.09 4.09 0 0 0 4.09-4.09V6.002h.818a1.092 1.092 0 0 0 0-2.18h-4.71l-.465-.961c-.195-.42-.408-.833-.638-1.235a3.254 3.254 0 0 0-.841-.974Zm-.48 3.17H8.3c.154-.358.323-.708.507-1.051a1.003 1.003 0 0 1 .87-.56c.291-.026.67-.026 1.27-.026.586 0 .955 0 1.237.026a1.004 1.004 0 0 1 .859.539c.144.237.303.56.55 1.071Zm-5.41 14.41a.818.818 0 0 1-.818-.817V10.87a.818.818 0 0 1 1.636 0v6.545a.818.818 0 0 1-.818.818Zm6.273-7.362v6.545a.818.818 0 0 1-1.636 0V10.87a.818.818 0 0 1 1.636 0Z"/>
</svg>

After

Width:  |  Height:  |  Size: 876 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="23" fill="none" viewBox="0 0 24 23">
<path fill="#E6F0FF" d="M22.313 6.835c0-1.14-.91-2.05-2.05-2.05h-4.098V1.94c0-1.14-.89-1.94-2.03-1.94-1.14 0-2.07.8-2.07 1.94v2.846H8.199c-1.14 0-2.05.91-2.05 2.05v2.049h16.274v-2.05h-.11Zm0 4.1H6.039S4.899 23 0 23h5.809c2.28 0 3.298-6.597 3.298-6.597s1.019 5.918.11 6.597h3.528c1.589-.23 1.819-7.506 1.819-7.506s1.589 7.397 1.37 7.397h-3.189 5.009c1.479-.34 1.588-5.688 1.588-5.688s.679 5.688.46 5.688h-2.158 2.729c4.098.109 4.329-5.578 1.94-11.957Z"/>
</svg>

After

Width:  |  Height:  |  Size: 559 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="22" fill="none" viewBox="0 0 24 22">
<path fill="#E6F0FF" d="M9.7 8.708c-.15-.458-.25-.916-.4-1.375-.3 1.146-.65 2.384-1 3.484l-.35 1.1h2.65l-.35-1.1a32.72 32.72 0 0 0-.55-2.109Z"/>
<path fill="#E6F0FF" d="M24 6.417v-5.5C24 .412 23.55 0 23 0h-6v1.833H7V0H1C.45 0 0 .412 0 .917v5.5h2v9.166H0v5.5C0 21.588.45 22 1 22h6v-1.833h10V22h6c.55 0 1-.413 1-.917v-5.5h-2V6.417h2Zm-9.3 10.129-.05.046H12.1c-.05 0-.05 0-.05-.046l-.85-2.796H7.45l-.85 2.796c0 .046-.05.046-.05.046H4.1s-.05 0-.05-.046V16.5L7.9 5.454c0-.046.05-.046.05-.046h2.85c.05 0 .05 0 .05.046L14.7 16.5v.046Zm3.85-.046c0 .046-.05.046-.1.046h-2.4c-.05 0-.1-.046-.1-.046V5.454c0-.046.05-.046.1-.046h2.4c.05 0 .1.046.1.046V16.5Z"/>
</svg>

After

Width:  |  Height:  |  Size: 755 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="#E6F0FF" d="M22.286 9.429h-1.213a9.45 9.45 0 0 0-.857-2.023l.857-.857a1.715 1.715 0 0 0 0-2.422L19.877 2.91a1.714 1.714 0 0 0-2.421 0l-.857.857a9.226 9.226 0 0 0-2.028-.836V1.714A1.713 1.713 0 0 0 12.857 0h-1.714a1.714 1.714 0 0 0-1.714 1.714v1.217a9.227 9.227 0 0 0-2.023.836l-.857-.857a1.714 1.714 0 0 0-2.422 0L2.91 4.123a1.714 1.714 0 0 0 0 2.421l.857.857a9.45 9.45 0 0 0-.857 2.023H1.714A1.714 1.714 0 0 0 0 11.14v1.714a1.714 1.714 0 0 0 1.714 1.714h1.213a9.45 9.45 0 0 0 .857 2.023l-.857.857a1.714 1.714 0 0 0 0 2.422l1.213 1.21a1.714 1.714 0 0 0 2.421 0l.858-.857c.634.36 1.309.644 2.01.845v1.217A1.714 1.714 0 0 0 11.143 24h1.714a1.713 1.713 0 0 0 1.714-1.714v-1.217a9.233 9.233 0 0 0 2.023-.836l.857.857a1.714 1.714 0 0 0 2.422 0l1.213-1.213a1.714 1.714 0 0 0 0-2.421l-.857-.857a9.45 9.45 0 0 0 .857-2.023h1.2A1.714 1.714 0 0 0 24 12.86v-1.718a1.714 1.714 0 0 0-1.714-1.714Zm-7.822 4.954a3.429 3.429 0 1 1-4.93-4.767 3.429 3.429 0 0 1 4.93 4.767Z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
<path fill="#E6F0FF" d="M11.023 0c6.08.04 10.993 4.985 10.964 11.031C21.957 17.103 17 22.035 10.96 22 4.86 21.966-.057 16.988 0 10.908.06 4.868 5.02-.04 11.024 0ZM4.145 14.27c.096 1.317.73 2.444 2.067 3.066 1.308.61 2.626.497 3.711-.45 1.142-.995 2.204-2.094 3.21-3.229.831-.938.926-2.095.55-3.277-.247-.77-.71-1.066-1.318-.872-.575.185-.77.655-.556 1.417.18.643.135 1.24-.344 1.732-.864.89-1.727 1.783-2.63 2.63-.696.65-1.661.612-2.268-.013-.625-.642-.628-1.629.01-2.33.308-.34.655-.646.968-.982.4-.428.398-1.026.018-1.397-.362-.352-.942-.363-1.355.019a17.04 17.04 0 0 0-1.22 1.24c-.574.658-.835 1.444-.843 2.446Zm4.118-3.929c.014.135.027.396.072.65.03.176.093.347.159.514.254.642.763.924 1.302.73.497-.178.725-.72.525-1.343-.244-.767-.076-1.41.488-1.973.793-.79 1.575-1.594 2.374-2.38.687-.676 1.679-.714 2.318-.108.665.629.682 1.635.025 2.37-.293.328-.625.62-.925.942-.426.458-.437.999-.047 1.39.406.407.947.434 1.387.014.462-.442.92-.895 1.312-1.396 1.093-1.394.914-3.464-.372-4.695-1.305-1.25-3.353-1.363-4.696-.181-1.034.91-2.005 1.896-2.966 2.885-.65.671-.95 1.516-.956 2.581Z"/>
<path fill="#E6F0FF" d="M20.166 5.268c0-.11-.002-.219-.004-.328A11.05 11.05 0 0 0 11.022 0C5.019-.04.059 4.87 0 10.908c-.037 3.828 1.898 7.217 4.86 9.212.148.004.297.006.445.006 8.207.002 14.86-6.65 14.86-14.858Zm-7.033 8.39c-1.007 1.133-2.068 2.233-3.21 3.23-1.085.945-2.404 1.057-3.711.449-1.338-.622-1.97-1.75-2.067-3.067.007-1.002.269-1.788.845-2.444a17.04 17.04 0 0 1 1.219-1.24c.413-.382.993-.373 1.355-.02.38.373.382.97-.018 1.399-.313.336-.66.643-.969.983-.637.7-.634 1.687-.009 2.33.607.624 1.574.663 2.267.011.905-.847 1.766-1.74 2.63-2.629.48-.492.526-1.09.345-1.732-.215-.762-.019-1.232.556-1.417.607-.194 1.072.102 1.317.872.376 1.18.281 2.337-.55 3.275Zm1.421-2.524c-.391-.392-.38-.933.047-1.39.3-.323.632-.615.925-.943.657-.735.64-1.74-.025-2.37-.638-.604-1.631-.566-2.318.108-.8.786-1.58 1.59-2.374 2.38-.564.561-.732 1.206-.488 1.973.199.624-.028 1.164-.525 1.343-.54.194-1.048-.09-1.302-.73a2.59 2.59 0 0 1-.16-.513c-.044-.255-.057-.516-.07-.65.006-1.065.306-1.909.957-2.58.961-.99 1.933-1.974 2.967-2.885 1.343-1.184 3.39-1.07 4.696.18 1.286 1.231 1.465 3.302.372 4.696-.393.5-.852.954-1.312 1.396-.443.418-.984.39-1.39-.015Z"/>
<path fill="#E6F0FF" d="M11.73 10.517c-.045-.524.17-.86.635-1.008.109-.036.223-.056.337-.058a14.81 14.81 0 0 0 2.111-3.4c-.545-.138-1.16.025-1.629.488-.799.786-1.58 1.59-2.374 2.38-.564.561-.732 1.206-.487 1.973.105.33.09.634-.02.876.503-.387.98-.804 1.427-1.251Z"/>
<path fill="#E6F0FF" d="M.002 10.908c-.014 1.342.22 2.674.69 3.93 1.169.044 2.339-.05 3.486-.279a4.1 4.1 0 0 1-.031-.291c.006-1.003.268-1.789.845-2.445a17.04 17.04 0 0 1 1.218-1.24c.413-.382.994-.373 1.355-.019.381.373.382.97-.017 1.398-.313.336-.66.643-.97.983-.293.324-.45.708-.473 1.09a14.72 14.72 0 0 0 3.485-1.75c-.46.063-.875-.223-1.095-.781a2.593 2.593 0 0 1-.159-.513c-.044-.255-.058-.516-.071-.651.006-1.065.306-1.908.958-2.58.96-.988 1.932-1.974 2.966-2.885.925-.815 2.183-1.015 3.303-.652.283-.955.474-1.95.559-2.976A11.018 11.018 0 0 0 11.026.002C5.02-.04.06 4.869.002 10.908Z"/>
<path fill="#E6F0FF" d="M.127 9.362c5.187-.924 9.441-4.54 11.27-9.35A15.24 15.24 0 0 0 11.023 0C5.543-.036.933 4.053.127 9.362Z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
<path fill="#E6F0FF" d="M11.005 0C4.93 0 0 4.924 0 11c0 6.074 4.93 11 11.005 11 6.071 0 11-4.926 11-11 0-6.076-4.929-11-11-11Zm5.5 12.373h-4.129V16.5H9.63v-4.127H5.506v-2.75h4.123V5.5h2.747v4.124h4.13v2.75Z"/>
</svg>

After

Width:  |  Height:  |  Size: 315 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
<path fill="#E6F0FF" d="M11.005 0C4.93 0 0 4.924 0 11c0 6.074 4.93 11 11.005 11 6.071 0 11-4.926 11-11 0-6.076-4.929-11-11-11Zm5.5 12.373H5.506v-2.75h11v2.75Z"/>
</svg>

After

Width:  |  Height:  |  Size: 267 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="21" height="17" fill="none" viewBox="0 0 21 17">
<path fill="#FFC16E" stroke="#FFC16E" stroke-width=".5" d="m14.422.25.312.008a6.495 6.495 0 0 1 4.156 1.75c1.187 1.126 1.858 2.655 1.86 4.254l-.008.3a5.89 5.89 0 0 1-1.852 3.955 6.509 6.509 0 0 1-4.468 1.757H3.134l3.248 3.079H6.38a.783.783 0 0 1 .016 1.166.846.846 0 0 1-.603.231.867.867 0 0 1-.588-.247L.5 12.044a.795.795 0 0 1-.25-.576c0-.219.092-.425.25-.575L5.206 6.43l.007-.006a.857.857 0 0 1 1.154.02.794.794 0 0 1 .25.56.792.792 0 0 1-.23.57l-.005.007H6.38l-3.246 3.077h11.287a4.793 4.793 0 0 0 3.295-1.292 4.278 4.278 0 0 0 1.357-3.104 4.278 4.278 0 0 0-1.357-3.105 4.794 4.794 0 0 0-3.295-1.293H5.793a.855.855 0 0 1-.588-.231.794.794 0 0 1-.25-.576c0-.219.092-.426.25-.577a.855.855 0 0 1 .588-.23h8.629Z"/>
</svg>

After

Width:  |  Height:  |  Size: 821 B

View File

@@ -0,0 +1,20 @@
<svg preserveAspectRatio="none" width="100%" height="100%" overflow="visible" style="display: block;" viewBox="0 0 76 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="Frame 2249">
<g id="Vector">
<path d="M38 20C32.477 20 28 15.523 28 10C28 4.477 32.477 0 38 0C43.523 0 48 4.477 48 10C48 15.523 43.523 20 38 20ZM38 18C42.4185 18 46 14.4185 46 10C46 5.5815 42.4185 2 38 2C33.5815 2 30 5.5815 30 10C30 14.4185 33.5815 18 38 18Z" fill="#FFC16E"/>
<path d="M37.2855 7.3395C37.3758 7.23686 37.4862 7.15382 37.6098 7.09552C37.7335 7.03722 37.8678 7.00489 38.0044 7.00053C38.1411 6.99617 38.2772 7.01988 38.4043 7.07018C38.5314 7.12049 38.6468 7.19632 38.7435 7.293L42.279 10.828C42.3719 10.9208 42.4456 11.0311 42.4959 11.1524C42.5462 11.2737 42.5722 11.4038 42.5722 11.5351C42.5723 11.6665 42.5464 11.7966 42.4962 11.9179C42.446 12.0393 42.3723 12.1496 42.2795 12.2425C42.1867 12.3354 42.0764 12.4091 41.9551 12.4594C41.8338 12.5097 41.7037 12.5357 41.5724 12.5357C41.441 12.5358 41.3109 12.5099 41.1896 12.4597C41.0682 12.4095 40.9579 12.3358 40.865 12.243L38.038 9.4155L35.207 12.2465C35.0194 12.434 34.765 12.5393 34.4998 12.5393C34.2346 12.5392 33.9803 12.4338 33.7928 12.2463C33.6052 12.0587 33.4999 11.8043 33.5 11.5391C33.5 11.2739 33.6054 11.0195 33.793 10.832L37.2855 7.3395Z" fill="#FFC16E"/>
</g>
<g id="Vector_2">
<path d="M56 38C56 32.477 60.477 28 66 28C71.523 28 76 32.477 76 38C76 43.523 71.523 48 66 48C60.477 48 56 43.523 56 38ZM58 38C58 42.4185 61.5815 46 66 46C70.4185 46 74 42.4185 74 38C74 33.5815 70.4185 30 66 30C61.5815 30 58 33.5815 58 38Z" fill="#FFC16E"/>
<path d="M68.6605 37.2855C68.7631 37.3758 68.8462 37.4862 68.9045 37.6098C68.9628 37.7335 68.9951 37.8678 68.9995 38.0044C69.0038 38.1411 68.9801 38.2772 68.9298 38.4043C68.8795 38.5314 68.8037 38.6468 68.707 38.7435L65.172 42.279C65.0792 42.3719 64.9689 42.4456 64.8476 42.4959C64.7263 42.5462 64.5962 42.5722 64.4649 42.5722C64.3335 42.5723 64.2034 42.5464 64.0821 42.4962C63.9607 42.446 63.8504 42.3723 63.7575 42.2795C63.6646 42.1867 63.5909 42.0764 63.5406 41.9551C63.4903 41.8338 63.4643 41.7037 63.4643 41.5724C63.4642 41.441 63.4901 41.3109 63.5403 41.1896C63.5905 41.0682 63.6642 40.9579 63.757 40.865L66.5845 38.038L63.7535 35.207C63.566 35.0194 63.4607 34.765 63.4607 34.4998C63.4608 34.2346 63.5662 33.9803 63.7537 33.7928C63.9413 33.6052 64.1957 33.4999 64.4609 33.5C64.7261 33.5 64.9805 33.6054 65.168 33.793L68.6605 37.2855Z" fill="#FFC16E"/>
</g>
<g id="Vector_3">
<path d="M20 38C20 43.523 15.523 48 10 48C4.477 48 0 43.523 0 38C0 32.477 4.477 28 10 28C15.523 28 20 32.477 20 38ZM18 38C18 33.5815 14.4185 30 10 30C5.5815 30 2 33.5815 2 38C2 42.4185 5.5815 46 10 46C14.4185 46 18 42.4185 18 38Z" fill="#FFC16E"/>
<path d="M7.3395 38.7145C7.23686 38.6242 7.15382 38.5138 7.09552 38.3902C7.03722 38.2665 7.00489 38.1322 7.00053 37.9956C6.99617 37.8589 7.01988 37.7228 7.07018 37.5957C7.12049 37.4686 7.19632 37.3532 7.293 37.2565L10.828 33.721C10.9208 33.6281 11.0311 33.5544 11.1524 33.5041C11.2737 33.4538 11.4038 33.4278 11.5351 33.4278C11.6665 33.4277 11.7966 33.4536 11.9179 33.5038C12.0393 33.554 12.1496 33.6277 12.2425 33.7205C12.3354 33.8133 12.4091 33.9236 12.4594 34.0449C12.5097 34.1662 12.5357 34.2963 12.5357 34.4276C12.5358 34.559 12.5099 34.6891 12.4597 34.8104C12.4095 34.9318 12.3358 35.0421 12.243 35.135L9.4155 37.962L12.2465 40.793C12.434 40.9806 12.5393 41.235 12.5393 41.5002C12.5392 41.7654 12.4338 42.0197 12.2463 42.2072C12.0587 42.3948 11.8043 42.5001 11.5391 42.5C11.2739 42.5 11.0195 42.3946 10.832 42.207L7.3395 38.7145Z" fill="#FFC16E"/>
</g>
<g id="Vector_4">
<path d="M38 28C43.523 28 48 32.477 48 38C48 43.523 43.523 48 38 48C32.477 48 28 43.523 28 38C28 32.477 32.477 28 38 28ZM38 30C33.5815 30 30 33.5815 30 38C30 42.4185 33.5815 46 38 46C42.4185 46 46 42.4185 46 38C46 33.5815 42.4185 30 38 30Z" fill="#FFC16E"/>
<path d="M38.7145 40.6605C38.6242 40.7631 38.5138 40.8462 38.3902 40.9045C38.2665 40.9628 38.1322 40.9951 37.9956 40.9995C37.8589 41.0038 37.7228 40.9801 37.5957 40.9298C37.4686 40.8795 37.3532 40.8037 37.2565 40.707L33.721 37.172C33.6281 37.0792 33.5544 36.9689 33.5041 36.8476C33.4538 36.7263 33.4278 36.5962 33.4278 36.4649C33.4277 36.3335 33.4536 36.2034 33.5038 36.0821C33.554 35.9607 33.6277 35.8504 33.7205 35.7575C33.8133 35.6646 33.9236 35.5909 34.0449 35.5406C34.1662 35.4903 34.2963 35.4643 34.4276 35.4643C34.559 35.4642 34.6891 35.4901 34.8104 35.5403C34.9318 35.5905 35.0421 35.6642 35.135 35.757L37.962 38.5845L40.793 35.7535C40.9806 35.566 41.235 35.4607 41.5002 35.4607C41.7654 35.4608 42.0197 35.5662 42.2072 35.7537C42.3948 35.9413 42.5001 36.1957 42.5 36.4609C42.5 36.7261 42.3946 36.9805 42.207 37.168L38.7145 40.6605Z" fill="#FFC16E"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.2 KiB

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
<path fill="#FFC16E" fill-rule="evenodd" d="M11.299 14.804c1.045 0 1.901.316 2.545.961.646.647.956 1.537.956 2.646 0 1.031-.271 1.873-.833 2.506l-.116.124c-.64.645-1.491.959-2.535.959-.987 0-1.803-.275-2.429-.838l-.122-.116c-.64-.642-.95-1.518-.95-2.603 0-.69.105-1.282.327-1.769.164-.356.39-.676.672-.959.196-.2.422-.37.669-.505l.264-.128.016-.005c.45-.184.964-.273 1.536-.273Zm2.181 6.32a2.888 2.888 0 0 1 .119-.101l-.119.101Zm-4.865-.544a2.745 2.745 0 0 0-.017-.024l.017.024Zm2.697-4.265c-.517 0-.92.168-1.227.502-.303.329-.471.844-.471 1.58 0 .723.171 1.237.485 1.576.318.346.718.517 1.213.517.496 0 .893-.17 1.206-.511.308-.335.479-.857.479-1.599 0-.642-.128-1.114-.36-1.439l-.105-.13c-.301-.329-.702-.496-1.22-.496Zm-3.29 1.702c-.003.034-.004.069-.006.104l.01-.162-.004.058Zm5.896-1.886a2.836 2.836 0 0 1-.102-.121l.102.121Zm4.908-1.327c.868 0 1.575.176 2.094.551.515.373.845.888.99 1.535l.042.187-.194.035-1.365.247-.174.032-.046-.166a1.225 1.225 0 0 0-.466-.665l-.09-.058a1.542 1.542 0 0 0-.79-.19c-.557 0-.98.17-1.293.495v-.001c-.31.323-.48.818-.48 1.518 0 .665.134 1.158.38 1.5l.11.138.002.001c.318.349.735.525 1.267.525.263 0 .527-.049.795-.151h.003a2.79 2.79 0 0 0 .62-.323v-.555h-1.318l-.258.188v-1.67H22v2.894l-.058.055c-.313.293-.755.543-1.315.754v-.001A4.786 4.786 0 0 1 18.9 22h-.001c-.74-.001-1.394-.15-1.957-.458a3.006 3.006 0 0 1-1.264-1.308l-.004-.009-.003-.007-.097-.214a4.038 4.038 0 0 1-.316-1.355l-.005-.233v-.038c0-.714.155-1.355.468-1.92a3.177 3.177 0 0 1 1.37-1.299l.007-.003.018-.008c.47-.233 1.043-.344 1.71-.344Zm-3.19 2.321-.01.034a2.55 2.55 0 0 1 .07-.197l-.06.163Z" clip-rule="evenodd"/>
<path fill="#FFC16E" d="M4.079 14.97v5.435h3.417v1.483H2.051l.272-.263V14.97h1.756Z"/>
<path fill="#FFC16E" fill-rule="evenodd" d="M16.812.004c1.383.048 2.591 1.306 2.591 2.734v11.467h-1.957V2.501a.6.6 0 0 0-.227-.444.777.777 0 0 0-.489-.194H2.562c-.315 0-.596.272-.596.638v19.186l-.002.183H0l.008-.193c.012-.254.013-.867.01-1.422a170.26 170.26 0 0 0-.006-.727c0-.095-.002-.173-.003-.227V2.736C.008 1.294 1.132 0 2.56 0h14.248l.003.004Z" clip-rule="evenodd"/>
<path fill="#FFC16E" d="M16.24 11.13H3.177V9.22H16.24v1.91Zm0-4.61H3.177V4.61H16.24v1.91Z"/>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="19" height="20" fill="none" viewBox="0 0 19 20">
<path fill="#FFC16E" d="M17 9h-7a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h7a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2Zm1-2V4a2 2 0 0 0-2-2h-2a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2H2a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4V9a2 2 0 0 1 2-2h10ZM6 4V2h6v2H6Z"/>
</svg>

After

Width:  |  Height:  |  Size: 326 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22.0016 22">
<path
fill="#E6F0FF"
d="M9.00219 0C9.48228 3.80706e-05 9.95536 0.11529 10.3817 0.336074C10.808 0.556857 11.1751 0.876725 11.4522 1.26881C11.7292 1.66089 11.9081 2.11374 11.9739 2.58931C12.0396 3.06488 11.9903 3.54929 11.83 4.00184H16.0011C17.1068 4.00184 18.0013 4.89631 18.0013 6.00042V10.1715C18.4538 10.0115 18.9381 9.96244 19.4135 10.0284C19.889 10.0943 20.3416 10.2734 20.7335 10.5505C21.1254 10.8276 21.4451 11.1947 21.6658 11.6209C21.8864 12.0472 22.0016 12.5202 22.0016 13.0001C22.0016 13.4801 21.8864 13.9531 21.6658 14.3793C21.4451 14.8056 21.1254 15.1727 20.7335 15.4498C20.3416 15.7269 19.889 15.906 19.4135 15.9719C18.9381 16.0378 18.4538 15.9888 18.0013 15.8288V19.9999C18.0013 21.104 17.1068 22 16.0011 22H11.8316C11.9912 21.5475 12.0399 21.0634 11.9737 20.5882C11.9075 20.113 11.7283 19.6606 11.4512 19.269C11.174 18.8774 10.807 18.5579 10.3809 18.3374C9.95475 18.1169 9.48197 18.0019 9.00219 18.0019C8.52241 18.0019 8.04962 18.1169 7.6235 18.3374C7.19738 18.5579 6.83034 18.8774 6.55319 19.269C6.27604 19.6606 6.09685 20.113 6.03066 20.5882C5.96447 21.0634 6.01322 21.5475 6.1728 22H2.00014C1.46967 22 0.960927 21.7893 0.585828 21.4142C0.210729 21.0391 0 20.5303 0 19.9999V15.8288C0.452526 15.9888 0.936835 16.0378 1.41226 15.9719C1.88769 15.906 2.34036 15.7269 2.73226 15.4498C3.12416 15.1727 3.44386 14.8056 3.66451 14.3793C3.88515 13.9531 4.00031 13.4801 4.00031 13.0001C4.00031 12.5202 3.88515 12.0472 3.66451 11.6209C3.44386 11.1947 3.12416 10.8276 2.73226 10.5505C2.34036 10.2734 1.88769 10.0943 1.41226 10.0284C0.936835 9.96244 0.452526 10.0115 0 10.1715V6.00042C0 4.89631 0.896026 4.00028 2.00014 4.00028H6.17124C6.01145 3.54754 5.96265 3.06305 6.02894 2.58753C6.09523 2.11201 6.27467 1.65935 6.55219 1.26756C6.82971 0.875777 7.1972 0.556317 7.6238 0.33602C8.05039 0.115722 8.52362 0.0010211 9.00374 0.00155293L9.00219 0Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none" viewBox="0 0 20 20">
<path fill="#E6F0FF" d="M1.111 0h14.445l4.118 4.119c.209.208.326.49.326.785V18.89A1.111 1.111 0 0 1 18.889 20H1.11A1.111 1.111 0 0 1 0 18.889V1.11A1.111 1.111 0 0 1 1.111 0ZM10 16.667A3.333 3.333 0 1 0 10 10a3.333 3.333 0 0 0 0 6.667ZM2.222 2.222v4.445h11.111V2.222H2.223Z"/>
</svg>

After

Width:  |  Height:  |  Size: 381 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" fill="none" viewBox="0 0 15 15">
<path
fill="#E6F0FF"
d="M9.322 8.255h-.564l-.2-.194a4.648 4.648 0 1 0-.5.5l.194.2v.564l3.57 3.563 1.064-1.064-3.563-3.57Zm-4.288 0a3.222 3.222 0 1 1 0-6.444 3.222 3.222 0 0 1 0 6.444Z"
/>
</svg>

After

Width:  |  Height:  |  Size: 303 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="22" fill="none" viewBox="0 0 22 22">
<path fill="#E6F0FF" fill-rule="evenodd" d="M20.428 16.62c.866 0 1.572.602 1.572 1.346v2.688c0 .742-.703 1.346-1.572 1.346H1.572C.706 22 0 21.398 0 20.654v-2.691c0-.742.703-1.344 1.572-1.344h18.856Zm-4.086 2.61-1.292-1.098-1.05.891L16.326 21 20 17.879 18.966 17l-2.624 2.23ZM5.235 17.963c-.866 0-1.572.601-1.572 1.346 0 .744.703 1.345 1.572 1.345.867 0 1.574-.601 1.574-1.345 0-.745-.704-1.346-1.574-1.346Zm15.193-9.651c.866 0 1.572.601 1.572 1.345v2.689c0 .741-.703 1.345-1.572 1.345H1.572C.706 13.691 0 13.09 0 12.346V9.654c0-.741.703-1.342 1.572-1.342h18.856Zm-4.086 2.918-1.292-1.098-1.05.891L16.326 13 20 9.879 18.966 9l-2.624 2.23ZM5.235 9.653c-.866 0-1.572.602-1.572 1.346 0 .744.703 1.346 1.572 1.346.867 0 1.574-.602 1.574-1.346 0-.744-.704-1.346-1.574-1.346ZM20.428 0C21.294 0 22 .602 22 1.346v2.688c0 .742-.703 1.347-1.572 1.347H1.572C.706 5.38 0 4.778 0 4.034V1.343C0 .6.703 0 1.572 0h18.856Zm-4.086 3.23L15.05 2.131 14 3.023 16.326 5 20 1.879 18.966 1l-2.624 2.23ZM5.235 1.342c-.866 0-1.572.602-1.572 1.345 0 .745.703 1.346 1.572 1.346.867 0 1.574-.601 1.574-1.346 0-.744-.704-1.345-1.574-1.345Z" clip-rule="evenodd"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
<path fill="#FFC16E" d="m0 24 2.496-11.112 13.8-.864-13.8-.936L0 0l24 12L0 24Z"/>
</svg>

After

Width:  |  Height:  |  Size: 187 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="22" height="20" fill="none" viewBox="0 0 22 20">
<path fill="#fff" d="M2.2 4.4a2.2 2.2 0 1 0 0-4.4 2.2 2.2 0 0 0 0 4.4ZM8.25.55a1.65 1.65 0 0 0 0 3.3h12.1a1.65 1.65 0 1 0 0-3.3H8.25ZM6.6 9.9a1.65 1.65 0 0 1 1.65-1.65h12.1a1.65 1.65 0 1 1 0 3.3H8.25A1.65 1.65 0 0 1 6.6 9.9Zm0 7.7a1.65 1.65 0 0 1 1.65-1.65h12.1a1.65 1.65 0 1 1 0 3.3H8.25A1.65 1.65 0 0 1 6.6 17.6ZM4.4 9.9a2.2 2.2 0 1 1-4.4 0 2.2 2.2 0 0 1 4.4 0Zm-2.2 9.9a2.2 2.2 0 1 0 0-4.4 2.2 2.2 0 0 0 0 4.4Z" opacity=".99"/>
</svg>

After

Width:  |  Height:  |  Size: 536 B

View File

@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" width="27" height="36" fill="none" viewBox="0 0 27 36">
<g opacity="1">
<path fill="#FFC16E" d="M13.326 26.661a8.254 8.254 0 0 0 8.246-8.246V8.245a8.246 8.246 0 1 0-16.491 0v10.17a8.254 8.254 0 0 0 8.245 8.246Z"/>
<path fill="#FFC16E" d="M22.759 26.932a13.487 13.487 0 0 0 3.894-9.47V11.64a1.47 1.47 0 1 0-2.941 0v5.822a10.453 10.453 0 0 1-10.386 10.513A10.458 10.458 0 0 1 2.941 17.462V11.64a1.47 1.47 0 0 0-2.941 0v5.822a13.483 13.483 0 0 0 3.898 9.47 13.242 13.242 0 0 0 7.966 3.882v2.245h-6.16a1.47 1.47 0 0 0 0 2.941h15.254a1.47 1.47 0 1 0 0-2.94h-6.161v-2.246a13.263 13.263 0 0 0 7.962-3.882Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 666 B

556
pxterm/src/App.vue Normal file
View File

@@ -0,0 +1,556 @@
<template>
<div class="app-shell" :class="{ 'keyboard-open': keyboardOpen }">
<section
class="app-canvas"
:class="{ 'without-bottom-bar': hideBottomFrame, 'bottom-frame-hidden': hideBottomFrame }"
:style="{ gridTemplateRows: hideBottomFrame ? 'minmax(0, 1fr)' : 'minmax(0, 1fr) 52px' }"
>
<main class="screen-content">
<RouterView />
</main>
<footer v-if="!hideBottomFrame" class="bottom-bar">
<button
class="icon-btn bottom-nav-btn"
type="button"
title="返回上一页"
aria-label="返回上一页"
:disabled="!canGoBack"
@click="goBack"
>
<span class="icon-mask" style="--icon: url('/icons/back.svg')" aria-hidden="true"></span>
</button>
<div class="bottom-right-actions">
<button
v-for="item in rightNavItems"
:key="item.path"
class="icon-btn bottom-nav-btn"
type="button"
:class="{ active: isActive(item.path) }"
:title="item.label"
:aria-label="item.label"
@click="goTo(item.path)"
>
<span class="icon-mask" :style="`--icon: url(${item.icon})`" aria-hidden="true"></span>
</button>
</div>
</footer>
</section>
<section class="toast-stack" :class="{ 'terminal-toast': isTerminalRoute }" aria-live="polite">
<article v-for="toast in appStore.toasts" :key="toast.id" class="toast-item" :class="`toast-${toast.level}`">
<header class="toast-title">{{ toToastTitle(toast.level) }}</header>
<p class="toast-message">{{ toast.message }}</p>
</article>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { RouterView, useRoute, useRouter } from "vue-router";
import { useSettingsStore } from "@/stores/settingsStore";
import { useAppStore } from "@/stores/appStore";
import { toToastTitle } from "@/utils/feedback";
const settingsStore = useSettingsStore();
const appStore = useAppStore();
const route = useRoute();
const router = useRouter();
const keyboardOpen = ref(false);
const formFieldFocused = ref(false);
const touchLikeDevice = ref(false);
const canGoBack = ref(false);
const inputFocusSessionActive = ref(false);
type FocusViewportSession = {
target: HTMLElement;
scrollContainer: HTMLElement | null;
};
const focusViewportSession = ref<FocusViewportSession | null>(null);
let ensureVisibleRafId: number | null = null;
let ensureVisibleRetryTimer: number | null = null;
let baselineViewportHeight = 0;
const hideGlobalBottomBar = computed(() => /^\/server\/.+\/settings$/.test(route.path));
/**
* 临时隐藏底栏策略(面向移动端):
* - 触屏设备上,只要输入控件聚焦就隐藏底栏(不强依赖 keyboardOpen 判定);
* - 兜底保留 keyboardOpen 条件,兼容部分设备触摸特征识别异常。
*/
const hideBottomBarForKeyboard = computed(
() => inputFocusSessionActive.value && (touchLikeDevice.value || keyboardOpen.value || formFieldFocused.value)
);
/**
* 底部框架层总开关:
* - 页面语义要求隐藏(如 server settings
* - 输入态临时隐藏(软键盘场景)。
* 命中任一条件时,直接移除整块底部框架与占位行。
*/
const hideBottomFrame = computed(() => hideGlobalBottomBar.value || hideBottomBarForKeyboard.value);
const pluginRuntimeEnabled = import.meta.env.VITE_ENABLE_PLUGIN_RUNTIME !== "false";
const isTerminalRoute = computed(() => route.path === "/terminal");
type BottomNavItem = { path: string; label: string; icon: string };
const recordsNavItem: BottomNavItem = { path: "/records", label: "闪念清单", icon: "/assets/icons/recordmanager.svg?v=2026022701" };
function appendRecordsNavItem(items: BottomNavItem[]): BottomNavItem[] {
if (route.path === "/records") {
return items;
}
if (items.some((item) => item.path === recordsNavItem.path)) {
return items;
}
return [...items, recordsNavItem];
}
/**
* 底部工具条统一约束设置入口config.svg始终排在最后。
* 仅调整顺序,不改变各页面实际可见按钮集合。
*/
function ensureConfigNavItemLast(items: BottomNavItem[]): BottomNavItem[] {
const index = items.findIndex((item) => item.path === "/settings");
if (index < 0 || index === items.length - 1) {
return items;
}
const next = items.slice();
const [settingsItem] = next.splice(index, 1);
if (!settingsItem) {
return items;
}
next.push(settingsItem);
return next;
}
const rightNavItems = computed<BottomNavItem[]>(() => {
let items: BottomNavItem[];
if (route.path === "/connect") {
items = appendRecordsNavItem([
{ path: "/settings", label: "全局配置", icon: "/icons/config.svg" },
{ path: "/logs", label: "日志", icon: "/icons/log.svg?v=20260227" }
]);
return ensureConfigNavItemLast(items);
}
if (route.path === "/settings") {
const items = [
{ path: "/connect", label: "服务器管理", icon: "/icons/serverlist.svg" },
{ path: "/logs", label: "日志", icon: "/icons/log.svg?v=20260227" }
];
if (pluginRuntimeEnabled) {
items.push({ path: "/plugins", label: "插件管理", icon: "/icons/plugins.svg" });
}
return ensureConfigNavItemLast(appendRecordsNavItem(items));
}
if (route.path === "/plugins") {
items = appendRecordsNavItem([
{ path: "/connect", label: "服务器管理", icon: "/icons/serverlist.svg" },
{ path: "/settings", label: "全局配置", icon: "/icons/config.svg" }
]);
return ensureConfigNavItemLast(items);
}
if (route.path === "/terminal") {
items = appendRecordsNavItem([
{ path: "/connect", label: "服务器管理", icon: "/icons/serverlist.svg" },
{ path: "/settings", label: "全局配置", icon: "/icons/config.svg" },
{ path: "/logs", label: "日志", icon: "/icons/log.svg?v=20260227" }
]);
return ensureConfigNavItemLast(items);
}
if (route.path === "/records") {
items = [
{ path: "/connect", label: "服务器管理", icon: "/icons/serverlist.svg" },
{ path: "/settings", label: "全局配置", icon: "/icons/config.svg" },
{ path: "/logs", label: "日志", icon: "/icons/log.svg?v=20260227" }
];
return ensureConfigNavItemLast(items);
}
items = appendRecordsNavItem([
{ path: "/connect", label: "服务器管理", icon: "/icons/serverlist.svg" },
{ path: "/settings", label: "全局配置", icon: "/icons/config.svg" }
]);
return ensureConfigNavItemLast(items);
});
function updateViewportLayout(): void {
/**
* 移动端软键盘弹出时visualViewport.height 会变小。
* 将根布局高度绑定到可视区域高度,确保 shell 向上收缩且光标行可见。
*/
const vv = window.visualViewport;
const viewportHeight = Math.round(vv?.height ?? window.innerHeight);
/**
* iOS 键盘弹出时Safari 可能会把 visual viewport 向下平移offsetTop > 0
* 若仅更新高度不处理 offset页面可视区域会“切到容器底部”表现为底栏被顶到顶部覆盖内容。
* 因此这里同步写入偏移量,令根容器跟随 visual viewport 对齐。
*/
const viewportOffsetTop = Math.max(0, Math.round(vv?.offsetTop ?? 0));
const viewportOffsetLeft = Math.max(0, Math.round(vv?.offsetLeft ?? 0));
const viewportWidth = Math.round(vv?.width ?? window.innerWidth);
document.documentElement.style.setProperty("--app-viewport-height", `${viewportHeight}px`);
document.documentElement.style.setProperty("--app-viewport-width", `${viewportWidth}px`);
document.documentElement.style.setProperty("--app-viewport-offset-top", `${viewportOffsetTop}px`);
document.documentElement.style.setProperty("--app-viewport-offset-left", `${viewportOffsetLeft}px`);
document.documentElement.style.setProperty("--focus-scroll-margin-top", `${Math.round(viewportHeight * 0.25)}px`);
/**
* 键盘态判定:
* - 首选“历史最大可视高度 - 当前可视高度”的收缩量;
* - 同时保留 visualViewport 与 innerHeight 差值判定作兜底;
* - 阈值取 120px过滤地址栏收展等小幅抖动。
*
* 说明:部分内核在弹键盘时会同步改写 innerHeight若只看 innerHeight-vv.height
* 可能长期为 0导致 keyboardOpen 假阴性(页面不触发输入态滚动)。
*/
const candidateBaseHeight = Math.max(window.innerHeight, viewportHeight);
if (baselineViewportHeight <= 0) {
baselineViewportHeight = candidateBaseHeight;
}
if (!formFieldFocused.value) {
baselineViewportHeight = Math.max(baselineViewportHeight, candidateBaseHeight);
}
const viewportShrink = Math.max(0, baselineViewportHeight - viewportHeight);
const innerHeightDelta = vv ? Math.max(0, window.innerHeight - vv.height) : 0;
keyboardOpen.value = viewportShrink > 120 || innerHeightDelta > 120;
if (formFieldFocused.value) {
scheduleEnsureFocusedFieldVisible();
}
}
/**
* 判断当前聚焦元素是否为会触发软键盘/编辑态的输入控件。
* 约束:
* - 仅把 input/textarea/select/contenteditable 视为“需要让位”的目标;
* - terminal 页的 xterm helper textarea 由 TerminalPanel 内部状态机接管,
* 这里必须排除,避免“全局滚动补偿”与“终端焦点控制”互相干扰;
* - 普通按钮、链接等不触发底栏隐藏。
*/
function isEditableField(target: EventTarget | null): target is HTMLElement {
if (!(target instanceof HTMLElement)) return false;
if (target.classList.contains("xterm-helper-textarea") || target.closest(".terminal-wrapper, .xterm")) {
return false;
}
if (target.isContentEditable) return true;
return target.matches("input, textarea, select");
}
/**
* 同步“是否有输入控件处于聚焦态”:
* - focusin/focusout 在切换两个 input 时会连续触发;
* - 使用微任务读取最终 activeElement避免中间态闪烁。
*/
function syncFormFieldFocusState(): void {
const active = document.activeElement;
formFieldFocused.value = isEditableField(active);
}
/**
* 向上查找“可纵向滚动容器”:
* - 优先使用最近的 overflowY=auto/scroll 容器;
* - 若找不到,后续回退到 window.scrollBy。
*/
function findScrollableAncestor(node: HTMLElement | null): HTMLElement | null {
let current = node?.parentElement ?? null;
while (current) {
const style = window.getComputedStyle(current);
const overflowY = style.overflowY;
const canScroll = /(auto|scroll|overlay)/.test(overflowY);
/**
* 这里不再要求“当前就可滚动”:
* 键盘弹出前很多容器尚未 overflow
* 若此时返回 null后续会错误回退到 window.scroll页面可能不可滚
*/
if (canScroll) {
return current;
}
current = current.parentElement;
}
return null;
}
/**
* 输入控件进入聚焦态时创建会话:
* - 标记当前目标与可滚动容器;
* - 供后续键盘动画阶段反复做可见性校正;
* - 会话只跟随焦点,不再做“滚动位置回滚”。
*/
function beginFocusViewportSession(target: HTMLElement): void {
focusViewportSession.value?.target.classList.remove("viewport-focus-target");
const scrollContainer = findScrollableAncestor(target);
inputFocusSessionActive.value = true;
target.classList.add("viewport-focus-target");
focusViewportSession.value = {
target,
scrollContainer
};
}
/**
* 若当前 activeElement 是输入控件但会话缺失/已切换目标,则重建会话。
* 目的:兜底浏览器事件顺序差异,保证后续滚动计算有基线。
*/
function ensureFocusViewportSession(): void {
const active = document.activeElement;
if (!isEditableField(active)) {
return;
}
if (focusViewportSession.value?.target === active) {
const latestContainer = findScrollableAncestor(active);
focusViewportSession.value.scrollContainer = latestContainer;
return;
}
beginFocusViewportSession(active);
}
function resolveEffectiveViewportHeight(): number {
return Math.round(window.visualViewport?.height ?? window.innerHeight);
}
/**
* 输入会话结束:
* - 清理会话状态与样式标记;
* - 不回滚滚动位置,避免多输入框切换或路由切换时出现反向跳动。
*/
function endFocusViewportSession(): void {
const session = focusViewportSession.value;
focusViewportSession.value = null;
inputFocusSessionActive.value = false;
session?.target.classList.remove("viewport-focus-target");
}
/**
* 将当前聚焦输入框滚入可视区:
* - 基于滚动容器与 visualViewport 高度计算可见窗口;
* - 键盘弹出场景将输入框顶部对齐到可见区“上到下 1/4”位置。
*/
function ensureFocusedFieldVisible(): void {
ensureFocusViewportSession();
const session = focusViewportSession.value;
if (!session) {
return;
}
if (!document.contains(session.target)) {
endFocusViewportSession();
return;
}
let container = session.scrollContainer;
if (!container || !document.contains(container)) {
container = findScrollableAncestor(session.target);
session.scrollContainer = container;
}
/**
* 键盘态兜底:
* - 某些设备 keyboardOpen 判定会短暂失真,但“触屏 + 输入聚焦”基本可视为软键盘过程;
* - 这里放宽为 likelyOpen避免因为判定误差导致完全不滚动。
*/
const likelyKeyboardOpen = keyboardOpen.value || (touchLikeDevice.value && formFieldFocused.value);
/**
* 先尝试一次原生滚动center 比 start 更稳,能在多数浏览器直接避开键盘遮挡),
* 然后再做几何微调,确保目标落在“上到下约 1/4”的稳定阅读区。
*/
if (likelyKeyboardOpen) {
session.target.scrollIntoView({ block: "center", inline: "nearest", behavior: "auto" });
}
const rect = session.target.getBoundingClientRect();
const viewportHeight = resolveEffectiveViewportHeight();
const containerRect = container?.getBoundingClientRect() ?? null;
const visibleTop = (containerRect?.top ?? 0) + 12;
const visibleBottom = Math.min(containerRect?.bottom ?? viewportHeight, viewportHeight) - 12;
if (visibleBottom - visibleTop < 8) {
return;
}
/**
* 交互策略:
* - likelyKeyboardOpen 时:输入框“顶部”对齐到可见区上到下 1/4
* - 非键盘态:仅在超出可见区时才滚动。
*/
const desiredTop = visibleTop + (visibleBottom - visibleTop) * 0.25;
const delta = rect.top - desiredTop;
const outOfVisibleRange = rect.top < visibleTop || rect.bottom > visibleBottom;
const needAdjust = likelyKeyboardOpen ? Math.abs(delta) > 3 : outOfVisibleRange;
if (!needAdjust || Math.abs(delta) < 1) {
return;
}
if (container && document.contains(container)) {
const before = container.scrollTop;
container.scrollTop += delta;
if (Math.abs(container.scrollTop - before) > 0.5) {
return;
}
}
const beforeWindowY = window.scrollY;
window.scrollBy({ top: delta, behavior: "auto" });
if (Math.abs(window.scrollY - beforeWindowY) > 0.5) {
return;
}
/**
* 最终兜底:若容器/window 都未实际移动,再次触发原生 nearest 对齐。
* 这一步主要覆盖个别 WebView 对 scrollTop 写入不生效的情况。
*/
if (likelyKeyboardOpen) {
session.target.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "auto" });
}
}
/**
* 键盘动画期会触发多次 viewport 变化,使用 RAF 合并滚动请求,
* 避免同一帧重复改 scrollTop 引发抖动。
*/
function scheduleEnsureFocusedFieldVisible(): void {
if (!formFieldFocused.value) {
return;
}
if (ensureVisibleRafId !== null) {
return;
}
ensureVisibleRafId = window.requestAnimationFrame(() => {
ensureVisibleRafId = null;
ensureFocusedFieldVisible();
});
}
/**
* 某些机型键盘展开较慢,额外做一次延迟校正,确保输入框最终可见。
*/
function scheduleEnsureFocusedFieldVisibleRetry(): void {
if (ensureVisibleRetryTimer !== null) {
window.clearTimeout(ensureVisibleRetryTimer);
}
ensureVisibleRetryTimer = window.setTimeout(() => {
ensureVisibleRetryTimer = null;
scheduleEnsureFocusedFieldVisible();
}, 180);
}
/**
* focusin 进入输入态:
* - 同步焦点状态;
* - 建立输入会话;
* - 立即 + 延迟一次可见性校正。
*/
function onFocusIn(event: FocusEvent): void {
syncFormFieldFocusState();
if (!isEditableField(event.target)) {
return;
}
beginFocusViewportSession(event.target);
scheduleEnsureFocusedFieldVisible();
scheduleEnsureFocusedFieldVisibleRetry();
}
/**
* focusout 发生时activeElement 可能还没切换完成;
* 放到微任务里读取最终焦点,保证状态稳定。
*/
function onFocusOut(): void {
queueMicrotask(() => {
syncFormFieldFocusState();
if (!formFieldFocused.value) {
endFocusViewportSession();
}
});
}
/**
* 统一“返回”语义:
* - 仅表示“返回历史上一页”;
* - 当历史栈不足length<=1时禁用返回按钮并置灰。
*/
function syncCanGoBack(): void {
if (typeof window === "undefined") {
canGoBack.value = false;
return;
}
canGoBack.value = window.history.length > 1;
}
onMounted(async () => {
touchLikeDevice.value = window.matchMedia?.("(pointer: coarse)")?.matches ?? ("ontouchstart" in window);
updateViewportLayout();
syncFormFieldFocusState();
syncCanGoBack();
window.addEventListener("resize", updateViewportLayout, { passive: true });
window.visualViewport?.addEventListener("resize", updateViewportLayout, { passive: true });
window.visualViewport?.addEventListener("scroll", updateViewportLayout, { passive: true });
document.addEventListener("focusin", onFocusIn);
document.addEventListener("focusout", onFocusOut);
window.addEventListener("popstate", syncCanGoBack);
await settingsStore.ensureBootstrapped();
});
onBeforeUnmount(() => {
window.removeEventListener("resize", updateViewportLayout);
window.visualViewport?.removeEventListener("resize", updateViewportLayout);
window.visualViewport?.removeEventListener("scroll", updateViewportLayout);
document.removeEventListener("focusin", onFocusIn);
document.removeEventListener("focusout", onFocusOut);
window.removeEventListener("popstate", syncCanGoBack);
if (ensureVisibleRafId !== null) {
window.cancelAnimationFrame(ensureVisibleRafId);
ensureVisibleRafId = null;
}
if (ensureVisibleRetryTimer !== null) {
window.clearTimeout(ensureVisibleRetryTimer);
ensureVisibleRetryTimer = null;
}
endFocusViewportSession();
});
watch(
() => settingsStore.themeVars,
(vars) => {
const root = document.documentElement;
for (const [key, value] of Object.entries(vars)) {
root.style.setProperty(key, value);
}
},
{ immediate: true, deep: true }
);
watch(
() => route.fullPath,
() => {
syncCanGoBack();
endFocusViewportSession();
},
{ immediate: true }
);
watch(keyboardOpen, (open, prev) => {
if (open) {
if (formFieldFocused.value) {
inputFocusSessionActive.value = true;
}
scheduleEnsureFocusedFieldVisible();
scheduleEnsureFocusedFieldVisibleRetry();
return;
}
if (prev) {
if (!formFieldFocused.value) {
endFocusViewportSession();
}
}
});
function isActive(path: string): boolean {
return route.path === path;
}
async function goTo(path: string): Promise<void> {
if (route.path === path) return;
await router.push(path);
}
async function goBack(): Promise<void> {
if (!canGoBack.value) {
return;
}
router.back();
}
</script>

View File

@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 12 12" fill="none">
<path
d="M3.644 1.5L1.25 10.5H2.56L3.067 8.55H5.564L6.072 10.5H7.382L4.988 1.5H3.644ZM3.365 7.523L4.315 3.873L5.266 7.523H3.365Z"
fill="white"
/>
<path d="M8.25 1.5H9.46V10.5H8.25V1.5Z" fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 319 B

View File

@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" width="8" height="14" viewBox="0 0 8 14" fill="none">
<circle cx="2" cy="2" r="1.3" fill="white" />
<circle cx="6" cy="2" r="1.3" fill="white" />
<circle cx="2" cy="7" r="1.3" fill="white" />
<circle cx="6" cy="7" r="1.3" fill="white" />
<circle cx="2" cy="12" r="1.3" fill="white" />
<circle cx="6" cy="12" r="1.3" fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 391 B

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

11
pxterm/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,11 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_GATEWAY_URL?: string;
readonly VITE_GATEWAY_TOKEN?: string;
readonly VITE_ENABLE_PLUGIN_RUNTIME?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

87
pxterm/src/main.ts Normal file
View File

@@ -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");

42
pxterm/src/routes.ts Normal file
View File

@@ -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")
}
]
: [])
];

View File

@@ -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<CryptoKey> {
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<boolean> {
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<EncryptedCredentialPayload> {
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<T>(payload: EncryptedCredentialPayload): Promise<T> {
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;
}

View File

@@ -0,0 +1,21 @@
type EventName = "connected" | "disconnected" | "stdout" | "stderr" | "latency";
type Handler = (payload: unknown) => void;
const listeners = new Map<EventName, Set<Handler>>();
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);
}
}

View File

@@ -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<ServerProfile, "id">;
public credentialRefs!: EntityTable<CredentialRef, "id">;
public credentials!: EntityTable<EncryptedCredentialPayload, "id">;
public sessionLogs!: EntityTable<SessionLog, "sessionId">;
public knownHosts!: EntityTable<KnownHostEntity, "key">;
public settings!: EntityTable<SettingEntity, "key">;
public pluginPackages!: EntityTable<PluginPackage & { id: string }, "id">;
public pluginRecords!: EntityTable<PluginRecord & { id: string }, "id">;
public pluginData!: EntityTable<PluginDataEntity, "key">;
public voiceRecords!: EntityTable<VoiceRecord, "id">;
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<void> {
if (!db.isOpen()) {
await db.open();
}
}
export async function getSettings(): Promise<GlobalSettings | null> {
await ensureDbOpen();
const row = await db.settings.get("global");
return row?.value ?? null;
}
export async function setSettings(value: GlobalSettings): Promise<void> {
await ensureDbOpen();
await db.settings.put({ key: "global", value });
}
export async function getKnownHosts(): Promise<Record<string, string>> {
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<void> {
await ensureDbOpen();
await db.knownHosts.put({
key,
fingerprint,
updatedAt: new Date().toISOString()
});
}

View File

@@ -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<PluginPackage[]> {
const rows = await db.pluginPackages.toArray();
return rows.map(({ id: _id, ...pkg }) => pkg);
}
public async getPackage(pluginId: string): Promise<PluginPackage | null> {
const row = await db.pluginPackages.get(pluginId);
if (!row) {
return null;
}
const { id: _id, ...pkg } = row;
return pkg;
}
public async upsertPackage(pluginPackage: PluginPackage): Promise<void> {
await db.pluginPackages.put({ id: pluginPackage.manifest.id, ...pluginPackage });
}
public async removePackage(pluginId: string): Promise<void> {
await db.pluginPackages.delete(pluginId);
}
public async readStore<T>(key: string, fallback: T): Promise<T> {
const row = await db.pluginData.get(key);
if (!row) {
return fallback;
}
return row.value as T;
}
public async writeStore<T>(key: string, value: T): Promise<void> {
await db.pluginData.put({ key, value });
}
}

View File

@@ -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);
}

View File

@@ -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<void> {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
throw new Error("会话已连接");
}
this.state = "connecting";
this.socket = await new Promise<WebSocket>((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<void> {
this.sendRaw({
type: "stdin",
payload: {
data,
...(meta ? { meta } : {})
}
});
}
public async resize(cols: number, rows: number): Promise<void> {
this.sendRaw({ type: "resize", payload: { cols, rows } });
}
public async disconnect(reason = "manual"): Promise<void> {
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)];
}
}

View File

@@ -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<void>;
send(options: { data: string }): Promise<void>;
resize(options: { cols: number; rows: number }): Promise<void>;
disconnect(options: { reason?: string }): Promise<void>;
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<void> {
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<void> {
await window.Capacitor?.Plugins?.RemoteConnSSH?.send({ data });
}
public async resize(cols: number, rows: number): Promise<void> {
await window.Capacitor?.Plugins?.RemoteConnSSH?.resize({ cols, rows });
}
public async disconnect(reason?: string): Promise<void> {
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);
}
}
}

View File

@@ -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<void>;
send(data: string, meta?: StdinMeta): Promise<void>;
resize(cols: number, rows: number): Promise<void>;
disconnect(reason?: string): Promise<void>;
on(listener: (event: TransportEvent) => void): () => void;
getState(): SessionState;
}

View File

@@ -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<AppToast[]>([]);
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
};
});

View File

@@ -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<SessionLog[]>([]);
const loaded = ref(false);
let bootstrapPromise: Promise<void> | 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<void> {
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<void> {
await ensureBootstrapped();
}
async function startLog(serverId: string): Promise<string> {
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<void> {
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<CommandMarker, "at">): Promise<void> {
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
};
});

View File

@@ -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<string[]>([]);
const initialized = ref(false);
let bootstrapPromise: Promise<void> | 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<void> {
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<void> {
await ensureBootstrapped();
}
async function ensureSamplePlugin(): Promise<void> {
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<void> {
for (const pkg of payload) {
await manager.installPackage(pkg);
}
}
async function importJson(raw: string): Promise<void> {
const parsed = JSON.parse(raw) as PluginPackage | PluginPackage[];
const items = Array.isArray(parsed) ? parsed : [parsed];
await importPackages(items);
}
async function exportJson(): Promise<string> {
const packages = await fsAdapter.listPackages();
return JSON.stringify(packages, null, 2);
}
async function enable(pluginId: string): Promise<void> {
await manager.enable(pluginId);
}
async function disable(pluginId: string): Promise<void> {
await manager.disable(pluginId);
}
async function reload(pluginId: string): Promise<void> {
await manager.reload(pluginId);
}
async function remove(pluginId: string): Promise<void> {
await manager.remove(pluginId);
}
async function runCommand(commandId: string): Promise<void> {
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
};
});

Some files were not shown because too many files have changed in this diff Show More