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: {