From 217e1f781ab078d1a901be39a9755fa74e0155e8 Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 9 Oct 2026 22:46:38 +0800 Subject: [PATCH] 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 --- scripts/_desktop-cdp.mjs | 306 ++++++++++++++++++++++++++++++ scripts/_enable-local-plugins.mjs | 86 +++++++++ 2 files changed, 392 insertions(+) create mode 100644 scripts/_desktop-cdp.mjs create mode 100644 scripts/_enable-local-plugins.mjs diff --git a/scripts/_desktop-cdp.mjs b/scripts/_desktop-cdp.mjs new file mode 100644 index 0000000..f662004 --- /dev/null +++ b/scripts/_desktop-cdp.mjs @@ -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() diff --git a/scripts/_enable-local-plugins.mjs b/scripts/_enable-local-plugins.mjs new file mode 100644 index 0000000..257f2b4 --- /dev/null +++ b/scripts/_enable-local-plugins.mjs @@ -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))