mirror of
https://github.com/hansjone/netxops.git
synced 2026-10-16 04:20:47 +08:00
Provide a minimal remote-debugging client and a one-shot enable script for linked Desktop plugins. Co-authored-by: Cursor <cursoragent@cursor.com>
306 lines
11 KiB
JavaScript
306 lines
11 KiB
JavaScript
/**
|
|
* 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()
|