first commit

This commit is contained in:
douboer@gmail.com
2026-03-03 13:23:14 +08:00
commit 3b7c1d558a
161 changed files with 28120 additions and 0 deletions

View File

@@ -0,0 +1,123 @@
/**
* WxInputBridge — 微信小程序 bindinput/bindconfirm 适配为 IInputSource。
*
* 使用方式(在 terminal-core-view/index.js 中):
* const bridge = createWxInputBridge();
* // 绑定到 InputBridge
* const core_bridge = new InputBridge();
* bridge.on('key', p => { const seq = core_bridge.mapKey(...); transport.send(seq); });
* bridge.on('input', p => { if (!p.isComposing && p.data) transport.send(p.data); });
* bridge.on('paste', p => { transport.send(core_bridge.mapPaste(p.text)); });
*
* 组件调用WXML 事件 → 桥接):
* Page/Component: {
* onInput(e) { bridge.triggerInput(e.detail.value, e.detail.isComposing); },
* onConfirm() { bridge.triggerConfirm(); },
* onPaste(text){ bridge.triggerPaste(text); },
* onFocus() { bridge.triggerFocus(); },
* onBlur() { bridge.triggerBlur(); },
* }
*/
function createWxInputBridge() {
const _listeners = Object.create(null);
function on(event, cb) {
if (!_listeners[event]) _listeners[event] = [];
_listeners[event].push(cb);
return function off() {
const arr = _listeners[event];
if (!arr) return;
const idx = arr.indexOf(cb);
if (idx >= 0) arr.splice(idx, 1);
};
}
function _emit(event, payload) {
const arr = _listeners[event];
if (!arr) return;
for (let i = 0; i < arr.length; i++) {
try { arr[i](payload); } catch(e) { /* isolation */ }
}
}
// ── WXML 事件驱动 API ─────────────────────────────────────────────────────
/**
* 由 WXML bindinput 调用。
* 注意:小程序 bindinput 的 value 是整体输入框值,不是增量;
* 调用方应提取增量(新增字符)后传入 data 参数。
*/
function triggerInput(data, isComposing) {
_emit('input', {
data: data != null ? String(data) : '',
isComposing: !!isComposing,
});
}
/**
* 由 WXML bindconfirm 调用(用户点击软键盘"完成/回车")。
*/
function triggerConfirm() {
_emit('key', {
key: 'Enter',
code: 'Enter',
ctrlKey: false,
altKey: false,
shiftKey: false,
metaKey: false,
});
}
/**
* 由触控工具栏按键调用箭头键、Tab、Ctrl+C 等)。
* @param {string} key KeyboardEvent.key 等价值(如 "ArrowUp"、"c"
* @param {string} code KeyboardEvent.code 等价值(可省略,默认与 key 相同)
* @param {{ ctrlKey, altKey, shiftKey, metaKey }} mods 修饰键状态
*/
function triggerKey(key, code, mods) {
const m = mods || {};
_emit('key', {
key: key,
code: code || key,
ctrlKey: !!m.ctrlKey,
altKey: !!m.altKey,
shiftKey: !!m.shiftKey,
metaKey: !!m.metaKey,
});
}
/**
* 由粘贴操作触发(通过 wx.getClipboardData 读取后调用)。
*/
function triggerPaste(text) {
_emit('paste', { text: String(text || '') });
}
/**
* IME 组合开始(候选词输入开始)。
*/
function triggerCompositionStart(data) {
_emit('compositionstart', { data: String(data || '') });
}
/**
* IME 组合结束(候选词确认)。
*/
function triggerCompositionEnd(data) {
_emit('compositionend', { data: String(data || '') });
}
return {
on: on,
// WXML 事件驱动方法
triggerInput: triggerInput,
triggerConfirm: triggerConfirm,
triggerKey: triggerKey,
triggerPaste: triggerPaste,
triggerCompositionStart: triggerCompositionStart,
triggerCompositionEnd: triggerCompositionEnd,
};
}
module.exports = { createWxInputBridge: createWxInputBridge };

View File

@@ -0,0 +1,112 @@
/**
* WxMeasureAdapter — 微信小程序 wx.createSelectorQuery 实现 IMeasureAdapter。
*
* 由于小程序不支持 ResizeObserver使用 BoundingClientRect 查询容器尺寸,
* 并提供 `poll(intervalMs)` 方法做周期性尺寸检测(模拟 resize 通知)。
*
* 使用方式:
* const m = createWxMeasureAdapter(component, '#tc-viewport', 14, 16.8);
* m.onResize(() => {
* const { widthPx, heightPx } = m.measureContainer();
* const { widthPx: cw, heightPx: ch } = m.measureChar();
* const cols = Math.max(20, Math.floor(widthPx / cw));
* const rows = Math.max(8, Math.floor(heightPx / ch));
* // ... resize terminal
* });
* m.startPoll(500);
* // 在 component detached / onUnload 中:
* m.stopPoll();
*/
/**
* @param {object} ctx - 小程序 Component 实例this用于 createSelectorQuery 作用域
* @param {string} selector - CSS 选择器,如 '#tc-viewport'
* @param {number} charWidthPx - 等宽字符宽度像素(由组件在初始化时测量或硬编码)
* @param {number} charHeightPx - 行高像素
*/
function createWxMeasureAdapter(ctx, selector, charWidthPx, charHeightPx) {
let _containerW = 0;
let _containerH = 0;
let _pollTimer = null;
const _callbacks = [];
/** 实现 IMeasureAdapter.measureChar */
function measureChar() {
return {
widthPx: charWidthPx || 8,
heightPx: charHeightPx || 16,
};
}
/** 实现 IMeasureAdapter.measureContainer */
function measureContainer() {
return {
widthPx: _containerW,
heightPx: _containerH,
};
}
/** 实现 IMeasureAdapter.onResize */
function onResize(cb) {
_callbacks.push(cb);
return function off() {
const idx = _callbacks.indexOf(cb);
if (idx >= 0) _callbacks.splice(idx, 1);
};
}
/** 主动刷新容器尺寸(异步) */
function refresh(onDone) {
const query = ctx.createSelectorQuery();
query.select(selector).boundingClientRect(function(rect) {
if (!rect) return;
const newW = rect.width || 0;
const newH = rect.height || 0;
if (newW !== _containerW || newH !== _containerH) {
_containerW = newW;
_containerH = newH;
for (let i = 0; i < _callbacks.length; i++) {
try { _callbacks[i](); } catch(e) { /* isolation */ }
}
}
if (typeof onDone === 'function') onDone({ widthPx: _containerW, heightPx: _containerH });
}).exec();
}
/** 启动周期性轮询(模拟 ResizeObserver。intervalMs 建议 300~1000ms */
function startPoll(intervalMs) {
stopPoll();
const ms = intervalMs || 500;
// 立刻执行一次
refresh();
_pollTimer = setInterval(function() { refresh(); }, ms);
}
/** 停止轮询 */
function stopPoll() {
if (_pollTimer !== null) {
clearInterval(_pollTimer);
_pollTimer = null;
}
}
/**
* 一次性测量(供初始化使用)。
* @param {function} cb - (result: { widthPx, heightPx }) => void
*/
function measureOnce(cb) {
refresh(cb);
}
return {
measureChar: measureChar,
measureContainer: measureContainer,
onResize: onResize,
refresh: refresh,
startPoll: startPoll,
stopPoll: stopPoll,
measureOnce: measureOnce,
};
}
module.exports = { createWxMeasureAdapter: createWxMeasureAdapter };

View File

@@ -0,0 +1,201 @@
/**
* WxTransport — 微信小程序 wx.connectSocket 实现的 TerminalTransport。
*
* 帧协议与 Web 端 GatewayTransport 完全一致§8.2
* 入站:{ type: 'init'|'stdin'|'resize'|'control', ... }
* 出站:{ type: 'stdout'|'stderr'|'control'|'error', ... }
*
* 使用方式:
* const t = new WxTransport({ gatewayUrl, gatewayToken });
* const off = t.on(event => { ... });
* await t.connect({ host, port, username, password });
* t.send("ls -la\r");
* t.resize(80, 24);
* t.disconnect();
*/
const { sanitizeTerminalOutput } = require('../../../packages/terminal-core/dist-miniprogram/index');
function buildWsUrl(rawUrl, token) {
let base = String(rawUrl || '').trim();
if (!base) throw new Error('WxTransport: 网关地址为空');
if (base.startsWith('http://')) base = 'ws://' + base.slice(7);
if (base.startsWith('https://')) base = 'wss://' + base.slice(8);
if (!base.startsWith('ws://') && !base.startsWith('wss://')) {
base = 'wss://' + base;
}
base = base.replace(/\/+$/, '');
return base + '/ws/terminal?token=' + encodeURIComponent(String(token || ''));
}
function WxTransport(options) {
const cfg = options || {};
this._gatewayUrl = cfg.gatewayUrl || '';
this._gatewayToken = cfg.gatewayToken || '';
this._state = 'idle'; // SessionState
this._sessionId = '';
this._socket = null; // wx.SocketTask
this._listeners = []; // Array<(event) => void>
this._pingTimer = null;
this._pingAt = 0;
}
// ── 接口实现 ──────────────────────────────────────────────────────────────────
/**
* 连接网关并发送 init 帧。
* 返回 Promise在 control.connected 帧到达后 resolve错误时 reject。
*/
WxTransport.prototype.connect = function(params) {
const self = this;
return new Promise(function(resolve, reject) {
if (self._state === 'connecting' || self._state === 'connected') {
resolve();
return;
}
self._state = 'connecting';
const sessionId = 'wx_' + Date.now() + '_' + Math.random().toString(36).slice(2);
self._sessionId = sessionId;
const url = buildWsUrl(self._gatewayUrl, self._gatewayToken);
const task = wx.connectSocket({
url: url,
protocols: ['v1.terminal'],
success: function() { /* socket created */ },
fail: function(err) {
self._state = 'error';
self._emit({ type: 'error', code: 'HOST_UNREACHABLE', message: JSON.stringify(err) });
reject(new Error('wx.connectSocket fail: ' + JSON.stringify(err)));
}
});
self._socket = task;
let resolved = false;
task.onOpen(function() {
// 发送 init 帧
self._sendFrame({ type: 'init', sessionId: sessionId, params: params });
// 启动心跳
self._startPing();
});
task.onMessage(function(res) {
let frame;
try {
frame = JSON.parse(res.data);
} catch(e) {
return;
}
if (frame.type === 'stdout' || frame.type === 'stderr') {
const raw = typeof frame.data === 'string' ? frame.data : '';
const sanitized = sanitizeTerminalOutput(raw);
if (sanitized) self._emit({ type: frame.type, data: sanitized });
} else if (frame.type === 'control') {
if (frame.action === 'connected') {
self._state = 'connected';
self._emit({ type: 'control', action: 'connected', data: frame.data });
if (!resolved) { resolved = true; resolve(); }
} else if (frame.action === 'disconnect') {
self._cleanup();
self._state = 'disconnected';
self._emit({ type: 'control', action: 'disconnect' });
} else if (frame.action === 'pong') {
self._emit({ type: 'control', action: 'pong' });
}
} else if (frame.type === 'error') {
self._state = 'error';
self._emit({ type: 'error', code: frame.code || 'INTERNAL_ERROR', message: frame.message || '' });
if (!resolved) { resolved = true; reject(new Error(frame.message)); }
}
});
task.onError(function(err) {
self._cleanup();
self._state = 'error';
self._emit({ type: 'error', code: 'HOST_UNREACHABLE', message: JSON.stringify(err) });
if (!resolved) { resolved = true; reject(new Error(JSON.stringify(err))); }
});
task.onClose(function() {
self._cleanup();
if (self._state !== 'disconnected' && self._state !== 'error') {
self._state = 'disconnected';
self._emit({ type: 'control', action: 'disconnect' });
}
});
});
};
WxTransport.prototype.send = function(data, meta) {
if (this._state !== 'connected') return;
this._sendFrame({ type: 'stdin', data: data, meta: meta });
};
WxTransport.prototype.resize = function(cols, rows) {
if (this._state !== 'connected') return;
this._sendFrame({ type: 'resize', cols: cols, rows: rows });
};
WxTransport.prototype.disconnect = function(reason) {
this._sendFrame({ type: 'control', action: 'disconnect', reason: reason || 'manual' });
this._cleanup();
this._state = 'disconnected';
};
WxTransport.prototype.on = function(listener) {
const self = this;
self._listeners.push(listener);
return function() {
const idx = self._listeners.indexOf(listener);
if (idx >= 0) self._listeners.splice(idx, 1);
};
};
WxTransport.prototype.getState = function() {
return this._state;
};
// ── 私有方法 ──────────────────────────────────────────────────────────────────
WxTransport.prototype._emit = function(event) {
for (let i = 0; i < this._listeners.length; i++) {
try { this._listeners[i](event); } catch(e) { /* 单个监听器异常不影响其他 */ }
}
};
WxTransport.prototype._sendFrame = function(frame) {
if (!this._socket) return;
try {
this._socket.send({ data: JSON.stringify(frame) });
} catch(e) { /* 忽略发送时 socket 已关闭 */ }
};
WxTransport.prototype._startPing = function() {
const self = this;
self._clearPing();
self._pingTimer = setInterval(function() {
if (self._state !== 'connected') return;
self._pingAt = Date.now();
self._sendFrame({ type: 'control', action: 'ping' });
}, 10000);
};
WxTransport.prototype._clearPing = function() {
if (this._pingTimer !== null) {
clearInterval(this._pingTimer);
this._pingTimer = null;
}
};
WxTransport.prototype._cleanup = function() {
this._clearPing();
if (this._socket) {
try { this._socket.close(); } catch(e) { /* ignore */ }
this._socket = null;
}
};
module.exports = { WxTransport: WxTransport };