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({ /> )} - +