mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-16 06:50:46 +08:00
Add Desktop CDP helpers for local plugin enablement.
Provide a minimal remote-debugging client and a one-shot enable script for linked Desktop plugins. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
838ff2fed4
commit
217e1f781a
2 changed files with 392 additions and 0 deletions
306
scripts/_desktop-cdp.mjs
Normal file
306
scripts/_desktop-cdp.mjs
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
/**
|
||||
* Minimal CDP client for DeepSeek Harness Desktop (--remote-debugging-port).
|
||||
* Usage:
|
||||
* node _desktop-cdp.mjs eval 'document.title'
|
||||
* node _desktop-cdp.mjs install github:hansjone/netxops
|
||||
*/
|
||||
|
||||
import net from 'node:net'
|
||||
import http from 'node:http'
|
||||
import crypto from 'node:crypto'
|
||||
|
||||
function httpGetJson(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
http.get(url, (res) => {
|
||||
let data = ''
|
||||
res.on('data', (c) => { data += c })
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(data)) } catch (e) { reject(e) }
|
||||
})
|
||||
}).on('error', reject)
|
||||
})
|
||||
}
|
||||
|
||||
class Cdp {
|
||||
constructor(wsUrl) {
|
||||
this.wsUrl = wsUrl
|
||||
this.id = 0
|
||||
this.pending = new Map()
|
||||
this.buf = Buffer.alloc(0)
|
||||
this.socket = null
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const u = new URL(this.wsUrl)
|
||||
const key = crypto.randomBytes(16).toString('base64')
|
||||
await new Promise((resolve, reject) => {
|
||||
const socket = net.connect({ host: u.hostname, port: Number(u.port) }, () => {
|
||||
socket.write(
|
||||
`GET ${u.pathname}${u.search} HTTP/1.1\r\n` +
|
||||
`Host: ${u.host}\r\n` +
|
||||
`Upgrade: websocket\r\n` +
|
||||
`Connection: Upgrade\r\n` +
|
||||
`Sec-WebSocket-Key: ${key}\r\n` +
|
||||
`Sec-WebSocket-Version: 13\r\n\r\n`,
|
||||
)
|
||||
})
|
||||
socket.on('error', reject)
|
||||
socket.once('data', (chunk) => {
|
||||
if (!chunk.toString().includes('101')) {
|
||||
reject(new Error(`WS upgrade failed: ${chunk.toString('utf8').slice(0, 200)}`))
|
||||
return
|
||||
}
|
||||
this.socket = socket
|
||||
const rest = chunk.indexOf('\r\n\r\n')
|
||||
if (rest >= 0 && rest + 4 < chunk.length) this.buf = chunk.subarray(rest + 4)
|
||||
socket.on('data', (c) => this.onData(c))
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
onData(chunk) {
|
||||
this.buf = Buffer.concat([this.buf, chunk])
|
||||
while (true) {
|
||||
if (this.buf.length < 2) return
|
||||
const b0 = this.buf[0]
|
||||
const b1 = this.buf[1]
|
||||
const masked = (b1 & 0x80) !== 0
|
||||
let len = b1 & 0x7f
|
||||
let off = 2
|
||||
if (len === 126) {
|
||||
if (this.buf.length < 4) return
|
||||
len = this.buf.readUInt16BE(2)
|
||||
off = 4
|
||||
} else if (len === 127) {
|
||||
if (this.buf.length < 10) return
|
||||
len = Number(this.buf.readBigUInt64BE(2))
|
||||
off = 10
|
||||
}
|
||||
const maskOff = masked ? off : -1
|
||||
if (masked) off += 4
|
||||
if (this.buf.length < off + len) return
|
||||
let payload = this.buf.subarray(off, off + len)
|
||||
if (masked) {
|
||||
const mask = this.buf.subarray(maskOff, maskOff + 4)
|
||||
payload = Buffer.from(payload)
|
||||
for (let i = 0; i < payload.length; i++) payload[i] ^= mask[i % 4]
|
||||
}
|
||||
this.buf = this.buf.subarray(off + len)
|
||||
const opcode = b0 & 0x0f
|
||||
if (opcode === 0x1 || opcode === 0x2) {
|
||||
try {
|
||||
const msg = JSON.parse(payload.toString('utf8'))
|
||||
if (msg.id != null && this.pending.has(msg.id)) {
|
||||
const { resolve, reject } = this.pending.get(msg.id)
|
||||
this.pending.delete(msg.id)
|
||||
if (msg.error) reject(new Error(JSON.stringify(msg.error)))
|
||||
else resolve(msg.result)
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} else if (opcode === 0x8) {
|
||||
this.socket?.end()
|
||||
} else if (opcode === 0x9) {
|
||||
this.sendFrame(0xA, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sendFrame(opcode, payload) {
|
||||
const data = Buffer.isBuffer(payload) ? payload : Buffer.from(payload)
|
||||
const mask = crypto.randomBytes(4)
|
||||
const masked = Buffer.alloc(data.length)
|
||||
for (let i = 0; i < data.length; i++) masked[i] = data[i] ^ mask[i % 4]
|
||||
let header
|
||||
if (data.length < 126) {
|
||||
header = Buffer.alloc(6)
|
||||
header[0] = 0x80 | opcode
|
||||
header[1] = 0x80 | data.length
|
||||
mask.copy(header, 2)
|
||||
} else if (data.length < 65536) {
|
||||
header = Buffer.alloc(8)
|
||||
header[0] = 0x80 | opcode
|
||||
header[1] = 0x80 | 126
|
||||
header.writeUInt16BE(data.length, 2)
|
||||
mask.copy(header, 4)
|
||||
} else {
|
||||
header = Buffer.alloc(14)
|
||||
header[0] = 0x80 | opcode
|
||||
header[1] = 0x80 | 127
|
||||
header.writeBigUInt64BE(BigInt(data.length), 2)
|
||||
mask.copy(header, 10)
|
||||
}
|
||||
this.socket.write(Buffer.concat([header, masked]))
|
||||
}
|
||||
|
||||
call(method, params = {}, timeoutMs = 30_000) {
|
||||
const id = ++this.id
|
||||
const payload = JSON.stringify({ id, method, params })
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject })
|
||||
this.sendFrame(0x1, payload)
|
||||
setTimeout(() => {
|
||||
if (this.pending.has(id)) {
|
||||
this.pending.delete(id)
|
||||
reject(new Error(`CDP timeout: ${method}`))
|
||||
}
|
||||
}, timeoutMs)
|
||||
})
|
||||
}
|
||||
|
||||
async evaluate(expression, timeoutMs = 30_000) {
|
||||
const result = await this.call('Runtime.evaluate', {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
userGesture: true,
|
||||
}, timeoutMs)
|
||||
if (result.exceptionDetails) {
|
||||
throw new Error(result.exceptionDetails.text || JSON.stringify(result.exceptionDetails))
|
||||
}
|
||||
return result.result?.value
|
||||
}
|
||||
|
||||
close() {
|
||||
this.socket?.end()
|
||||
}
|
||||
}
|
||||
|
||||
async function connectPage() {
|
||||
const tabs = await httpGetJson('http://127.0.0.1:9222/json')
|
||||
const page = tabs.find((t) => t.type === 'page' && t.webSocketDebuggerUrl)
|
||||
if (!page) throw new Error('no CDP page target')
|
||||
const cdp = new Cdp(page.webSocketDebuggerUrl)
|
||||
await cdp.connect()
|
||||
await cdp.call('Runtime.enable')
|
||||
return cdp
|
||||
}
|
||||
|
||||
async function probe(cdp) {
|
||||
return cdp.evaluate(`(() => {
|
||||
const out = {
|
||||
title: document.title,
|
||||
url: location.href,
|
||||
buttons: [...document.querySelectorAll('button,[role="button"],a')].slice(0, 80).map(el => ({
|
||||
text: (el.innerText || el.textContent || '').trim().slice(0, 80),
|
||||
aria: el.getAttribute('aria-label') || '',
|
||||
testid: el.getAttribute('data-testid') || '',
|
||||
})).filter(x => x.text || x.aria),
|
||||
inputs: [...document.querySelectorAll('input,textarea')].slice(0, 40).map(el => ({
|
||||
type: el.type, placeholder: el.placeholder || '', name: el.name || '', aria: el.getAttribute('aria-label') || '',
|
||||
})),
|
||||
hasPluginManager: typeof globalThis !== 'undefined',
|
||||
}
|
||||
// Try to find cordis / remote faces on window
|
||||
const keys = Object.keys(window).filter(k => /plugin|dsh|cordis|remote/i.test(k)).slice(0, 40)
|
||||
out.windowKeys = keys
|
||||
return out
|
||||
})()`)
|
||||
}
|
||||
|
||||
async function clickText(cdp, text) {
|
||||
return cdp.evaluate(`(() => {
|
||||
const want = ${JSON.stringify(text)}
|
||||
const els = [...document.querySelectorAll('button,[role="button"],a,div,span,li')]
|
||||
const el = els.find(e => {
|
||||
const t = (e.innerText || e.textContent || '').trim()
|
||||
return t === want || t.includes(want)
|
||||
})
|
||||
if (!el) return { ok: false, reason: 'not-found' }
|
||||
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }))
|
||||
el.click?.()
|
||||
return { ok: true, text: (el.innerText || '').trim().slice(0, 80) }
|
||||
})()`)
|
||||
}
|
||||
|
||||
async function ensurePluginsPage(cdp) {
|
||||
await cdp.evaluate(`(() => {
|
||||
const el = [...document.querySelectorAll('button')].find(e => e.getAttribute('aria-label') === '插件')
|
||||
if (el) el.click()
|
||||
return true
|
||||
})()`)
|
||||
await new Promise((r) => setTimeout(r, 800))
|
||||
}
|
||||
|
||||
async function installSpec(cdp, spec) {
|
||||
await ensurePluginsPage(cdp)
|
||||
const expression = `(async () => {
|
||||
const spec = ${JSON.stringify(spec)}
|
||||
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
|
||||
const open = () => {
|
||||
const btn = [...document.querySelectorAll('button')].find(e => (e.innerText || '').trim() === '添加插件')
|
||||
if (btn) btn.click()
|
||||
}
|
||||
let input = document.querySelector('input[aria-label="包名或地址"]')
|
||||
if (!input) { open(); await sleep(600); input = document.querySelector('input[aria-label="包名或地址"]') }
|
||||
if (!input) return { ok: false, stage: 'no-input', spec }
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set
|
||||
setter.call(input, spec)
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
await sleep(250)
|
||||
const installBtn = [...document.querySelectorAll('button')].find(e => (e.innerText || '').trim() === '安装')
|
||||
if (!installBtn) return { ok: false, stage: 'no-install-btn', value: input.value, spec }
|
||||
if (installBtn.disabled) return { ok: false, stage: 'disabled', value: input.value, spec }
|
||||
installBtn.click()
|
||||
for (let i = 0; i < 240; i++) {
|
||||
await sleep(1000)
|
||||
const still = document.querySelector('input[aria-label="包名或地址"]')
|
||||
const dlg = document.querySelector('[role="dialog"]')
|
||||
const text = dlg?.innerText || ''
|
||||
if (!still) return { ok: true, stage: 'dialog-closed', spec, waitedSec: i + 1 }
|
||||
// Only inspect dialog body; avoid page-wide false positives.
|
||||
if (/这个包没有|无法作为插件安装|安装失败|无法安装:|ENOENT|ERR_PNPM|ERR_PACKAGE|404 Not Found|declares no dsh\.bundle/i.test(text)) {
|
||||
return { ok: false, stage: 'error-text', text: text.slice(0, 800), spec, waitedSec: i + 1 }
|
||||
}
|
||||
if (/正在检查|正在安装|正在下载|Installing|Checking/i.test(text)) continue
|
||||
}
|
||||
return { ok: false, stage: 'timeout', text: (document.querySelector('[role="dialog"]')?.innerText || '').slice(0, 800), spec }
|
||||
})()`
|
||||
return cdp.evaluate(expression, 260_000)
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const [cmd, ...args] = process.argv.slice(2)
|
||||
const cdp = await connectPage()
|
||||
try {
|
||||
if (cmd === 'eval') {
|
||||
const v = await cdp.evaluate(args.join(' '), 60_000)
|
||||
console.log(JSON.stringify(v, null, 2))
|
||||
return
|
||||
}
|
||||
if (cmd === 'probe') {
|
||||
console.log(JSON.stringify(await probe(cdp), null, 2))
|
||||
return
|
||||
}
|
||||
if (cmd === 'click') {
|
||||
console.log(JSON.stringify(await clickText(cdp, args.join(' ')), null, 2))
|
||||
return
|
||||
}
|
||||
if (cmd === 'install') {
|
||||
const spec = args.join(' ')
|
||||
if (!spec) throw new Error('install requires a package spec')
|
||||
console.log(JSON.stringify(await installSpec(cdp, spec), null, 2))
|
||||
return
|
||||
}
|
||||
if (cmd === 'list-installed') {
|
||||
await ensurePluginsPage(cdp)
|
||||
const v = await cdp.evaluate(`(() => {
|
||||
const rows = [...document.querySelectorAll('button,[role="button"]')]
|
||||
.map(el => ({ text: (el.innerText || '').trim().slice(0, 80), aria: el.getAttribute('aria-label') || '' }))
|
||||
.filter(x => /查看 |启用 |禁用 /.test(x.aria) || /已安装|Installed/.test(x.text))
|
||||
const body = document.body.innerText
|
||||
const section = body.includes('已安装') ? body.split('已安装')[1]?.slice(0, 1500) : body.slice(0, 1500)
|
||||
return { rows: rows.slice(0, 80), section }
|
||||
})()`)
|
||||
console.log(JSON.stringify(v, null, 2))
|
||||
return
|
||||
}
|
||||
console.error('usage: eval|probe|click|install|list-installed')
|
||||
process.exit(2)
|
||||
} finally {
|
||||
cdp.close()
|
||||
}
|
||||
}
|
||||
|
||||
await main()
|
||||
86
scripts/_enable-local-plugins.mjs
Normal file
86
scripts/_enable-local-plugins.mjs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
/**
|
||||
* Enable local linked plugins via Desktop CDP (one plugin per eval).
|
||||
* Usage: node _enable-local-plugins.mjs
|
||||
*/
|
||||
import { spawn } from 'node:child_process'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const cdp = path.join(__dirname, '_desktop-cdp.mjs')
|
||||
const NAMES = [
|
||||
'dsh-search-mcp',
|
||||
'dsh-trilium',
|
||||
'uds-auth',
|
||||
'dsh-ops-cron',
|
||||
'dsh-netxops',
|
||||
'dsh-im-ops',
|
||||
'dsh-mind-map',
|
||||
]
|
||||
|
||||
function runEval(code, timeoutMs = 90_000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, [cdp, 'eval', code], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
})
|
||||
let out = ''
|
||||
let err = ''
|
||||
const t = setTimeout(() => {
|
||||
child.kill()
|
||||
reject(new Error(`timeout after ${timeoutMs}ms: ${err || out}`))
|
||||
}, timeoutMs)
|
||||
child.stdout.on('data', (d) => { out += d })
|
||||
child.stderr.on('data', (d) => { err += d })
|
||||
child.on('close', (code) => {
|
||||
clearTimeout(t)
|
||||
if (code !== 0) reject(new Error(err || out || `exit ${code}`))
|
||||
else {
|
||||
try { resolve(JSON.parse(out)) } catch { resolve(out) }
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function enableOne(name) {
|
||||
// Keep under CDP 60s eval timeout
|
||||
return runEval(`(async () => {
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
const byAria = a => [...document.querySelectorAll('button')].find(e => (e.getAttribute('aria-label')||'') === a)
|
||||
const clickText = t => {
|
||||
const b = [...document.querySelectorAll('button')].find(e => (e.innerText||'').trim() === t)
|
||||
if (b) { b.click(); return true }
|
||||
return false
|
||||
}
|
||||
byAria('插件')?.click(); await sleep(800)
|
||||
const steps = []
|
||||
const en = byAria('启用 ' + ${JSON.stringify(name)})
|
||||
if (en) { en.click(); steps.push('启用'); await sleep(2500) }
|
||||
byAria('查看 ' + ${JSON.stringify(name)})?.click(); await sleep(900)
|
||||
if (clickText('立即启用')) { steps.push('立即启用'); await sleep(2800) }
|
||||
byAria('返回插件列表')?.click(); await sleep(350)
|
||||
byAria('查看 ' + ${JSON.stringify(name)})?.click(); await sleep(700)
|
||||
const t = document.body.innerText || ''
|
||||
const idx = t.indexOf(${JSON.stringify(name)})
|
||||
const bit = idx >= 0 ? t.slice(idx, idx + 280) : ''
|
||||
const running = /运行中/.test(bit)
|
||||
const enableStill = !!byAria('启用 ' + ${JSON.stringify(name)})
|
||||
byAria('返回插件列表')?.click()
|
||||
return { name: ${JSON.stringify(name)}, steps, running, enableStill, bit: bit.slice(0, 200) }
|
||||
})()`, 70_000)
|
||||
}
|
||||
|
||||
const results = []
|
||||
for (const name of NAMES) {
|
||||
try {
|
||||
const r = await enableOne(name)
|
||||
results.push(r)
|
||||
console.log(JSON.stringify(r))
|
||||
} catch (e) {
|
||||
results.push({ name, error: String(e.message || e).slice(0, 300) })
|
||||
console.error(name, e.message || e)
|
||||
}
|
||||
}
|
||||
|
||||
const cron = await runEval(`(() => ({ hasCron: /定时任务/.test(document.body.innerText||''), head: (document.body.innerText||'').slice(0,120) }))()`)
|
||||
console.log(JSON.stringify({ results, cron }, null, 2))
|
||||
Loading…
Add table
Add a link
Reference in a new issue