terminal-bridge-setup 3.1.0 → 3.3.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/files/extension/background.js +311 -30
- package/files/extension/content-yearning.js +552 -8
- package/files/extension/manifest.json +2 -2
- package/files/extension/popup.html +6 -2
- package/files/extension/popup.js +23 -1
- package/files/proxy/server.js +133 -36
- package/files/proxy/yr-example.mjs +41 -5
- package/files/proxy/yr-sql-guard.js +43 -0
- package/files/skill/SKILL.md +73 -0
- package/files/skill/references/protocol.md +27 -0
- package/package.json +2 -2
|
@@ -8,6 +8,10 @@
|
|
|
8
8
|
// yr-ping 探测编辑器类型和查询按钮,返回结构化信息
|
|
9
9
|
// yr-sql-set {sql} 注入 SQL(按探测到的编辑器类型选策略)
|
|
10
10
|
// yr-query-click 找「查询」按钮并点击
|
|
11
|
+
// yr-source-switch {target} 点「切换数据源」按钮并在弹层中选中目标数据源,
|
|
12
|
+
// 以 URL hash 变化做成功验证(切源会切页面路由)
|
|
13
|
+
// yr-db-select {database} 打开库选择器的 antd Select 下拉,点选目标 schema,
|
|
14
|
+
// 以重读 meta 的 database 值做成功验证
|
|
11
15
|
|
|
12
16
|
(function () {
|
|
13
17
|
const TAG = "[terminal-bridge-yr]";
|
|
@@ -107,7 +111,8 @@
|
|
|
107
111
|
if (idc && idc !== dataSource) dataSource = dataSource ? `${dataSource} · ${idc}` : idc;
|
|
108
112
|
} catch {}
|
|
109
113
|
|
|
110
|
-
// 3. 兜底:form
|
|
114
|
+
// 3. 兜底:form 启发式(排除与 dataSource 重叠/相似的值,防止把
|
|
115
|
+
// 数据源名误认成数据库名——文件名第二段曾因此错成 dk_shard)
|
|
111
116
|
if (!database || !dataSource) {
|
|
112
117
|
const form = document.querySelector("form") || xpathNode(FORM_XPATH);
|
|
113
118
|
if (form) {
|
|
@@ -117,9 +122,11 @@
|
|
|
117
122
|
const value = el.tagName === "SELECT" ? el.options[el.selectedIndex]?.textContent : el.value;
|
|
118
123
|
if (value?.trim()) values.push(value.trim());
|
|
119
124
|
});
|
|
120
|
-
const unique = [...new Set(values)].filter(v =>
|
|
125
|
+
const unique = [...new Set(values)].filter(v =>
|
|
126
|
+
!/^(查询|执行|取消|确定|SQL)$/i.test(v) &&
|
|
127
|
+
v !== dataSource && !dataSource.includes(v) && !v.includes("shard"));
|
|
121
128
|
if (!dataSource) dataSource = unique.find(v => /source|实例|数据源|tdsql|mysql|prod|test/i.test(v)) || "";
|
|
122
|
-
if (!database) database = unique.find(v => /database|
|
|
129
|
+
if (!database) database = unique.find(v => /database|schema|^\w+_dk\b/i.test(v)) || "";
|
|
123
130
|
}
|
|
124
131
|
}
|
|
125
132
|
|
|
@@ -142,8 +149,18 @@
|
|
|
142
149
|
}
|
|
143
150
|
|
|
144
151
|
// ---------- monaco 编辑器内容读取(注入验证用,无 API 时从 DOM 读)----------
|
|
152
|
+
// 多 SQL tab 并存时 DOM 里有多个 monaco 实例,inactive tab 的编辑器不渲染
|
|
153
|
+
// view-lines——必须优先取"可见"的那个,否则注入/读回都落在旧隐藏 tab 上
|
|
154
|
+
function activeMonaco() {
|
|
155
|
+
return [...document.querySelectorAll(".monaco-editor")]
|
|
156
|
+
.find(m => m.offsetParent !== null)
|
|
157
|
+
|| document.querySelector(".monaco-editor");
|
|
158
|
+
}
|
|
159
|
+
|
|
145
160
|
function readMonacoText() {
|
|
146
|
-
const
|
|
161
|
+
const m = activeMonaco();
|
|
162
|
+
if (!m) return "";
|
|
163
|
+
const lines = [...m.querySelectorAll(".view-lines .view-line")]
|
|
147
164
|
.map(l => l.textContent || "");
|
|
148
165
|
return lines.join("\n");
|
|
149
166
|
}
|
|
@@ -236,7 +253,395 @@
|
|
|
236
253
|
return { ok: true, via: exact.text };
|
|
237
254
|
}
|
|
238
255
|
|
|
239
|
-
// ----------
|
|
256
|
+
// ---------- 归一化文本 / 等待 ----------
|
|
257
|
+
function normText(el) {
|
|
258
|
+
return (el.textContent || "").replace(/\s+/g, "").trim();
|
|
259
|
+
}
|
|
260
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
261
|
+
function pressEscape() {
|
|
262
|
+
// antd Modal/Select 对 keydown Escape 响应关闭弹层(含 keyCode 兼容旧版本)
|
|
263
|
+
document.dispatchEvent(new KeyboardEvent("keydown", {
|
|
264
|
+
key: "Escape", keyCode: 27, which: 27, bubbles: true, cancelable: true
|
|
265
|
+
}));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// ---------- 切换数据源(点「切换数据源」→ 弹层选目标 → hash 变化验证)----------
|
|
269
|
+
async function switchDataSource(targetName) {
|
|
270
|
+
const wanted = String(targetName || "").trim();
|
|
271
|
+
if (!wanted) return { ok: false, error: "missing target source name" };
|
|
272
|
+
const wantNorm = wanted.replace(/\s+/g, "");
|
|
273
|
+
|
|
274
|
+
const btns = [...document.querySelectorAll("button")]
|
|
275
|
+
.filter(b => b.offsetParent !== null && !b.disabled);
|
|
276
|
+
const entry = btns.find(b => normText(b).includes("切换数据源"));
|
|
277
|
+
if (!entry) {
|
|
278
|
+
return {
|
|
279
|
+
ok: false,
|
|
280
|
+
error: "「切换数据源」入口按钮未找到",
|
|
281
|
+
buttons: btns.map(b => normText(b)).filter(t => t && t.length <= 12).slice(0, 25),
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
const hashBefore = location.hash;
|
|
286
|
+
entry.click();
|
|
287
|
+
|
|
288
|
+
// 弹层形态未知,宽扫所有常见 antd overlay 容器里的可点击元素。
|
|
289
|
+
// 只保留"叶子"命中(内部不含其他命中元素),避免点到整个外层容器导致误击。
|
|
290
|
+
const grabMatches = () => {
|
|
291
|
+
const scopes = [...document.querySelectorAll(
|
|
292
|
+
".ant-modal,.ant-drawer,.ant-dropdown,.ant-popover,[class*=drawer],[class*=modal]"
|
|
293
|
+
)].filter(el => el.offsetParent !== null);
|
|
294
|
+
const hits = [];
|
|
295
|
+
for (const sc of scopes) {
|
|
296
|
+
sc.querySelectorAll("li,[role=menuitem],[role=option],[class*=item],a,button,td").forEach(el => {
|
|
297
|
+
if (el.offsetParent === null) return;
|
|
298
|
+
const t = normText(el);
|
|
299
|
+
if (!t || t.length > 48 || !t.includes(wantNorm)) return;
|
|
300
|
+
hits.push({ el, t });
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
return hits.filter(h => !hits.some(o => o !== h && h.el.contains(o.el)));
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
const deadlineHit = Date.now() + 3000;
|
|
307
|
+
let leaves = [];
|
|
308
|
+
while (Date.now() < deadlineHit) {
|
|
309
|
+
leaves = grabMatches();
|
|
310
|
+
if (leaves.length > 0) break;
|
|
311
|
+
await sleep(150);
|
|
312
|
+
}
|
|
313
|
+
if (leaves.length === 0) {
|
|
314
|
+
pressEscape();
|
|
315
|
+
return { ok: false, error: "弹层中未找到目标数据源项", searched: wanted };
|
|
316
|
+
}
|
|
317
|
+
if (leaves.length > 1) {
|
|
318
|
+
const exact = leaves.find(l => l.t === wantNorm);
|
|
319
|
+
if (exact) leaves = [exact];
|
|
320
|
+
}
|
|
321
|
+
if (leaves.length > 1) {
|
|
322
|
+
pressEscape();
|
|
323
|
+
return { ok: false, error: "目标数据源命中多个候选", candidates: leaves.map(l => l.t).slice(0, 10) };
|
|
324
|
+
}
|
|
325
|
+
leaves[0].el.click();
|
|
326
|
+
|
|
327
|
+
// 验证:切源会切换页面路由,等 URL hash 变化(~6s)
|
|
328
|
+
const deadlineVer = Date.now() + 6000;
|
|
329
|
+
while (Date.now() < deadlineVer) {
|
|
330
|
+
await sleep(250);
|
|
331
|
+
if (location.hash !== hashBefore) {
|
|
332
|
+
return { ok: true, via: "source-switch", hash: location.hash };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return {
|
|
336
|
+
ok: false,
|
|
337
|
+
error: "已点击候选但路由未变化(hash 不变),切源可能失败",
|
|
338
|
+
clickedText: leaves[0].t,
|
|
339
|
+
hash: location.hash,
|
|
340
|
+
};
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---------- 选数据库 schema(antd Select 下拉点选 → 重读 meta 验证)----------
|
|
344
|
+
function closestAntdSelect(node) {
|
|
345
|
+
let cur = node;
|
|
346
|
+
for (let i = 0; cur && i < 8; i++) {
|
|
347
|
+
if (cur.classList && cur.classList.contains("ant-select")) return cur;
|
|
348
|
+
cur = cur.parentElement;
|
|
349
|
+
}
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function selectDumpLite(el) {
|
|
354
|
+
const r = el.getBoundingClientRect();
|
|
355
|
+
return {
|
|
356
|
+
cls: String(el.className || "").slice(0, 80),
|
|
357
|
+
inForm: !!el.closest("form"),
|
|
358
|
+
rect: { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) },
|
|
359
|
+
hasInput: !!el.querySelector("input"),
|
|
360
|
+
visible: el.offsetParent !== null && r.width > 0 && r.height > 0,
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function findDbTrigger() {
|
|
365
|
+
// 枚举所有 .ant-select,优先"可见 + 在 form 内 + 带 input"(可搜索 combobox);
|
|
366
|
+
// 新建 tab 后旧 select 会被隐藏(rect 全 0),必须过滤,否则点在 (0,0) 上
|
|
367
|
+
const all = [...document.querySelectorAll(".ant-select")];
|
|
368
|
+
const visible = all.filter(s => {
|
|
369
|
+
const r = s.getBoundingClientRect();
|
|
370
|
+
return s.offsetParent !== null && r.width > 0 && r.height > 0;
|
|
371
|
+
});
|
|
372
|
+
const score = (s) => (s.closest("form") ? 2 : 0) + (s.querySelector("input") ? 1 : 0);
|
|
373
|
+
const byScore = [...visible].sort((a, b) => score(b) - score(a));
|
|
374
|
+
// 主路径:DATABASE_XPATH 锚点(仍要求可见,防隐藏残留)
|
|
375
|
+
const anchor = xpathNode(DATABASE_XPATH);
|
|
376
|
+
const anchored = anchor ? closestAntdSelect(anchor) : null;
|
|
377
|
+
if (anchored && visible.includes(anchored)) return anchored;
|
|
378
|
+
return byScore[0] || null;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function visibleDbDropdown() {
|
|
382
|
+
// 页面会残留多个 dropdown portal(旧 select 隐藏后 portal 仍在 DOM)。
|
|
383
|
+
// antd 新 portal 渲染在 body 末尾、层级最高——取"最后一个可见"的才是
|
|
384
|
+
// 刚打开的那个;取第一个会拿到坐标错位的旧 portal(实测点 dk_shard
|
|
385
|
+
// 落到 information_schema 上)。
|
|
386
|
+
const visible = [...document.querySelectorAll(".ant-select-dropdown")]
|
|
387
|
+
.filter(d => d.offsetParent !== null && !/dropdown-hidden/.test(d.className));
|
|
388
|
+
return visible[visible.length - 1] || null;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
async function selectDatabase(targetName) {
|
|
392
|
+
const wanted = String(targetName || "").trim();
|
|
393
|
+
if (!wanted) return { ok: false, error: "missing database name" };
|
|
394
|
+
const wantNorm = wanted.replace(/\s+/g, "");
|
|
395
|
+
const trigger = findDbTrigger();
|
|
396
|
+
if (!trigger) return { ok: false, error: "库选择器未找到(antd Select)" };
|
|
397
|
+
|
|
398
|
+
// rc-select 打开依赖 mousedown;click 序列作为部分版本的兜底重试
|
|
399
|
+
const openSeqs = [
|
|
400
|
+
() => trigger.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true })),
|
|
401
|
+
() => {
|
|
402
|
+
trigger.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
|
|
403
|
+
trigger.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true }));
|
|
404
|
+
trigger.click();
|
|
405
|
+
},
|
|
406
|
+
];
|
|
407
|
+
const readOptions = () => {
|
|
408
|
+
const dd = visibleDbDropdown();
|
|
409
|
+
if (!dd) return null;
|
|
410
|
+
const nodes = [...dd.querySelectorAll(".ant-select-item-option, [role=option]")];
|
|
411
|
+
if (nodes.length === 0) return null;
|
|
412
|
+
return nodes.map(o => {
|
|
413
|
+
const text = o.getAttribute("title") || o.textContent || "";
|
|
414
|
+
return { el: o, text, norm: text.replace(/\s+/g, ""), disabled: /disabled/.test(o.className) };
|
|
415
|
+
}).filter(o => o.norm);
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
let opts = null;
|
|
419
|
+
for (const open of openSeqs) {
|
|
420
|
+
open();
|
|
421
|
+
const ddl = Date.now() + 1200;
|
|
422
|
+
while (Date.now() < ddl) {
|
|
423
|
+
await sleep(120);
|
|
424
|
+
opts = readOptions();
|
|
425
|
+
if (opts) break;
|
|
426
|
+
}
|
|
427
|
+
if (opts) break;
|
|
428
|
+
}
|
|
429
|
+
if (!opts) return { ok: false, error: "下拉未能展开(mousedown/click 后均无选项出现)" };
|
|
430
|
+
|
|
431
|
+
const hit = opts.find(o => o.norm === wantNorm) || opts.find(o => o.norm.includes(wantNorm));
|
|
432
|
+
if (!hit) {
|
|
433
|
+
pressEscape();
|
|
434
|
+
return { ok: false, error: "下拉中无匹配的库", expect: wanted, options: opts.map(o => o.text.trim()).slice(0, 30) };
|
|
435
|
+
}
|
|
436
|
+
if (hit.disabled) {
|
|
437
|
+
pressEscape();
|
|
438
|
+
return { ok: false, error: "目标库存在但为禁用状态(无权限)", target: wanted };
|
|
439
|
+
}
|
|
440
|
+
// antd option 以 mouseup/click 完成选中
|
|
441
|
+
hit.el.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true }));
|
|
442
|
+
hit.el.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, cancelable: true }));
|
|
443
|
+
hit.el.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
|
|
444
|
+
|
|
445
|
+
// 验证:重读 meta,确认 form 上实际显示的库名已变为目标
|
|
446
|
+
const vdl = Date.now() + 2000;
|
|
447
|
+
while (Date.now() < vdl) {
|
|
448
|
+
await sleep(200);
|
|
449
|
+
const meta = readYearningMeta();
|
|
450
|
+
if (meta.database && meta.database.replace(/\s+/g, "") === wantNorm) {
|
|
451
|
+
return { ok: true, via: "antd-select", database: meta.database };
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
const finalMeta = readYearningMeta();
|
|
455
|
+
return {
|
|
456
|
+
ok: false,
|
|
457
|
+
error: "已点击选项但验证失败:form 实际显示库名与目标不符",
|
|
458
|
+
expect: wanted,
|
|
459
|
+
actual: finalMeta.database,
|
|
460
|
+
options: opts.map(o => o.text.trim()).slice(0, 30),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// ---------- 只读定位/枚举(供 background 用 CDP 真实鼠标事件编排)----------
|
|
465
|
+
// 经验教训:antd Select 对合成 mousedown 不响应,必须 CDP Input.dispatchMouseEvent
|
|
466
|
+
// 在坐标上产生受信任点击;本脚本只负责"把元素找到并给出中心坐标"、"枚举下拉/弹层项"。
|
|
467
|
+
function rectCenterOf(el) {
|
|
468
|
+
el.scrollIntoView({ block: "center" });
|
|
469
|
+
const r = el.getBoundingClientRect();
|
|
470
|
+
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + r.height / 2) };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function locateTarget(kind, arg) {
|
|
474
|
+
if (kind === "db-trigger") {
|
|
475
|
+
const el = findDbTrigger();
|
|
476
|
+
return el ? rectCenterOf(el) : null;
|
|
477
|
+
}
|
|
478
|
+
if (kind === "entry-button") {
|
|
479
|
+
// arg=按钮归一化文本片段,取文本最短的可命中项(通常是真正的入口小按钮)
|
|
480
|
+
const btn = [...document.querySelectorAll("button")]
|
|
481
|
+
.filter(b => b.offsetParent !== null && !b.disabled && normText(b).includes(String(arg || "")))
|
|
482
|
+
.sort((a, b) => normText(a).length - normText(b).length)[0];
|
|
483
|
+
return btn ? rectCenterOf(btn) : null;
|
|
484
|
+
}
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// 容器内可点元素取叶子节点(内部不含其他命中元素),避免坐标落在整块外层容器上误击
|
|
489
|
+
function collectLeafItems(scopes, maxItems) {
|
|
490
|
+
const hits = [];
|
|
491
|
+
for (const sc of scopes) {
|
|
492
|
+
sc.querySelectorAll("li,[role=menuitem],[role=option],[class*=item],a,button,td").forEach(el => {
|
|
493
|
+
if (el.offsetParent === null) return;
|
|
494
|
+
const t = normText(el);
|
|
495
|
+
if (!t || t.length > 48) return;
|
|
496
|
+
hits.push({ el, t });
|
|
497
|
+
});
|
|
498
|
+
}
|
|
499
|
+
return hits
|
|
500
|
+
.filter(h => !hits.some(o => o !== h && h.el.contains(o.el)))
|
|
501
|
+
.slice(0, maxItems || 40)
|
|
502
|
+
.map(h => ({ text: h.t, rect: rectCenterOf(h.el) }));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function listDropdownOptionsWithRects() {
|
|
506
|
+
const dd = visibleDbDropdown();
|
|
507
|
+
if (!dd) {
|
|
508
|
+
// 诊断随行:下拉未出现时带回焦点元素与 listbox 节点是否存在于 DOM
|
|
509
|
+
const ae = document.activeElement;
|
|
510
|
+
return {
|
|
511
|
+
ready: false,
|
|
512
|
+
activeEl: ae ? { cls: String(ae.className || "").slice(0, 80), id: ae.id || "", tag: ae.tagName } : null,
|
|
513
|
+
listboxInDom: !!document.querySelector("[role=listbox]"),
|
|
514
|
+
listboxOptions: document.querySelectorAll("[role=listbox] .ant-select-item-option").length,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
const nodes = [...dd.querySelectorAll(".ant-select-item-option, [role=option]")];
|
|
518
|
+
if (nodes.length === 0) return { ready: false };
|
|
519
|
+
// rc-virtual-list 会为每个选项渲染一份隐藏"测量行"(零尺寸),
|
|
520
|
+
// 其坐标漂移会导致点错行——过滤零尺寸后按文本去重,只留真实可见行
|
|
521
|
+
const real = nodes
|
|
522
|
+
.map(o => {
|
|
523
|
+
const r = o.getBoundingClientRect();
|
|
524
|
+
const text = o.getAttribute("title") || o.textContent || "";
|
|
525
|
+
return {
|
|
526
|
+
text: text.trim(),
|
|
527
|
+
disabled: /disabled/.test(o.className),
|
|
528
|
+
x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height),
|
|
529
|
+
};
|
|
530
|
+
})
|
|
531
|
+
.filter(o => o.text && o.w > 0 && o.h > 0);
|
|
532
|
+
const byText = new Map();
|
|
533
|
+
for (const o of real) if (!byText.has(o.text)) byText.set(o.text, o);
|
|
534
|
+
const options = [...byText.values()].map(o => ({
|
|
535
|
+
text: o.text, disabled: o.disabled,
|
|
536
|
+
rect: { x: Math.round(o.x + o.w / 2), y: Math.round(o.y + o.h / 2) },
|
|
537
|
+
}));
|
|
538
|
+
return { ready: options.length > 0, options };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function listOverlayItems() {
|
|
542
|
+
const scopes = [...document.querySelectorAll(
|
|
543
|
+
".ant-modal,.ant-drawer,.ant-dropdown,.ant-popover,[class*=drawer],[class*=modal]"
|
|
544
|
+
)].filter(el => el.offsetParent !== null);
|
|
545
|
+
return { items: collectLeafItems(scopes, 40) };
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// ---------- 切源弹层(元素级枚举/点击,不依赖坐标)----------
|
|
549
|
+
// modal/portal 场景 offsetParent 判可见不可靠(fixed 祖先/动画态返回 null),
|
|
550
|
+
// 用 getClientRects() 判"已布局";点击直接在元素上派发事件(React 合成事件可达)
|
|
551
|
+
function laidOut(el) {
|
|
552
|
+
try {
|
|
553
|
+
const r = el.getBoundingClientRect();
|
|
554
|
+
return el.getClientRects().length > 0 && r.width > 1 && r.height > 1;
|
|
555
|
+
} catch { return false; }
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function overlayScopes() {
|
|
559
|
+
return [...document.querySelectorAll(
|
|
560
|
+
".ant-modal,.ant-drawer,.ant-dropdown,.ant-popover,[class*=drawer],[class*=modal]"
|
|
561
|
+
)].filter(laidOut);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
function overlayLeafElements() {
|
|
565
|
+
// scope 互相嵌套(mask⊃wrap⊃modal),同一元素会被重复收集;
|
|
566
|
+
// 且 el.contains(自身)===true,重复对象会在叶子过滤时互相"包含"而全军覆没
|
|
567
|
+
// (实测 rawCount=255 全有效、叶子过滤后=0 的根因)——先按元素去重再过滤
|
|
568
|
+
const seen = new Set();
|
|
569
|
+
const hits = [];
|
|
570
|
+
for (const sc of overlayScopes()) {
|
|
571
|
+
sc.querySelectorAll("li,[role=menuitem],[role=option],[class*=item],a,button,td").forEach(el => {
|
|
572
|
+
if (seen.has(el) || !laidOut(el)) return;
|
|
573
|
+
seen.add(el);
|
|
574
|
+
const t = normText(el);
|
|
575
|
+
if (!t || t.length > 48) return;
|
|
576
|
+
hits.push({ el, t });
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
return hits.filter(h => !hits.some(o => o.el !== h.el && h.el.contains(o.el)));
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function listSourceModalItems() {
|
|
583
|
+
// 自诊断版:带回每一级过滤的计数与原始命中,定位"哪一步滤没了"
|
|
584
|
+
const scopes = overlayScopes();
|
|
585
|
+
let raw = [];
|
|
586
|
+
for (const sc of scopes) {
|
|
587
|
+
sc.querySelectorAll("li,[role=menuitem],[role=option],[class*=item],a,button,td").forEach(el => {
|
|
588
|
+
raw.push({ el, laidOut: laidOut(el), t: normText(el) });
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
const leaves = overlayLeafElements();
|
|
592
|
+
return {
|
|
593
|
+
items: leaves.map(h => h.t).slice(0, 40),
|
|
594
|
+
debug: {
|
|
595
|
+
scopeCount: scopes.length,
|
|
596
|
+
scopeCls: scopes.slice(0, 3).map(s => String(s.className).slice(0, 80)),
|
|
597
|
+
rawCount: raw.length,
|
|
598
|
+
rawLaidOut: raw.filter(r => r.laidOut).length,
|
|
599
|
+
rawTextOk: raw.filter(r => r.laidOut && r.t && r.t.length <= 48).length,
|
|
600
|
+
sample: raw.filter(r => r.laidOut && r.t).slice(0, 12).map(r => r.t.slice(0, 30)),
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function clickSourceItem(target) {
|
|
606
|
+
const wanted = String(target || "").replace(/\s+/g, "");
|
|
607
|
+
const leaves = overlayLeafElements();
|
|
608
|
+
if (leaves.length === 0) return { ok: false, error: "弹层未打开或无可选项" };
|
|
609
|
+
const matches = leaves.filter(h => h.t.includes(wanted));
|
|
610
|
+
if (matches.length === 0) {
|
|
611
|
+
return { ok: false, error: "弹层中无匹配的数据源", candidates: leaves.map(h => h.t).slice(0, 30) };
|
|
612
|
+
}
|
|
613
|
+
if (matches.length > 1) {
|
|
614
|
+
const exact = matches.find(h => h.t === wanted);
|
|
615
|
+
if (!exact) {
|
|
616
|
+
return { ok: false, error: "目标命中多个候选,需精确名称", candidates: matches.map(h => h.t) };
|
|
617
|
+
}
|
|
618
|
+
matches.length = 0;
|
|
619
|
+
matches.push(exact);
|
|
620
|
+
}
|
|
621
|
+
["mousedown", "mouseup", "click"].forEach(t =>
|
|
622
|
+
matches[0].el.dispatchEvent(new MouseEvent(t, { bubbles: true, cancelable: true, view: window })));
|
|
623
|
+
return { ok: true, clicked: matches[0].t };
|
|
624
|
+
}
|
|
625
|
+
// ---------- 点选下拉中的目标选项(在元素上直接派发事件,无坐标漂移)----------
|
|
626
|
+
function clickDropdownOption(target) {
|
|
627
|
+
const dd = visibleDbDropdown();
|
|
628
|
+
if (!dd) return { ok: false, error: "dropdown not open" };
|
|
629
|
+
const nodes = [...dd.querySelectorAll(".ant-select-item-option, [role=option]")]
|
|
630
|
+
.filter(o => { const r = o.getBoundingClientRect(); return r.width > 0 && r.height > 0; });
|
|
631
|
+
const norm = s => (s || "").replace(/\s+/g, "");
|
|
632
|
+
const textOf = o => (o.getAttribute("title") || o.textContent || "").trim();
|
|
633
|
+
const hit = nodes.find(o => norm(textOf(o)) === norm(target))
|
|
634
|
+
|| nodes.find(o => norm(textOf(o)).includes(norm(target)));
|
|
635
|
+
if (!hit) {
|
|
636
|
+
return { ok: false, error: "option not found", options: nodes.map(textOf).slice(0, 30) };
|
|
637
|
+
}
|
|
638
|
+
if (/disabled/.test(hit.className)) return { ok: false, error: "目标库为禁用状态(无权限)", target };
|
|
639
|
+
["mousedown", "mouseup", "click"].forEach(t =>
|
|
640
|
+
hit.dispatchEvent(new MouseEvent(t, { bubbles: true, cancelable: true, view: window })));
|
|
641
|
+
return { ok: true, clicked: textOf(hit) };
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ---------- 消息处理 ----------
|
|
240
645
|
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
|
241
646
|
if (!msg || !msg.type) return;
|
|
242
647
|
|
|
@@ -246,10 +651,27 @@
|
|
|
246
651
|
}
|
|
247
652
|
if (msg.type === "yr-ping") {
|
|
248
653
|
detectEditor().then(editor => {
|
|
654
|
+
// 切源入口 / Select 状态 / 当前路由,辅助诊断切库链路
|
|
655
|
+
const hasSourceEntry = [...document.querySelectorAll("button")]
|
|
656
|
+
.some(b => b.offsetParent !== null && normText(b).includes("切换数据源"));
|
|
657
|
+
const selects = [...document.querySelectorAll(".ant-select")]
|
|
658
|
+
.filter(s => s.offsetParent !== null).slice(0, 6)
|
|
659
|
+
.map(s => {
|
|
660
|
+
const valueEl = s.querySelector(".ant-select-selection-item");
|
|
661
|
+
const phEl = s.querySelector(".ant-select-selection-placeholder");
|
|
662
|
+
return {
|
|
663
|
+
value: valueEl ? (valueEl.getAttribute("title") || valueEl.textContent || "").trim() : "",
|
|
664
|
+
placeholder: phEl ? (phEl.textContent || "").trim() : "",
|
|
665
|
+
disabled: /disabled/.test(s.className),
|
|
666
|
+
};
|
|
667
|
+
});
|
|
249
668
|
sendResponse({
|
|
250
669
|
ok: true,
|
|
251
670
|
editor,
|
|
252
671
|
buttons: findQueryButtons().slice(0, 20),
|
|
672
|
+
sourceEntry: hasSourceEntry,
|
|
673
|
+
selects,
|
|
674
|
+
hash: location.hash,
|
|
253
675
|
});
|
|
254
676
|
});
|
|
255
677
|
return true; // 异步响应
|
|
@@ -262,6 +684,123 @@
|
|
|
262
684
|
sendResponse(clickQuery());
|
|
263
685
|
return true;
|
|
264
686
|
}
|
|
687
|
+
if (msg.type === "yr-source-switch") {
|
|
688
|
+
switchDataSource(msg.target).then(sendResponse);
|
|
689
|
+
return true;
|
|
690
|
+
}
|
|
691
|
+
if (msg.type === "yr-db-select") {
|
|
692
|
+
selectDatabase(msg.database).then(sendResponse);
|
|
693
|
+
return true;
|
|
694
|
+
}
|
|
695
|
+
if (msg.type === "yr-focus-db") {
|
|
696
|
+
// 程序化聚焦库选择器的搜索输入框(focus 无需受信任事件,随后键盘事件才有效)
|
|
697
|
+
const trigger = findDbTrigger();
|
|
698
|
+
const input = trigger?.querySelector("input");
|
|
699
|
+
if (!input) {
|
|
700
|
+
sendResponse({
|
|
701
|
+
ok: false,
|
|
702
|
+
error: "库选择器内未找到 input",
|
|
703
|
+
triggerCls: trigger ? String(trigger.className).slice(0, 100) : null,
|
|
704
|
+
selects: [...document.querySelectorAll(".ant-select")].map(selectDumpLite).slice(0, 10),
|
|
705
|
+
});
|
|
706
|
+
return true;
|
|
707
|
+
}
|
|
708
|
+
input.focus();
|
|
709
|
+
const ae = document.activeElement;
|
|
710
|
+
sendResponse({
|
|
711
|
+
ok: ae === input || (ae && input.contains(ae)) || ae === trigger,
|
|
712
|
+
focused: ae === input,
|
|
713
|
+
activeCls: String(ae?.className || "").slice(0, 80),
|
|
714
|
+
activeTag: ae?.tagName,
|
|
715
|
+
});
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
if (msg.type === "yr-db-click-option") {
|
|
719
|
+
sendResponse(clickDropdownOption(msg.database));
|
|
720
|
+
return true;
|
|
721
|
+
}
|
|
722
|
+
if (msg.type === "yr-source-items") {
|
|
723
|
+
sendResponse(listSourceModalItems());
|
|
724
|
+
return true;
|
|
725
|
+
}
|
|
726
|
+
if (msg.type === "yr-source-click") {
|
|
727
|
+
sendResponse(clickSourceItem(msg.target));
|
|
728
|
+
return true;
|
|
729
|
+
}
|
|
730
|
+
if (msg.type === "yr-locate") {
|
|
731
|
+
const rect = locateTarget(msg.kind, msg.arg);
|
|
732
|
+
if (rect) { sendResponse({ ok: true, rect }); return true; }
|
|
733
|
+
// 定位失败带回全量 select 候选(含可见性与尺寸),便于判读页面结构
|
|
734
|
+
sendResponse({
|
|
735
|
+
ok: false,
|
|
736
|
+
error: "target not found: " + (msg.kind || ""),
|
|
737
|
+
selects: [...document.querySelectorAll(".ant-select")].map(selectDumpLite).slice(0, 10),
|
|
738
|
+
});
|
|
739
|
+
return true;
|
|
740
|
+
}
|
|
741
|
+
if (msg.type === "yr-options") {
|
|
742
|
+
sendResponse(listDropdownOptionsWithRects());
|
|
743
|
+
return true;
|
|
744
|
+
}
|
|
745
|
+
if (msg.type === "yr-overlay-items") {
|
|
746
|
+
sendResponse(listOverlayItems());
|
|
747
|
+
return true;
|
|
748
|
+
}
|
|
749
|
+
if (msg.type === "yr-hash") {
|
|
750
|
+
sendResponse({ ok: true, hash: location.hash });
|
|
751
|
+
return true;
|
|
752
|
+
}
|
|
753
|
+
if (msg.type === "yr-active-element") {
|
|
754
|
+
const el = document.activeElement;
|
|
755
|
+
sendResponse(el ? { ok: true, cls: String(el.className || "").slice(0, 100), id: el.id || "", tag: el.tagName } : { ok: false });
|
|
756
|
+
return true;
|
|
757
|
+
}
|
|
758
|
+
if (msg.type === "yr-dom-probe") {
|
|
759
|
+
// 只读探测:抓 Select / 下拉层 / 弹窗的真实类名与文本,用于诊断未知 UI 结构
|
|
760
|
+
const dump = (el) => ({
|
|
761
|
+
cls: String(el.className || "").slice(0, 120),
|
|
762
|
+
text: normText(el).slice(0, 60),
|
|
763
|
+
visible: el.offsetParent !== null,
|
|
764
|
+
});
|
|
765
|
+
try {
|
|
766
|
+
const rectOf = (el) => {
|
|
767
|
+
const r = el.getBoundingClientRect();
|
|
768
|
+
return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) };
|
|
769
|
+
};
|
|
770
|
+
const selectDump = (el) => ({
|
|
771
|
+
cls: String(el.className || "").slice(0, 120),
|
|
772
|
+
text: normText(el).slice(0, 60),
|
|
773
|
+
visible: el.offsetParent !== null,
|
|
774
|
+
rect: rectOf(el),
|
|
775
|
+
inForm: !!el.closest("form"),
|
|
776
|
+
html: el.outerHTML.replace(/\s+/g, " ").slice(0, 500),
|
|
777
|
+
});
|
|
778
|
+
// 用与编排一致的定位逻辑回看"到底选中了哪个节点"
|
|
779
|
+
const trigEl = findDbTrigger();
|
|
780
|
+
sendResponse({
|
|
781
|
+
ok: true,
|
|
782
|
+
hash: location.hash,
|
|
783
|
+
selects: [...document.querySelectorAll(".ant-select")].map(selectDump),
|
|
784
|
+
triggerLocated: trigEl ? selectDump(trigEl) : null,
|
|
785
|
+
metaNow: readYearningMeta(),
|
|
786
|
+
dropdownsAny: [...document.querySelectorAll("[class*=dropdown]")]
|
|
787
|
+
.slice(0, 8)
|
|
788
|
+
.map(el => ({ cls: String(el.className || "").slice(0, 100), visible: el.offsetParent !== null, text: normText(el).slice(0, 60) })),
|
|
789
|
+
optionCount: document.querySelectorAll(".ant-select-item-option").length,
|
|
790
|
+
overlays: [...document.querySelectorAll(".ant-modal,.ant-drawer,.ant-popover")]
|
|
791
|
+
.filter(el => el.offsetParent !== null)
|
|
792
|
+
.map(el => ({
|
|
793
|
+
cls: String(el.className || "").slice(0, 100),
|
|
794
|
+
text: normText(el).slice(0, 100),
|
|
795
|
+
btns: [...el.querySelectorAll("button,li,[role=menuitem],[role=option]")]
|
|
796
|
+
.map(b => normText(b)).filter(t => t && t.length <= 30).slice(0, 15),
|
|
797
|
+
})),
|
|
798
|
+
});
|
|
799
|
+
} catch (e) {
|
|
800
|
+
sendResponse({ ok: false, error: e.message });
|
|
801
|
+
}
|
|
802
|
+
return true;
|
|
803
|
+
}
|
|
265
804
|
if (msg.type === "yr-new-sql") {
|
|
266
805
|
// 新建 SQL 窗口:点工具栏新建按钮,等新编辑器渲染。
|
|
267
806
|
// 避免把 SQL 注入用户正在看/正在用的已有编辑器。
|
|
@@ -285,14 +824,19 @@
|
|
|
285
824
|
return true;
|
|
286
825
|
}
|
|
287
826
|
if (msg.type === "yr-focus-editor") {
|
|
288
|
-
// CDP
|
|
289
|
-
const ta =
|
|
827
|
+
// CDP 注入前置:聚焦当前可见 tab 的 monaco inputarea(Input.insertText 作用于焦点元素)
|
|
828
|
+
const ta = activeMonaco()?.querySelector("textarea.inputarea")
|
|
290
829
|
|| document.querySelector("textarea");
|
|
291
830
|
if (!ta) { sendResponse({ ok: false, error: "no inputarea" }); return true; }
|
|
292
831
|
ta.focus();
|
|
293
832
|
sendResponse({ ok: true });
|
|
294
833
|
return true;
|
|
295
834
|
}
|
|
835
|
+
if (msg.type === "yr-sql-get") {
|
|
836
|
+
// 读当前编辑器完整 SQL(手动查询结果帧到达时,编辑器里就是刚执行的 SQL)
|
|
837
|
+
sendResponse({ ok: true, sql: readMonacoText() });
|
|
838
|
+
return true;
|
|
839
|
+
}
|
|
296
840
|
if (msg.type === "yr-verify-sql") {
|
|
297
841
|
// CDP 注入后验证:读回 monaco view-lines 内容比对
|
|
298
842
|
const current = readMonacoText();
|
|
@@ -303,5 +847,5 @@
|
|
|
303
847
|
}
|
|
304
848
|
});
|
|
305
849
|
|
|
306
|
-
console.log(TAG, "Yearning content script loaded at", location.href);
|
|
850
|
+
console.log(TAG, "Yearning content script v" + chrome.runtime.getManifest().version + " loaded at", location.href);
|
|
307
851
|
})();
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "JumpServer 终端桥接",
|
|
4
|
-
"version": "
|
|
5
|
-
"description": "
|
|
4
|
+
"version": "3.3.0",
|
|
5
|
+
"description": "终端桥接:JumpServer / Arthas / Yearning,让 Agent 能通过浏览器执行命令、收结果与查数据",
|
|
6
6
|
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAtsnqR6PcFUueZwYria79tVbstvjk+tM7PpvIXILm5xbd6bAdjDIhzg3lsnKioVfvxjfvT+s6vJsiOYa9ojVZyJMFc5m/05TYqr770ovYwQmz0e88fmiy6dUoSulbtKvBCSLbN6OOL7u+ul8ixLZ/HautxSmou/eNgAFPmhE+4UueE7wfCqcgMYvjLvEzlqTVumMW+5LKw9YsRk6WhHPghY1a3MVUn3eQOWXBtQTEUy3wBM3v4wHxLwDeinVOR4f/P87IlUNo84C5DeimoFit0qCj3K04hS8MIYCLCYZc3v9ftRJDJBkAoah6Eaqj7JajbS3KvLR1ctOYfDhubgmURQIDAQAB",
|
|
7
7
|
"permissions": ["debugger", "tabs", "alarms", "webNavigation", "nativeMessaging", "scripting", "downloads", "storage"],
|
|
8
8
|
"background": {
|
|
@@ -131,6 +131,10 @@
|
|
|
131
131
|
.csv-item:hover { background: #dcebfd; }
|
|
132
132
|
.csv-item .csv-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
133
133
|
.csv-item .csv-rows { color: #888; font-size: 10px; flex-shrink: 0; }
|
|
134
|
+
.csv-prompt-btn {
|
|
135
|
+
width: auto !important; padding: 2px 7px !important; margin: 0 !important;
|
|
136
|
+
font-size: 11px; background: #7c4dff; flex-shrink: 0;
|
|
137
|
+
}
|
|
134
138
|
.guide {
|
|
135
139
|
font-size: 11px;
|
|
136
140
|
color: #666;
|
|
@@ -180,8 +184,8 @@
|
|
|
180
184
|
</style>
|
|
181
185
|
</head>
|
|
182
186
|
<body>
|
|
183
|
-
<h3>🔌
|
|
184
|
-
<div class="subtitle">JumpServer · Arthas Web Console</div>
|
|
187
|
+
<h3>🔌 终端桥接 <span id="extVersion" style="font-size:10px;color:#bbb;font-weight:400;"></span></h3>
|
|
188
|
+
<div class="subtitle">JumpServer · Arthas Web Console · Yearning</div>
|
|
185
189
|
|
|
186
190
|
<div class="section" style="margin-top:8px;padding-top:0;border:none;">
|
|
187
191
|
<div class="status-line">
|