diff --git a/packages/app/src/attachments/file-types.test.ts b/packages/app/src/attachments/file-types.test.ts index cb90e3667..a26b57c22 100644 --- a/packages/app/src/attachments/file-types.test.ts +++ b/packages/app/src/attachments/file-types.test.ts @@ -10,7 +10,7 @@ import { describe("attachment file types", () => { it("keeps SVG as a file while treating raster image files as images", () => { - expect(getMimeTypeFromPath("/tmp/logo.svg")).toBe("image/svg+xml"); + expect(getMimeTypeFromPath("/tmp/logo.svg")).toBe("application/octet-stream"); expect(isRasterImagePath("/tmp/logo.svg")).toBe(false); expect(isRasterImageMimeType("image/svg+xml")).toBe(false); expect(isRasterImageFile(new File([""], "logo.svg", { type: "image/svg+xml" }))).toBe( @@ -18,11 +18,20 @@ describe("attachment file types", () => { ); expect(getRasterImageMimeTypeFromPath("/tmp/screenshot.PNG?cache=1")).toBe("image/png"); + expect(getMimeTypeFromPath("/tmp/screenshot.PNG?cache=1")).toBe("image/png"); expect(isRasterImagePath("/tmp/screenshot.PNG?cache=1")).toBe(true); expect(isRasterImageMimeType("image/png; charset=binary")).toBe(true); expect(isRasterImageFile(new File([new Uint8Array([0])], "screenshot.png"))).toBe(true); }); + it("does not require MIME table entries for generic file attachments", () => { + expect(getMimeTypeFromPath("/tmp/notes.md")).toBe("application/octet-stream"); + expect(getMimeTypeFromPath("/tmp/archive.zip")).toBe("application/octet-stream"); + expect(getMimeTypeFromPath("/tmp/report.docx")).toBe("application/octet-stream"); + expect(getMimeTypeFromPath("/tmp/runtime.log")).toBe("application/octet-stream"); + expect(getMimeTypeFromPath("/tmp/export.anything")).toBe("application/octet-stream"); + }); + it("does not offer SVG in the image picker extension list", () => { expect(new Set(RASTER_IMAGE_FILE_EXTENSIONS)).toEqual( new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "heic", "heif", "avif", "tif", "tiff"]), diff --git a/packages/app/src/attachments/file-types.ts b/packages/app/src/attachments/file-types.ts index caa96733e..9fc6f1027 100644 --- a/packages/app/src/attachments/file-types.ts +++ b/packages/app/src/attachments/file-types.ts @@ -1,45 +1,3 @@ -const MIME_TYPE_BY_EXTENSION: Record = { - ".png": "image/png", - ".jpg": "image/jpeg", - ".jpeg": "image/jpeg", - ".gif": "image/gif", - ".webp": "image/webp", - ".bmp": "image/bmp", - ".svg": "image/svg+xml", - ".heic": "image/heic", - ".heif": "image/heif", - ".avif": "image/avif", - ".tif": "image/tiff", - ".tiff": "image/tiff", - ".pdf": "application/pdf", - ".txt": "text/plain", - ".md": "text/markdown", - ".json": "application/json", - ".js": "text/javascript", - ".ts": "text/typescript", - ".tsx": "text/typescript-jsx", - ".jsx": "text/javascript", - ".html": "text/html", - ".css": "text/css", - ".xml": "text/xml", - ".csv": "text/csv", - ".zip": "application/zip", - ".gz": "application/gzip", - ".tar": "application/x-tar", - ".mp3": "audio/mpeg", - ".mp4": "video/mp4", - ".mov": "video/quicktime", - ".webm": "video/webm", - ".wav": "audio/wav", - ".ogg": "audio/ogg", - ".doc": "application/msword", - ".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - ".xls": "application/vnd.ms-excel", - ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", - ".ppt": "application/vnd.ms-powerpoint", - ".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation", -}; - const RASTER_IMAGE_MIME_TYPE_BY_EXTENSION: Record = { ".png": "image/png", ".jpg": "image/jpeg", @@ -55,6 +13,7 @@ const RASTER_IMAGE_MIME_TYPE_BY_EXTENSION: Record = { }; const RASTER_IMAGE_MIME_TYPES = new Set(Object.values(RASTER_IMAGE_MIME_TYPE_BY_EXTENSION)); +const GENERIC_FILE_MIME_TYPE = "application/octet-stream"; export const RASTER_IMAGE_FILE_EXTENSIONS = Object.keys(RASTER_IMAGE_MIME_TYPE_BY_EXTENSION).map( (extension) => extension.slice(1), @@ -75,7 +34,7 @@ export function getFileTypeLabel(path: string): string | null { } export function getMimeTypeFromPath(path: string): string { - return MIME_TYPE_BY_EXTENSION[getFileExtension(path)] ?? "application/octet-stream"; + return getRasterImageMimeTypeFromPath(path) ?? GENERIC_FILE_MIME_TYPE; } export function getRasterImageMimeTypeFromPath(path: string): string | null { diff --git a/packages/app/src/hooks/use-file-picker.ts b/packages/app/src/hooks/use-file-picker.ts index 110a273ee..e845e83e9 100644 --- a/packages/app/src/hooks/use-file-picker.ts +++ b/packages/app/src/hooks/use-file-picker.ts @@ -26,7 +26,7 @@ export async function readDesktopFileBytes(path: string): Promise { const { path: managedPath } = await copyDesktopAttachmentFile({ attachmentId: crypto.randomUUID(), sourcePath: path, - extension: getFileExtension(path).slice(1) || null, + extension: getFileExtension(path) || null, }); const base64 = await readDesktopFileBase64(managedPath); return base64ToUint8Array(base64); @@ -63,7 +63,7 @@ async function pickFilesWithDesktopDialog(): Promise { const { path: managedPath } = await copyDesktopAttachmentFile({ attachmentId: crypto.randomUUID(), sourcePath: filePath, - extension: getFileExtension(filePath).slice(1) || null, + extension: getFileExtension(filePath) || null, }); const base64 = await readDesktopFileBase64(managedPath); diff --git a/packages/desktop/src/features/attachments.test.ts b/packages/desktop/src/features/attachments.test.ts new file mode 100644 index 000000000..df549537a --- /dev/null +++ b/packages/desktop/src/features/attachments.test.ts @@ -0,0 +1,65 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { copyAttachmentFileToManagedStorage } from "./attachments"; + +const originalPaseoHome = process.env.PASEO_HOME; +let testHome: string | null = null; + +async function useTempPaseoHome(): Promise { + testHome = await mkdtemp(path.join(os.tmpdir(), "paseo-desktop-attachments-")); + process.env.PASEO_HOME = testHome; + return testHome; +} + +describe("desktop attachment files", () => { + afterEach(async () => { + if (originalPaseoHome === undefined) { + delete process.env.PASEO_HOME; + } else { + process.env.PASEO_HOME = originalPaseoHome; + } + + if (testHome) { + await rm(testHome, { recursive: true, force: true }); + testHome = null; + } + }); + + it("accepts dot-prefixed picker extensions for managed copies", async () => { + const paseoHome = await useTempPaseoHome(); + const sourcePath = path.join(paseoHome, "report.md"); + await writeFile(sourcePath, "# Report\n"); + + const result = await copyAttachmentFileToManagedStorage({ + attachmentId: "att_markdown", + sourcePath, + extension: ".md", + }); + + expect(result).toEqual({ + path: path.join(paseoHome, "desktop-attachments", "att_markdown.md"), + byteSize: 9, + }); + await expect(readFile(result.path, "utf8")).resolves.toBe("# Report\n"); + }); + + it("normalizes legacy bare extensions for managed copies", async () => { + const paseoHome = await useTempPaseoHome(); + const sourcePath = path.join(paseoHome, "report.md"); + await writeFile(sourcePath, "# Report\n"); + + const result = await copyAttachmentFileToManagedStorage({ + attachmentId: "att_markdown_legacy", + sourcePath, + extension: "md", + }); + + expect(result).toEqual({ + path: path.join(paseoHome, "desktop-attachments", "att_markdown_legacy.md"), + byteSize: 9, + }); + await expect(readFile(result.path, "utf8")).resolves.toBe("# Report\n"); + }); +}); diff --git a/packages/desktop/src/features/attachments.ts b/packages/desktop/src/features/attachments.ts index f93c974dd..96e9aea6c 100644 --- a/packages/desktop/src/features/attachments.ts +++ b/packages/desktop/src/features/attachments.ts @@ -40,10 +40,11 @@ function normalizeExtension(value: unknown): string { throw new Error("Attachment extension must be a string."); } const normalized = value.trim().toLowerCase(); - if (!EXTENSION_PATTERN.test(normalized)) { + const extension = normalized.startsWith(".") ? normalized : `.${normalized}`; + if (!EXTENSION_PATTERN.test(extension)) { throw new Error(`Invalid attachment extension: ${value}`); } - return normalized; + return extension; } async function buildManagedAttachmentPath(input: {