terminal-bridge-setup 2.9.1 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/files/extension/background.js +341 -11
- package/files/extension/content-yearning-main.js +56 -0
- package/files/extension/content-yearning.js +307 -0
- package/files/extension/manifest.json +14 -1
- package/files/extension/popup.html +33 -0
- package/files/extension/popup.js +129 -1
- package/files/proxy/package.json +1 -0
- package/files/proxy/server.js +260 -1
- package/files/proxy/tap-example.mjs +137 -0
- package/files/proxy/yr-example.mjs +110 -0
- package/files/skill/SKILL.md +2 -0
- package/files/skill/references/protocol.md +22 -1
- package/package.json +1 -1
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// Terminal Bridge - Yearning SQL 平台自动化 content script (ISOLATED world)
|
|
2
|
+
//
|
|
3
|
+
// 只在 Yearning(sql.meiyunji.net)页面工作。配合 background 的 tap 模式:
|
|
4
|
+
// Agent → 代理 → background → 本脚本:注入 SQL 到编辑器、点「查询」按钮
|
|
5
|
+
// 查询结果通过 tap 通道(WS 帧)回到 Agent
|
|
6
|
+
//
|
|
7
|
+
// 消息(均由 background 转发,frameId=0 顶层文档):
|
|
8
|
+
// yr-ping 探测编辑器类型和查询按钮,返回结构化信息
|
|
9
|
+
// yr-sql-set {sql} 注入 SQL(按探测到的编辑器类型选策略)
|
|
10
|
+
// yr-query-click 找「查询」按钮并点击
|
|
11
|
+
|
|
12
|
+
(function () {
|
|
13
|
+
const TAG = "[terminal-bridge-yr]";
|
|
14
|
+
if (window.__terminalBridgeYearning) return;
|
|
15
|
+
window.__terminalBridgeYearning = true;
|
|
16
|
+
|
|
17
|
+
// ---------- MAIN world 桥(CodeMirror/monaco API 只在页面上下文可达)----------
|
|
18
|
+
let mainMsgId = 0;
|
|
19
|
+
const mainWaiters = new Map();
|
|
20
|
+
|
|
21
|
+
window.addEventListener("message", (event) => {
|
|
22
|
+
if (event.source !== window) return;
|
|
23
|
+
const msg = event.data;
|
|
24
|
+
if (!msg || msg.source !== "tb-yr-main") return;
|
|
25
|
+
const waiter = mainWaiters.get(msg.id);
|
|
26
|
+
if (waiter) {
|
|
27
|
+
mainWaiters.delete(msg.id);
|
|
28
|
+
waiter(msg);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function callMain(kind, payload, timeoutMs = 300) {
|
|
33
|
+
return new Promise((resolve) => {
|
|
34
|
+
const id = ++mainMsgId;
|
|
35
|
+
const timer = setTimeout(() => {
|
|
36
|
+
mainWaiters.delete(id);
|
|
37
|
+
resolve(null); // 超时 = MAIN world 没装或没处理
|
|
38
|
+
}, timeoutMs);
|
|
39
|
+
mainWaiters.set(id, (reply) => {
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
resolve(reply);
|
|
42
|
+
});
|
|
43
|
+
window.postMessage({ source: "tb-yr-iso", id, kind, ...payload }, "*");
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ---------- 编辑器探测 ----------
|
|
48
|
+
async function detectEditor() {
|
|
49
|
+
const info = {
|
|
50
|
+
url: location.href,
|
|
51
|
+
codeMirrorDom: !!document.querySelector(".CodeMirror"),
|
|
52
|
+
monacoDom: !!document.querySelector(".monaco-editor"),
|
|
53
|
+
mainWorld: null,
|
|
54
|
+
textareas: [],
|
|
55
|
+
contentEditables: [],
|
|
56
|
+
};
|
|
57
|
+
const main = await callMain("detect", {}, 200);
|
|
58
|
+
info.mainWorld = main ? main.editors : "unreachable";
|
|
59
|
+
document.querySelectorAll("textarea").forEach((ta, i) => {
|
|
60
|
+
if (i < 5) info.textareas.push({
|
|
61
|
+
cls: (ta.className || "").slice(0, 60),
|
|
62
|
+
placeholder: (ta.placeholder || "").slice(0, 40),
|
|
63
|
+
visible: ta.offsetParent !== null,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
document.querySelectorAll('[contenteditable="true"]').forEach((el, i) => {
|
|
67
|
+
if (i < 5) info.contentEditables.push({
|
|
68
|
+
tag: el.tagName,
|
|
69
|
+
cls: (el.className || "").slice(0, 60),
|
|
70
|
+
visible: el.offsetParent !== null,
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
return info;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------- 数据库/数据源元信息 ----------
|
|
77
|
+
// 三层读取策略:
|
|
78
|
+
// 1. 数据库(所选库):精确 XPath(form 下 div[2]/div[2] 的选择器文本)——
|
|
79
|
+
// 未选库时查询会报错,必须准确知道
|
|
80
|
+
// 2. 数据源:URL hash 的 source/idc 参数(#/apply/query?source=xxx&idc=xxx)
|
|
81
|
+
// 3. 兜底:整个 form 的 input/select 值启发式
|
|
82
|
+
const FORM_XPATH = "/html/body/div[1]/div/section/section/div[2]/main/div/div/div[2]/div[2]/div/div/div/div/div[2]/div/div[1]/div/div[2]/div/div/div/form";
|
|
83
|
+
const DATABASE_XPATH = FORM_XPATH + "/div[2]/div[2]/div/div/div";
|
|
84
|
+
|
|
85
|
+
function xpathNode(path) {
|
|
86
|
+
try { return document.evaluate(path, document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue; } catch { return null; }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readYearningMeta() {
|
|
90
|
+
let database = "";
|
|
91
|
+
let dataSource = "";
|
|
92
|
+
|
|
93
|
+
// 1. 所选数据库:精确 XPath 读取(antd Select 的显示文本)
|
|
94
|
+
const dbNode = xpathNode(DATABASE_XPATH);
|
|
95
|
+
if (dbNode) {
|
|
96
|
+
const dbText = (dbNode.textContent || "").trim();
|
|
97
|
+
// Select 未选择时 antd 显示 placeholder("请选择..."),排除
|
|
98
|
+
if (dbText && !/请选择|placeholder/i.test(dbText)) database = dbText;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 2. 数据源:URL hash
|
|
102
|
+
try {
|
|
103
|
+
const h = location.hash.replace(/^#/, "");
|
|
104
|
+
const q = new URLSearchParams(h.split("?")[1] || "");
|
|
105
|
+
dataSource = q.get("source") || "";
|
|
106
|
+
const idc = q.get("idc") || "";
|
|
107
|
+
if (idc && idc !== dataSource) dataSource = dataSource ? `${dataSource} · ${idc}` : idc;
|
|
108
|
+
} catch {}
|
|
109
|
+
|
|
110
|
+
// 3. 兜底:form 启发式
|
|
111
|
+
if (!database || !dataSource) {
|
|
112
|
+
const form = document.querySelector("form") || xpathNode(FORM_XPATH);
|
|
113
|
+
if (form) {
|
|
114
|
+
const values = [];
|
|
115
|
+
form.querySelectorAll("input, select").forEach(el => {
|
|
116
|
+
if (el.offsetParent === null) return;
|
|
117
|
+
const value = el.tagName === "SELECT" ? el.options[el.selectedIndex]?.textContent : el.value;
|
|
118
|
+
if (value?.trim()) values.push(value.trim());
|
|
119
|
+
});
|
|
120
|
+
const unique = [...new Set(values)].filter(v => !/^(查询|执行|取消|确定|SQL)$/i.test(v));
|
|
121
|
+
if (!dataSource) dataSource = unique.find(v => /source|实例|数据源|tdsql|mysql|prod|test/i.test(v)) || "";
|
|
122
|
+
if (!database) database = unique.find(v => /database|db|库|schema/i.test(v)) || "";
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const label = [dataSource, database].filter(Boolean).join(" · ")
|
|
127
|
+
|| [database, dataSource].filter(Boolean).join(" · ")
|
|
128
|
+
|| document.title
|
|
129
|
+
|| "Yearning";
|
|
130
|
+
return { ok: true, database, dataSource, label, formFound: !!(database || dataSource) };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ---------- 查询按钮探测 ----------
|
|
134
|
+
function findQueryButtons() {
|
|
135
|
+
const buttons = [];
|
|
136
|
+
document.querySelectorAll("button").forEach((b) => {
|
|
137
|
+
const text = (b.textContent || "").trim();
|
|
138
|
+
if (!text || text.length > 8) return;
|
|
139
|
+
buttons.push({ text, visible: b.offsetParent !== null, disabled: b.disabled });
|
|
140
|
+
});
|
|
141
|
+
return buttons;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ---------- monaco 编辑器内容读取(注入验证用,无 API 时从 DOM 读)----------
|
|
145
|
+
function readMonacoText() {
|
|
146
|
+
const lines = [...document.querySelectorAll(".monaco-editor .view-lines .view-line")]
|
|
147
|
+
.map(l => l.textContent || "");
|
|
148
|
+
return lines.join("\n");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ---------- SQL 注入(先 MAIN world API,后 DOM 策略,注入后验证)----------
|
|
152
|
+
async function setSql(sql) {
|
|
153
|
+
// 策略 1:CodeMirror/monaco(MAIN world,官方 API 状态一定同步)
|
|
154
|
+
const main = await callMain("set-sql", { sql });
|
|
155
|
+
if (main && main.ok) return { ok: true, via: main.via };
|
|
156
|
+
|
|
157
|
+
// 策略 2:monaco DOM 注入——合成 paste 事件(monaco 官方输入路径)。
|
|
158
|
+
// 实测教训:execCommand("insertText") 在 inputarea 上静默失败(假阳性),
|
|
159
|
+
// 而 ClipboardEvent("paste") + DataTransfer 是 monaco 粘贴处理器认的通道。
|
|
160
|
+
const monacoTa = document.querySelector(".monaco-editor textarea.inputarea");
|
|
161
|
+
if (monacoTa) {
|
|
162
|
+
monacoTa.focus();
|
|
163
|
+
// 全选旧内容(paste 会替换选区)
|
|
164
|
+
monacoTa.dispatchEvent(new KeyboardEvent("keydown", {
|
|
165
|
+
key: "a", code: "KeyA", keyCode: 65, which: 65,
|
|
166
|
+
ctrlKey: true, bubbles: true, cancelable: true
|
|
167
|
+
}));
|
|
168
|
+
const dt = new DataTransfer();
|
|
169
|
+
dt.setData("text/plain", sql);
|
|
170
|
+
monacoTa.dispatchEvent(new ClipboardEvent("paste", {
|
|
171
|
+
clipboardData: dt, bubbles: true, cancelable: true
|
|
172
|
+
}));
|
|
173
|
+
|
|
174
|
+
// 注入后验证:等 monaco 渲染,读回 view-lines 内容比对(杜绝假阳性)
|
|
175
|
+
await new Promise(r => setTimeout(r, 250));
|
|
176
|
+
const current = readMonacoText();
|
|
177
|
+
const norm = s => s.replace(/\s+/g, "");
|
|
178
|
+
if (norm(current).includes(norm(sql).slice(0, 40))) {
|
|
179
|
+
return { ok: true, via: "monaco-paste" };
|
|
180
|
+
}
|
|
181
|
+
// paste 失败再试 execCommand(检查返回值)
|
|
182
|
+
monacoTa.focus();
|
|
183
|
+
const ok2 = document.execCommand("insertText", false, sql);
|
|
184
|
+
await new Promise(r => setTimeout(r, 250));
|
|
185
|
+
const current2 = readMonacoText();
|
|
186
|
+
if (ok2 && norm(current2).includes(norm(sql).slice(0, 40))) {
|
|
187
|
+
return { ok: true, via: "monaco-execcmd" };
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
ok: false,
|
|
191
|
+
error: "monaco inject failed (paste+execCommand 均未生效)",
|
|
192
|
+
editorText: current2.slice(0, 120),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// 策略 3:普通可见 textarea(value + input 事件)
|
|
197
|
+
const tas = [...document.querySelectorAll("textarea")].filter(ta => ta.offsetParent !== null);
|
|
198
|
+
if (tas.length > 0) {
|
|
199
|
+
const ta = tas[0];
|
|
200
|
+
ta.focus();
|
|
201
|
+
ta.value = sql;
|
|
202
|
+
ta.dispatchEvent(new InputEvent("input", {
|
|
203
|
+
inputType: "insertText", data: sql, bubbles: true, cancelable: true
|
|
204
|
+
}));
|
|
205
|
+
return { ok: true, via: "textarea" };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// 策略 4:contenteditable(focus + 全选 + 插入)
|
|
209
|
+
const ces = [...document.querySelectorAll('[contenteditable="true"]')].filter(el => el.offsetParent !== null);
|
|
210
|
+
if (ces.length > 0) {
|
|
211
|
+
const el = ces[0];
|
|
212
|
+
el.focus();
|
|
213
|
+
document.execCommand("selectAll", false, null);
|
|
214
|
+
document.execCommand("insertText", false, sql);
|
|
215
|
+
return { ok: true, via: "contenteditable" };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return { ok: false, error: "no editor found (main-world/monaco/textarea/contenteditable 均未命中)" };
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// ---------- 点「查询」按钮 ----------
|
|
222
|
+
function clickQuery() {
|
|
223
|
+
// 文本匹配时去空白(实测按钮文案是「查 询」,中间带空格)
|
|
224
|
+
const norm = (s) => (s || "").replace(/\s+/g, "").trim();
|
|
225
|
+
const candidates = [...document.querySelectorAll("button")]
|
|
226
|
+
.filter(b => b.offsetParent !== null && !b.disabled)
|
|
227
|
+
.map(b => ({ b, text: (b.textContent || "").trim(), key: norm(b.textContent) }));
|
|
228
|
+
const exact = candidates.find(c => c.key === "查询") ||
|
|
229
|
+
candidates.find(c => c.key === "执行") ||
|
|
230
|
+
candidates.find(c => /^查询|^执行|^运行/.test(c.key));
|
|
231
|
+
if (!exact) {
|
|
232
|
+
return { ok: false, error: "query button not found", buttons: candidates.slice(0, 15).map(c => c.text) };
|
|
233
|
+
}
|
|
234
|
+
exact.b.scrollIntoView({ block: "center" });
|
|
235
|
+
exact.b.click();
|
|
236
|
+
return { ok: true, via: exact.text };
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ---------- 消息处理 ----------
|
|
240
|
+
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
241
|
+
if (!msg || !msg.type) return;
|
|
242
|
+
|
|
243
|
+
if (msg.type === "yr-meta") {
|
|
244
|
+
sendResponse(readYearningMeta());
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
if (msg.type === "yr-ping") {
|
|
248
|
+
detectEditor().then(editor => {
|
|
249
|
+
sendResponse({
|
|
250
|
+
ok: true,
|
|
251
|
+
editor,
|
|
252
|
+
buttons: findQueryButtons().slice(0, 20),
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
return true; // 异步响应
|
|
256
|
+
}
|
|
257
|
+
if (msg.type === "yr-sql-set") {
|
|
258
|
+
setSql(msg.sql || "").then(sendResponse);
|
|
259
|
+
return true;
|
|
260
|
+
}
|
|
261
|
+
if (msg.type === "yr-query-click") {
|
|
262
|
+
sendResponse(clickQuery());
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
if (msg.type === "yr-new-sql") {
|
|
266
|
+
// 新建 SQL 窗口:点工具栏新建按钮,等新编辑器渲染。
|
|
267
|
+
// 避免把 SQL 注入用户正在看/正在用的已有编辑器。
|
|
268
|
+
// 按钮定位:优先 XPath(用户给的路径),兜底找文本含"新建"的可见按钮。
|
|
269
|
+
const btn = xpathNode("/html/body/div[1]/div/section/section/div[2]/main/div/div/div[2]/div[2]/div/div/div/div/div[2]/div/div[1]/div/div[1]/div[1]/div/button")
|
|
270
|
+
|| [...document.querySelectorAll("button")].find(b =>
|
|
271
|
+
b.offsetParent !== null && /新建|new/i.test((b.textContent || "").trim()));
|
|
272
|
+
if (!btn) { sendResponse({ ok: false, error: "新建按钮未找到" }); return true; }
|
|
273
|
+
const before = document.querySelectorAll(".monaco-editor").length;
|
|
274
|
+
btn.click();
|
|
275
|
+
// 等新编辑器出现(最多 4s;tab 页签式 UI 时编辑器数不变,退化为等 800ms)
|
|
276
|
+
const deadline = Date.now() + 4000;
|
|
277
|
+
(function waitFor() {
|
|
278
|
+
const now = document.querySelectorAll(".monaco-editor").length;
|
|
279
|
+
if (now > before || Date.now() > deadline) {
|
|
280
|
+
setTimeout(() => sendResponse({ ok: true, editors: now, via: "new-sql-btn" }), 800);
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
setTimeout(waitFor, 200);
|
|
284
|
+
})();
|
|
285
|
+
return true;
|
|
286
|
+
}
|
|
287
|
+
if (msg.type === "yr-focus-editor") {
|
|
288
|
+
// CDP 注入前置:聚焦 monaco 的 inputarea(Input.insertText 作用于焦点元素)
|
|
289
|
+
const ta = document.querySelector(".monaco-editor textarea.inputarea")
|
|
290
|
+
|| document.querySelector("textarea");
|
|
291
|
+
if (!ta) { sendResponse({ ok: false, error: "no inputarea" }); return true; }
|
|
292
|
+
ta.focus();
|
|
293
|
+
sendResponse({ ok: true });
|
|
294
|
+
return true;
|
|
295
|
+
}
|
|
296
|
+
if (msg.type === "yr-verify-sql") {
|
|
297
|
+
// CDP 注入后验证:读回 monaco view-lines 内容比对
|
|
298
|
+
const current = readMonacoText();
|
|
299
|
+
const norm = s => s.replace(/\s+/g, "");
|
|
300
|
+
const hit = norm(current).indexOf(norm(msg.sql || "").slice(0, 40)) !== -1;
|
|
301
|
+
sendResponse({ ok: hit, editorText: current.slice(0, 120) });
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
console.log(TAG, "Yearning content script loaded at", location.href);
|
|
307
|
+
})();
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"version": "2.0.0",
|
|
5
5
|
"description": "桥接 JumpServer Web 终端,让 Agent 能远程执行命令并获取输出",
|
|
6
6
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtsnqR6PcFUueZwYria79tVbstvjk+tM7PpvIXILm5xbd6bAdjDIhzg3lsnKioVfvxjfvT+s6vJsiOYa9ojVZyJMFc5m/05TYqr770ovYwQmz0e88fmiy6dUoSulbtKvBCSLbN6OOL7u+ul8ixLZ/HautxSmou/eNgAFPmhE+4UueE7wfCqcgMYvjLvEzlqTVumMW+5LKw9YsRk6WhHPghY1a3MVUn3eQOWXBtQTEUy3wBM3v4wHxLwDeinVOR4f/P87IlUNo84C5DeimoFit0qCj3K04hS8MIYCLCYZc3v9ftRJDJBkAoah6Eaqj7JajbS3KvLR1ctOYfDhubgmURQIDAQAB",
|
|
7
|
-
"permissions": ["debugger", "tabs", "alarms", "webNavigation", "nativeMessaging", "scripting"],
|
|
7
|
+
"permissions": ["debugger", "tabs", "alarms", "webNavigation", "nativeMessaging", "scripting", "downloads", "storage"],
|
|
8
8
|
"background": {
|
|
9
9
|
"service_worker": "background.js"
|
|
10
10
|
},
|
|
@@ -18,6 +18,19 @@
|
|
|
18
18
|
"js": ["content.js"],
|
|
19
19
|
"run_at": "document_start",
|
|
20
20
|
"all_frames": true
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"matches": ["*://sql.meiyunji.net/*"],
|
|
24
|
+
"js": ["content-yearning-main.js"],
|
|
25
|
+
"run_at": "document_start",
|
|
26
|
+
"all_frames": false,
|
|
27
|
+
"world": "MAIN"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"matches": ["*://sql.meiyunji.net/*"],
|
|
31
|
+
"js": ["content-yearning.js"],
|
|
32
|
+
"run_at": "document_idle",
|
|
33
|
+
"all_frames": false
|
|
21
34
|
}
|
|
22
35
|
],
|
|
23
36
|
"host_permissions": ["<all_urls>"]
|
|
@@ -115,6 +115,22 @@
|
|
|
115
115
|
margin-left: 6px;
|
|
116
116
|
}
|
|
117
117
|
.tab-item .tab-row { display: flex; align-items: center; margin-left: 6px; flex: 1; min-width: 0; }
|
|
118
|
+
.yearning-item {
|
|
119
|
+
padding: 6px 8px; margin: 3px 0; border-radius: 4px;
|
|
120
|
+
background: #f5f5f5; cursor: pointer; font-size: 11px;
|
|
121
|
+
}
|
|
122
|
+
.yearning-item.active { background: #e3f2fd; border-left: 3px solid #2196F3; }
|
|
123
|
+
.yearning-item .yr-title { font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
124
|
+
.yearning-item .yr-meta { color: #666; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
125
|
+
.yearning-item .yr-badge { color: #4CAF50; font-size: 10px; margin-left: 4px; }
|
|
126
|
+
.csv-item {
|
|
127
|
+
display: flex; align-items: center; gap: 6px;
|
|
128
|
+
padding: 5px 8px; margin: 3px 0; border-radius: 4px;
|
|
129
|
+
background: #f0f7ff; cursor: pointer; font-size: 11px;
|
|
130
|
+
}
|
|
131
|
+
.csv-item:hover { background: #dcebfd; }
|
|
132
|
+
.csv-item .csv-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
133
|
+
.csv-item .csv-rows { color: #888; font-size: 10px; flex-shrink: 0; }
|
|
118
134
|
.guide {
|
|
119
135
|
font-size: 11px;
|
|
120
136
|
color: #666;
|
|
@@ -177,6 +193,23 @@
|
|
|
177
193
|
<div id="tabList" style="margin-top:6px;"></div>
|
|
178
194
|
</div>
|
|
179
195
|
|
|
196
|
+
<div class="section">
|
|
197
|
+
<div class="section-title">Yearning 监听</div>
|
|
198
|
+
<div class="hint" style="margin-bottom:6px;">监听 Yearning SQL 查询结果。先监听页面,再选择一个页面作为 SQL 注入和结果接收目标。</div>
|
|
199
|
+
<button class="scan" id="btnWsTap">📡 监听当前 Yearning 页</button>
|
|
200
|
+
<div class="hint" id="wsTapStatus">未监听 Yearning 页面</div>
|
|
201
|
+
<div id="yearningTabList" style="margin-top:6px;"></div>
|
|
202
|
+
<div id="csvSection" style="margin-top:8px;display:none;">
|
|
203
|
+
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;">
|
|
204
|
+
<span class="section-title" style="margin:0;">CSV 导出记录</span>
|
|
205
|
+
<button class="copy-btn" id="btnCsvClear" style="background:#e53935;">清空</button>
|
|
206
|
+
</div>
|
|
207
|
+
<div id="csvList"></div>
|
|
208
|
+
<div class="hint">点击记录下载 · 表名_库_数据源_日期.csv</div>
|
|
209
|
+
</div>
|
|
210
|
+
<button class="scan" id="btnCopyPrompt" style="margin-top:8px;background:#7c4dff;">📋 复制 Agent 使用 Prompt</button>
|
|
211
|
+
</div>
|
|
212
|
+
|
|
180
213
|
<div class="section">
|
|
181
214
|
<div class="section-title">桥接代理</div>
|
|
182
215
|
<div class="status-line">
|
package/files/extension/popup.js
CHANGED
|
@@ -75,6 +75,134 @@ document.getElementById('btnXtermScan').onclick = () => {
|
|
|
75
75
|
});
|
|
76
76
|
};
|
|
77
77
|
|
|
78
|
+
// ============== Yearning 监听(多 tab 绑定)==============
|
|
79
|
+
const btnWsTap = document.getElementById('btnWsTap');
|
|
80
|
+
const wsTapStatus = document.getElementById('wsTapStatus');
|
|
81
|
+
const yearningTabList = document.getElementById('yearningTabList');
|
|
82
|
+
|
|
83
|
+
function refreshYearningTabs() {
|
|
84
|
+
chrome.runtime.sendMessage({ type: 'YR_TAP_STATUS' }, (res) => {
|
|
85
|
+
if (chrome.runtime.lastError || !res || !res.ok) return;
|
|
86
|
+
const tabs = res.tabs || [];
|
|
87
|
+
wsTapStatus.textContent = tabs.length
|
|
88
|
+
? `${tabs.length} 个 Yearning 页面已监听,当前选中 1 个`
|
|
89
|
+
: '未监听 Yearning 页面';
|
|
90
|
+
yearningTabList.innerHTML = tabs.map(t => {
|
|
91
|
+
const title = escapeHtml(t.title || 'Yearning');
|
|
92
|
+
const label = escapeHtml(t.label || [t.database, t.dataSource].filter(Boolean).join(' · ') || t.host || '数据库信息读取中');
|
|
93
|
+
const host = escapeHtml(t.host || `tab ${t.tabId}`);
|
|
94
|
+
return `<div class="yearning-item ${t.active ? 'active' : ''}" data-tabid="${t.tabId}">
|
|
95
|
+
<div class="yr-title">${t.active ? '◉' : '○'} ${title}${t.active ? '<span class="yr-badge">✓ 当前 Yearning 页面</span>' : ''}${t.isCurrent ? '<span class="yr-badge">● 当前浏览器页</span>' : ''}</div>
|
|
96
|
+
<div class="yr-meta">${label} · ${host}</div>
|
|
97
|
+
</div>`;
|
|
98
|
+
}).join('');
|
|
99
|
+
yearningTabList.querySelectorAll('.yearning-item').forEach(el => {
|
|
100
|
+
el.onclick = () => {
|
|
101
|
+
chrome.runtime.sendMessage({ type: 'YR_TAP_SELECT', tabId: Number(el.dataset.tabid) }, () => refreshYearningTabs());
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
// 按钮文案按「当前浏览器页是否在监听」决定(不是看 active 选中页)
|
|
105
|
+
const thisTabWatched = tabs.some(t => t.isCurrent);
|
|
106
|
+
btnWsTap.textContent = thisTabWatched ? '⏹ 停止监听当前 Yearning 页' : '📡 监听当前 Yearning 页';
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function escapeHtml(value) {
|
|
111
|
+
return String(value).replace(/[&<>"']/g, ch => ({ '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[ch]));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
btnWsTap.onclick = () => {
|
|
115
|
+
chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
|
|
116
|
+
const tab = tabs && tabs[0];
|
|
117
|
+
if (!tab || !tab.id) { wsTapStatus.textContent = '未找到当前页'; return; }
|
|
118
|
+
// 先查当前状态:已在监听 → 停止;未监听 → 添加
|
|
119
|
+
chrome.runtime.sendMessage({ type: 'YR_TAP_STATUS' }, (st) => {
|
|
120
|
+
if (chrome.runtime.lastError || !st || !st.ok) {
|
|
121
|
+
wsTapStatus.textContent = '状态查询失败';
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const already = (st.tapTabs || []).includes(tab.id);
|
|
125
|
+
const type = already ? 'WS_TAP_DETACH' : 'WS_TAP_ATTACH';
|
|
126
|
+
chrome.runtime.sendMessage({ type, tabId: tab.id }, (res) => {
|
|
127
|
+
if (chrome.runtime.lastError || !res || !res.ok) {
|
|
128
|
+
wsTapStatus.textContent = (already ? '停止失败: ' : '监听失败: ') + (res && res.msg || '无响应');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
wsTapStatus.textContent = already ? '已停止监听该页面' : '已监听';
|
|
132
|
+
refreshYearningTabs();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
refreshYearningTabs();
|
|
139
|
+
|
|
140
|
+
// ============== CSV 导出记录 ==============
|
|
141
|
+
const csvSection = document.getElementById('csvSection');
|
|
142
|
+
const csvList = document.getElementById('csvList');
|
|
143
|
+
|
|
144
|
+
function refreshCsvList() {
|
|
145
|
+
chrome.runtime.sendMessage({ type: 'CSV_LIST' }, (res) => {
|
|
146
|
+
if (chrome.runtime.lastError || !res || !res.ok) return;
|
|
147
|
+
const all = res.exports || [];
|
|
148
|
+
csvSection.style.display = all.length ? 'block' : 'none';
|
|
149
|
+
// 只显示最近 5 条,popup 不超长(存储仍保留 20 条)
|
|
150
|
+
const exports = all.slice(0, 5);
|
|
151
|
+
csvList.innerHTML = exports.map(e => {
|
|
152
|
+
const time = new Date(e.time).toLocaleTimeString();
|
|
153
|
+
return `<div class="csv-item" data-id="${e.id}" title="${escapeHtml(e.sql || e.name)}">
|
|
154
|
+
<span class="csv-name">📄 ${escapeHtml(e.name)}</span>
|
|
155
|
+
<span class="csv-rows">${e.rows} 行 · ${time}</span>
|
|
156
|
+
</div>`;
|
|
157
|
+
}).join('');
|
|
158
|
+
csvList.querySelectorAll('.csv-item').forEach(el => {
|
|
159
|
+
el.onclick = () => {
|
|
160
|
+
chrome.runtime.sendMessage({ type: 'CSV_DOWNLOAD', id: Number(el.dataset.id) }, (r) => {
|
|
161
|
+
if (chrome.runtime.lastError || !r || !r.ok) {
|
|
162
|
+
console.warn('重新下载失败:', r && r.msg);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
};
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
refreshCsvList();
|
|
170
|
+
|
|
171
|
+
// 清空 CSV 导出记录
|
|
172
|
+
document.getElementById('btnCsvClear').onclick = () => {
|
|
173
|
+
chrome.runtime.sendMessage({ type: 'CSV_CLEAR' }, () => refreshCsvList());
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// 复制 Agent 使用 Prompt(粘给任意 AI 助手即可用桥接查 Yearning)
|
|
177
|
+
const AGENT_PROMPT = `你的机器上已装好 Terminal Bridge(Yearning SQL 桥接)。请按以下方式查询数据库:
|
|
178
|
+
|
|
179
|
+
## 查询(Yearning)
|
|
180
|
+
cd ~/.terminal-bridge/proxy && node yr-example.mjs "SELECT ...;" [超时ms]
|
|
181
|
+
|
|
182
|
+
返回 JSON:{ ok, output(结果 JSON: field 列定义 + data 数据行), error }。
|
|
183
|
+
|
|
184
|
+
## 说明
|
|
185
|
+
- SQL 会自动注入到用户浏览器里选中的 Yearning 页面并点「查询」,结果自动返回
|
|
186
|
+
- 前置:用户已在 Yearning 页面选择数据库,且插件 popup 已「监听当前 Yearning 页」
|
|
187
|
+
- 多个 Yearning 页面时:用户在 popup 列表点选目标页面(显示 数据源 · 数据库)
|
|
188
|
+
- 报 database-not-selected = 页面未选数据库,请让用户先选
|
|
189
|
+
- 报 timeout = 查询超时或页面未监听,让用户确认 popup 状态
|
|
190
|
+
- 只读查询即可;每次查询的结果会自动出现在插件 popup 的「CSV 导出记录」里供用户下载
|
|
191
|
+
|
|
192
|
+
## 终端命令(JumpServer/Arthas,同一代理)
|
|
193
|
+
cd ~/.terminal-bridge/proxy && node client-example.mjs "linux 命令" [超时ms]
|
|
194
|
+
多层引号命令加 --b64 参数下发。`;
|
|
195
|
+
|
|
196
|
+
document.getElementById('btnCopyPrompt').onclick = () => {
|
|
197
|
+
navigator.clipboard.writeText(AGENT_PROMPT).then(() => {
|
|
198
|
+
const btn = document.getElementById('btnCopyPrompt');
|
|
199
|
+
btn.textContent = '✓ 已复制,粘贴给 Agent 即可';
|
|
200
|
+
setTimeout(() => { btn.textContent = '📋 复制 Agent 使用 Prompt'; }, 2000);
|
|
201
|
+
}).catch(() => {
|
|
202
|
+
console.warn('复制失败');
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
|
|
78
206
|
// ============== 代理控制 ==============
|
|
79
207
|
const proxyDot = document.getElementById('proxyDot');
|
|
80
208
|
const proxyText = document.getElementById('proxyStatusText');
|
|
@@ -176,7 +304,7 @@ document.getElementById('btnCopyInstall').onclick = () => {
|
|
|
176
304
|
};
|
|
177
305
|
|
|
178
306
|
// 定时刷新(popup 打开期间)
|
|
179
|
-
setInterval(() => { refreshXterm(); refreshProxy(); }, 3000);
|
|
307
|
+
setInterval(() => { refreshXterm(); refreshProxy(); refreshCsvList(); refreshYearningTabs(); }, 3000);
|
|
180
308
|
refreshXterm();
|
|
181
309
|
refreshProxy();
|
|
182
310
|
// 启动时主动探测一次 native host:发个 status,失败就显示安装提示
|