diff --git a/electron/preload.cjs b/electron/preload.cjs index 7b7816aa4..e211a28d5 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -1,6 +1,6 @@ // Renderer bridge. contextIsolation stays on; the renderer only ever sees // this narrow surface (window.ogb), never Node or ipcRenderer itself. -const { contextBridge, ipcRenderer } = require("electron"); +const { contextBridge, ipcRenderer, webUtils } = require("electron"); contextBridge.exposeInMainWorld("ogb", { /** Host platform ("darwin" | "win32" | "linux") — for platform-aware UI. */ @@ -20,6 +20,15 @@ contextBridge.exposeInMainWorld("ogb", { ipcRenderer.on("speech:end", handler); return () => ipcRenderer.removeListener("speech:end", handler); }, + /** Absolute path of a dropped File — Electron 32 removed File.path, and + * only the preload can ask. "" when the drag carried no file on disk. */ + getPathForFile: (file) => { + try { + return webUtils.getPathForFile(file); + } catch { + return ""; + } + }, /** {mic} TCC status strings: granted|denied|not-determined|unknown. * No screen field — macOS 15+ caches that status per-process, so any * value here would lie for the whole session after a grant. */ diff --git a/server/composer-attachments.test.ts b/server/composer-attachments.test.ts index 08d381ccd..22d748b2a 100644 --- a/server/composer-attachments.test.ts +++ b/server/composer-attachments.test.ts @@ -3,8 +3,10 @@ import { describe, expect, it } from "vitest"; import { PASTE_CHARS, PASTE_LINES, + attachmentsFromDroppedFiles, byteLength, composeMessage, + fileAttachment, isAttachment, isLongPaste, pasteAttachment, @@ -38,9 +40,56 @@ describe("composer paste attachments", () => { ); }); + it("keeps unusual file paths inside the attachment attribute", () => { + const file = fileAttachment("report.txt", '/tmp/a"&<>\t\n\r.txt', 42); + expect(composeMessage("", [file])).toBe( + '', + ); + }); + + it("preserves drop order and falls back to small pathless text", async () => { + const dropped = [ + { + name: "on-disk.md", + size: 12, + type: "text/markdown", + path: "/tmp/on-disk.md", + text: async () => "not read", + }, + { + name: "browser.txt", + size: 7, + type: "text/plain", + path: "", + text: async () => "browser", + }, + { + name: "image.png", + size: 10, + type: "image/png", + path: "", + text: async () => "not text", + }, + ]; + + const result = await attachmentsFromDroppedFiles(dropped, (file) => file.path); + expect(result.attachments.map((attachment) => attachment.kind)).toEqual(["file", "paste"]); + expect(result.attachments[0]).toMatchObject({ + kind: "file", + name: "on-disk.md", + path: "/tmp/on-disk.md", + }); + expect(result.attachments[1]).toMatchObject({ kind: "paste", text: "browser" }); + expect(result.rejectedNames).toEqual(["image.png"]); + }); + it("rejects malformed persisted attachments", () => { expect(isAttachment({ kind: "paste", id: "a", text: "ok", size: 2, lines: 1 })).toBe(true); + expect( + isAttachment({ kind: "file", id: "f", name: "notes.txt", path: "/tmp/notes.txt", size: 2 }), + ).toBe(true); expect(isAttachment({ kind: "paste", id: "a", text: "missing size" })).toBe(false); expect(isAttachment({ kind: "file", id: "a", text: "wrong kind", size: 2 })).toBe(false); + expect(isAttachment({ kind: "file", id: "f", name: "empty", path: "", size: 0 })).toBe(false); }); }); diff --git a/server/drafts.test.ts b/server/drafts.test.ts index 08be22363..4a44d3c72 100644 --- a/server/drafts.test.ts +++ b/server/drafts.test.ts @@ -6,7 +6,7 @@ import { setDraft, setDraftAttachments, } from "../src/lib/drafts.ts"; -import { pasteAttachment } from "../src/lib/composer-attachments.ts"; +import { fileAttachment, pasteAttachment } from "../src/lib/composer-attachments.ts"; function memoryStore() { const values = new Map(); @@ -20,12 +20,13 @@ describe("composer drafts", () => { it("keeps text and attachments isolated per bot or room", () => { const store = memoryStore(); const paste = pasteAttachment("bot paste"); + const file = fileAttachment("notes.txt", "/tmp/notes.txt", 12); setDraft(store, "bot:one", "hello"); - setDraftAttachments(store, "bot:one", [paste]); + setDraftAttachments(store, "bot:one", [paste, file]); setDraft(store, "group:two", "room text"); expect(getDraft(store, "bot:one")).toBe("hello"); - expect(getDraftAttachments(store, "bot:one")).toEqual([paste]); + expect(getDraftAttachments(store, "bot:one")).toEqual([paste, file]); expect(getDraft(store, "group:two")).toBe("room text"); expect(getDraftAttachments(store, "group:two")).toEqual([]); }); diff --git a/src/components/Composer.tsx b/src/components/Composer.tsx index 4537357db..6cc2c016e 100644 --- a/src/components/Composer.tsx +++ b/src/components/Composer.tsx @@ -10,6 +10,7 @@ import { composeMessage, isLongPaste, pasteAttachment, + type Attachment, } from "@/lib/composer-attachments"; import { normalizeState } from "@/lib/mascot"; import { PendingApprovalActions, PendingApprovalPanel, pendingApprovals } from "./PendingApproval"; @@ -61,6 +62,10 @@ export function Composer({ const [text, setText, attachments, setAttachments] = useComposerDraft( group ? `group:${group.id}` : `bot:${bot?.id ?? ""}`, ); + const addAttachments = useCallback( + (next: Attachment[]) => setAttachments((prev) => [...prev, ...next]), + [setAttachments], + ); const removeAttachment = useCallback( (id: string) => setAttachments((prev) => prev.filter((a) => a.id !== id)), [setAttachments], @@ -252,7 +257,11 @@ export function Composer({ /> )} - + void; onRemove: (id: string) => void; }) { - if (!items.length) return null; + const [dragging, setDragging] = useState(false); + const [notice, setNotice] = useState(null); + // dragenter/dragleave fire once per element crossed, so the overlay + // tracks depth rather than the last event it happened to see + const depth = useRef(0); + + useEffect(() => { + let active = true; + const carriesFiles = (e: DragEvent) => Array.from(e.dataTransfer?.types ?? []).includes("Files"); + + const onEnter = (e: DragEvent) => { + if (!carriesFiles(e)) return; + depth.current += 1; + setDragging(true); + }; + const onLeave = (e: DragEvent) => { + if (!carriesFiles(e)) return; + depth.current = Math.max(0, depth.current - 1); + if (depth.current === 0) setDragging(false); + }; + // without preventDefault the window navigates to the dropped file and + // the app is simply gone + const onOver = (e: DragEvent) => { + if (carriesFiles(e)) e.preventDefault(); + }; + const onDrop = async (e: DragEvent) => { + if (!carriesFiles(e)) return; + e.preventDefault(); + depth.current = 0; + setDragging(false); + const files = Array.from(e.dataTransfer?.files ?? []); + const { attachments, rejectedNames } = await attachmentsFromDroppedFiles(files, pathForFile); + if (!active) return; + if (attachments.length) onAdd(attachments); + setNotice( + rejectedNames.length + ? `${rejectedNames.join(", ")} — that drag carried no file on disk. Save it first, then drop it from Finder.` + : null, + ); + }; + + window.addEventListener("dragenter", onEnter); + window.addEventListener("dragleave", onLeave); + window.addEventListener("dragover", onOver); + window.addEventListener("drop", onDrop); + return () => { + active = false; + window.removeEventListener("dragenter", onEnter); + window.removeEventListener("dragleave", onLeave); + window.removeEventListener("dragover", onOver); + window.removeEventListener("drop", onDrop); + }; + }, [onAdd]); + return ( - - {items.map((a) => ( - onRemove(a.id)} /> - ))} - + <> + {dragging && ( + + + Drop to attach — the bot gets the file path + + + )} + + {notice && ( + + {notice} + setNotice(null)} + aria-label="Dismiss" + className="shrink-0 rounded p-0.5" + > + + + + )} + + {items.length > 0 && ( + + {items.map((a) => + a.kind === "paste" ? ( + onRemove(a.id)} + > + + + {a.text.slice(0, 400)} + + + + {pasteSummary(a)} + + ) : ( + onRemove(a.id)}> + + + + {a.name} + {formatSize(a.size)} + + + + ), + )} + + )} + > ); } -function PasteChip({ item, onRemove }: { item: Attachment; onRemove: () => void }) { - const { text } = item; +function Chip({ + children, + label, + title, + onRemove, +}: { + children: React.ReactNode; + label: "PASTED" | "FILE"; + title: string; + onRemove: () => void; +}) { + const Icon = label === "PASTED" ? ClipboardPaste : FileIcon; return ( - - - {text.slice(0, 400)} - - - - {pasteSummary(item)} + {children} - + - PASTED + {label} {/* hover reveals it, but so must focus: `hidden` would take the only way to drop a chip out of reach of the keyboard */} diff --git a/src/lib/composer-attachments.ts b/src/lib/composer-attachments.ts index 30e20d969..3f11e8381 100644 --- a/src/lib/composer-attachments.ts +++ b/src/lib/composer-attachments.ts @@ -1,7 +1,7 @@ -// Text pasted into the composer that is too long to live in the input. -// It rides along as a chip and folds back into the message on send — -// nothing here reaches the server, so every driver still sees a prompt. -export type Attachment = { +// What is attached to the next message: text too long for the input or a +// file dropped onto the window. Chips fold back into a normal prompt on +// send, so every driver receives the same message shape. +export type PasteAttachment = { kind: "paste"; id: string; text: string; @@ -9,20 +9,40 @@ export type Attachment = { lines: number; }; +export type FileAttachment = { + kind: "file"; + id: string; + path: string; + name: string; + size: number; +}; + +export type Attachment = PasteAttachment | FileAttachment; + export function isAttachment(value: unknown): value is Attachment { if (!value || typeof value !== "object") return false; - const attachment = value as Partial; - return ( - attachment.kind === "paste" && - typeof attachment.id === "string" && - typeof attachment.text === "string" && - typeof attachment.size === "number" && - Number.isFinite(attachment.size) && - attachment.size >= 0 && - typeof attachment.lines === "number" && - Number.isInteger(attachment.lines) && - attachment.lines >= 1 - ); + const attachment = value as Record; + if (typeof attachment.id !== "string" || !validSize(attachment.size)) return false; + if (attachment.kind === "paste") { + return ( + typeof attachment.text === "string" && + typeof attachment.lines === "number" && + Number.isInteger(attachment.lines) && + attachment.lines >= 1 + ); + } + if (attachment.kind === "file") { + return ( + typeof attachment.path === "string" && + attachment.path.length > 0 && + typeof attachment.name === "string" + ); + } + return false; +} + +function validSize(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; } /** Past this, a paste stops reading as typing and becomes an attachment. @@ -38,13 +58,67 @@ export function countLines(text: string): number { return text.split("\n").length; } -export function pasteAttachment(text: string): Attachment { - const id = globalThis.crypto?.randomUUID?.() ?? `a${Math.random().toString(36).slice(2)}`; +function newId(): string { + return globalThis.crypto?.randomUUID?.() ?? `a${Math.random().toString(36).slice(2)}`; +} + +export function fileAttachment(name: string, path: string, size: number): FileAttachment { + return { kind: "file", id: newId(), path, name, size }; +} + +export function pasteAttachment(text: string): PasteAttachment { + const id = newId(); // measured once, here: a chip re-renders on every keystroke in the // composer, and encoding half a megabyte each time would be felt return { kind: "paste", id, text, size: byteLength(text), lines: countLines(text) }; } +export const INLINE_DROP_LIMIT = 512 * 1024; + +export type DroppedFile = Pick; + +/** Turn a browser drop into composer attachments. Electron-backed files + * keep their disk path; small pathless text drops keep their contents. + * Promise.all preserves the user's drop order even when text reads finish + * in a different order. */ +export async function attachmentsFromDroppedFiles( + files: readonly T[], + getPath: (file: T) => string, +): Promise<{ attachments: Attachment[]; rejectedNames: string[] }> { + const results = await Promise.all( + files.map(async (file) => { + let path = ""; + try { + path = getPath(file); + } catch { + // A browser or older desktop shell has no disk path to expose. + } + if (path) return { attachment: fileAttachment(file.name, path, file.size) }; + if (isInlineText(file) && file.size <= INLINE_DROP_LIMIT) { + try { + return { attachment: pasteAttachment(await file.text()) }; + } catch { + // Treat an unreadable browser drag like any other pathless file. + } + } + return { rejectedName: file.name }; + }), + ); + + return { + attachments: results.flatMap((result) => + "attachment" in result && result.attachment ? [result.attachment] : [], + ), + rejectedNames: results.flatMap((result) => + "rejectedName" in result && result.rejectedName ? [result.rejectedName] : [], + ), + }; +} + +function isInlineText(file: DroppedFile): boolean { + return file.type.startsWith("text/") || file.type === "application/json"; +} + /** What the paste actually weighs — String#length counts UTF-16 units, so * it reads a third under on accented text and half under on CJK. */ export function byteLength(text: string): number { @@ -62,13 +136,31 @@ export function formatSize(bytes: number): string { return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; } -/** The prompt the bot receives: what was typed, then one block per paste. - * Tagged blocks rather than fences — pasted code and markdown carry fences - * of their own, and nesting them loses the boundary. */ +/** The prompt the bot receives: what was typed, then one block per + * attachment. Tagged blocks rather than fences — pasted code and markdown + * carry fences of their own, and nesting them loses the boundary. A file + * needs only its path: every driver here is an agent that can open it. */ export function composeMessage(text: string, attachments: Attachment[]): string { const parts = [text.trim()]; attachments.forEach((a, i) => { - parts.push(`\n${a.text}\n`); + if (a.kind === "paste") { + parts.push(`\n${a.text}\n`); + } else { + parts.push(``); + } }); return parts.filter(Boolean).join("\n\n"); } + +/** File paths are untrusted prompt content. Keep them inside the quoted + * attribute even when a filename contains XML characters or line breaks. */ +export function escapeAttribute(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll('"', """) + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("\t", " ") + .replaceAll("\r", " ") + .replaceAll("\n", " "); +} diff --git a/src/types/ogb.d.ts b/src/types/ogb.d.ts index cdf928586..db38ce06e 100644 --- a/src/types/ogb.d.ts +++ b/src/types/ogb.d.ts @@ -39,6 +39,9 @@ declare global { cb: (line: { partial?: boolean; text?: string; error?: string }) => void, ): () => void; onSpeechEnd(cb: (info: { code: number | null; reason?: string }) => void): () => void; + /** Absolute path of a dropped File ("" when the drag carried no + * file on disk). Absent in older builds of the shell. */ + getPathForFile?(file: File): string; /** {mic} TCC status: granted|denied|not-determined|unknown. Screen * status is deliberately absent — macOS 15+ caches it per-process, * so it lies for the whole session after a grant. */
+ {a.text.slice(0, 400)} +
- {text.slice(0, 400)} -