|
| 1 | +/** |
| 2 | + * Tab-seat paste. Text uses CDP Input.insertText. Images dispatch a synthetic |
| 3 | + * ClipboardEvent on document.activeElement (contenteditable / textarea / input). |
| 4 | + * Never writes the shared desk clipboard — that would leak to the other seat. |
| 5 | + */ |
| 6 | + |
| 7 | +export const TAB_PASTE_TEXT_MAX = 64 * 1024; |
| 8 | +export const TAB_PASTE_IMAGE_MAX = 4 * 1024 * 1024; |
| 9 | +export const TAB_PASTE_IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp"]; |
| 10 | +export const TAB_PASTE_NEED_FOCUS = "点一下输入框再粘贴"; |
| 11 | + |
| 12 | +function mimeOf(contentType) { |
| 13 | + const ct = String(contentType || "") |
| 14 | + .split(";")[0] |
| 15 | + .trim() |
| 16 | + .toLowerCase(); |
| 17 | + if (ct === "image/jpg") return "image/jpeg"; |
| 18 | + return ct; |
| 19 | +} |
| 20 | + |
| 21 | +export function filenameForMime(mime) { |
| 22 | + if (mime === "image/jpeg") return "image.jpg"; |
| 23 | + if (mime === "image/webp") return "image.webp"; |
| 24 | + return "image.png"; |
| 25 | +} |
| 26 | + |
| 27 | +export function classifyTabPaste(contentType, byteLength) { |
| 28 | + const mime = mimeOf(contentType); |
| 29 | + const n = Number(byteLength) || 0; |
| 30 | + if (!n) return { error: "空内容", status: 400 }; |
| 31 | + if (mime.startsWith("text/") || mime === "" || mime === "application/json") { |
| 32 | + if (n > TAB_PASTE_TEXT_MAX) return { error: "太大了", status: 413 }; |
| 33 | + return { kind: "text", mime: mime || "text/plain" }; |
| 34 | + } |
| 35 | + if (TAB_PASTE_IMAGE_TYPES.includes(mime)) { |
| 36 | + if (n > TAB_PASTE_IMAGE_MAX) return { error: "太大了", status: 413 }; |
| 37 | + return { kind: "image", mime }; |
| 38 | + } |
| 39 | + return { error: "分屏席位只支持文字和图片(png/jpeg/webp)", status: 400 }; |
| 40 | +} |
| 41 | + |
| 42 | +/** |
| 43 | + * Runtime.evaluate body. Uses document.activeElement only — no chatgpt.com |
| 44 | + * class names or composer selectors. If nothing pasteable is focused, returns |
| 45 | + * { error: "need-focus" } so the UI can say TAB_PASTE_NEED_FOCUS. |
| 46 | + */ |
| 47 | +export function imagePasteExpression({ mime, base64, filename } = {}) { |
| 48 | + const m = JSON.stringify(String(mime || "image/png")); |
| 49 | + const b = JSON.stringify(String(base64 || "")); |
| 50 | + const n = JSON.stringify(String(filename || filenameForMime(mime))); |
| 51 | + return `(() => { |
| 52 | + const mime = ${m}; |
| 53 | + const b64 = ${b}; |
| 54 | + const name = ${n}; |
| 55 | + const el = document.activeElement; |
| 56 | + const tag = el && el.tagName ? String(el.tagName).toUpperCase() : ""; |
| 57 | + const type = el && el.type != null ? String(el.type) : "text"; |
| 58 | + const focused = !!( |
| 59 | + el && |
| 60 | + el !== document.body && |
| 61 | + el !== document.documentElement && |
| 62 | + ( |
| 63 | + el.isContentEditable === true || |
| 64 | + tag === "TEXTAREA" || |
| 65 | + (tag === "INPUT" && /^(text|search|url|email|password|tel|number)?$/i.test(type)) |
| 66 | + ) |
| 67 | + ); |
| 68 | + if (!focused) return { ok: false, error: "need-focus" }; |
| 69 | + const bin = atob(b64); |
| 70 | + const bytes = new Uint8Array(bin.length); |
| 71 | + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); |
| 72 | + const file = new File([bytes], name, { type: mime }); |
| 73 | + const dt = new DataTransfer(); |
| 74 | + dt.items.add(file); |
| 75 | + let ev; |
| 76 | + try { |
| 77 | + ev = new ClipboardEvent("paste", { bubbles: true, cancelable: true, clipboardData: dt }); |
| 78 | + } catch (e) { |
| 79 | + ev = new Event("paste", { bubbles: true, cancelable: true }); |
| 80 | + } |
| 81 | + if (!ev.clipboardData) { |
| 82 | + Object.defineProperty(ev, "clipboardData", { value: dt, configurable: true }); |
| 83 | + } |
| 84 | + el.dispatchEvent(ev); |
| 85 | + return { ok: true, kind: "image" }; |
| 86 | + })()`; |
| 87 | +} |
| 88 | + |
| 89 | +export function tabPastePlan(contentType, bytes) { |
| 90 | + const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes || []); |
| 91 | + const classified = classifyTabPaste(contentType, buf.length); |
| 92 | + if (classified.error) return classified; |
| 93 | + if (classified.kind === "text") { |
| 94 | + return { |
| 95 | + kind: "text", |
| 96 | + method: "Input.insertText", |
| 97 | + params: { text: buf.toString("utf8").slice(0, TAB_PASTE_TEXT_MAX) }, |
| 98 | + }; |
| 99 | + } |
| 100 | + return { |
| 101 | + kind: "image", |
| 102 | + method: "Runtime.evaluate", |
| 103 | + params: { |
| 104 | + expression: imagePasteExpression({ |
| 105 | + mime: classified.mime, |
| 106 | + base64: buf.toString("base64"), |
| 107 | + filename: filenameForMime(classified.mime), |
| 108 | + }), |
| 109 | + awaitPromise: true, |
| 110 | + returnByValue: true, |
| 111 | + }, |
| 112 | + }; |
| 113 | +} |
| 114 | + |
| 115 | +export function interpretTabPasteEvaluate(value) { |
| 116 | + if (value?.ok) return { ok: true, kind: value.kind || "image" }; |
| 117 | + if (value?.error === "need-focus") { |
| 118 | + return { ok: false, error: TAB_PASTE_NEED_FOCUS, status: 400, code: "TAB_PASTE_NEED_FOCUS" }; |
| 119 | + } |
| 120 | + return { ok: false, error: value?.error || "无法粘贴", status: 502 }; |
| 121 | +} |
| 122 | + |
| 123 | +export async function applyTabPastePlan(send, plan) { |
| 124 | + if (!plan || plan.error) { |
| 125 | + return { ok: false, error: plan?.error || "无法粘贴", status: plan?.status || 400 }; |
| 126 | + } |
| 127 | + if (plan.kind === "text") { |
| 128 | + await send("Input.insertText", plan.params); |
| 129 | + return { ok: true, kind: "text" }; |
| 130 | + } |
| 131 | + const raw = await send("Runtime.evaluate", plan.params); |
| 132 | + if (raw?.exceptionDetails) return { ok: false, error: "无法粘贴", status: 502 }; |
| 133 | + return interpretTabPasteEvaluate(raw?.result?.value ?? raw); |
| 134 | +} |
| 135 | + |
| 136 | +export function tabPasteFromMessage(msg) { |
| 137 | + if (!msg || msg.type !== "paste") return null; |
| 138 | + if (typeof msg.text === "string") { |
| 139 | + return tabPastePlan("text/plain; charset=utf-8", Buffer.from(msg.text, "utf8")); |
| 140 | + } |
| 141 | + if (typeof msg.image === "string") { |
| 142 | + return tabPastePlan(msg.mime || "image/png", Buffer.from(msg.image, "base64")); |
| 143 | + } |
| 144 | + return { error: "空内容", status: 400 }; |
| 145 | +} |
0 commit comments