web+native: attachments accept any file type (#486)
The extension allowlist (fileAllowed) bit real files — an extensionless text file was rejected on every surface (the Mac said 'unsupported type', iOS silently dropped it). The gate bought nothing: the server only ever stores an opaque encrypted blob, and the filename/MIME ride inside the encrypted payload. fileAllowed now answers true everywhere (kept so the web and native twins stay in lockstep); FILE_TYPES/fileIcons remain purely as the icon + download-MIME map (📎/octet-stream for anything unmapped). Size caps unchanged. Gates removed from chat.js staging, the shell upload bridge, iOS stageFile, and Mac stage(); ui.test.mjs pins the new contract.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
8 files changed,
+31
−33
changelog/2026-07-09-any-file-attachments.md+4 −0ios/LChat/Views/NativeChatScreen.swift+0 −1ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift+6 −3ios/LChatMac/NativeFeedView.swift+1 −5test/ui.test.mjs+4 −6web/static/apps/chat.js+2 −9web/static/shell.js+2 −1web/static/ui.js+12 −8
diff --git a/changelog/2026-07-09-any-file-attachments.md b/changelog/2026-07-09-any-file-attachments.md| new file mode 100644 |
| index 0000000..91061f3 |
| --- /dev/null |
| +++ b/changelog/2026-07-09-any-file-attachments.md |
| @@ -0,0 +1,4 @@ |
| +🤖 Share any file. Attachments are no longer limited to documents and |
| +spreadsheets — drop anything into a chat and it goes through, end-to-end |
| +encrypted like everything else. (Files without an extension used to be |
| +rejected; not any more.) Size limits are unchanged. |
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift| index d73758c..3ebeef6 100644 |
| --- a/ios/LChat/Views/NativeChatScreen.swift |
| +++ b/ios/LChat/Views/NativeChatScreen.swift |
| @@ -1185,7 +1185,6 @@ struct NativeChatScreen: View { |
| defer { url.stopAccessingSecurityScopedResource() } |
| guard let data = try? Data(contentsOf: url) else { return } |
| let name = url.lastPathComponent |
| - guard fileAllowed(name) else { return } |
| let entry = StagedUpload(name: name) |
| staged.append(entry) |
| let type = UTType(filenameExtension: url.pathExtension)?.preferredMIMEType |
diff --git a/ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift b/ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift| index fdc63dc..2b15ab3 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/AttachmentFormat.swift |
| @@ -23,10 +23,13 @@ private func fileExt(_ name: String?) -> String { |
| (name ?? "").split(separator: ".").last.map { $0.lowercased() } ?? "" |
| } |
| -/// ui.js fileAllowed: a non-image file may be attached only when its extension |
| -/// is on the allowlist (the safety guard — executables/scripts have no entry). |
| +/// ui.js fileAllowed: any file may be attached (2026-07-09 — the extension |
| +/// allowlist bit real files, e.g. extensionless text; the server only stores |
| +/// an encrypted blob, so the gate bought nothing). The icon map above is the |
| +/// only per-type behaviour left. Kept so call sites stay in lockstep with |
| +/// the web's fileAllowed. |
| public func fileAllowed(_ name: String?) -> Bool { |
| - fileIcons[fileExt(name)] != nil |
| + true |
| } |
| /// "742 B" / "1.4 KB" / "12 MB" — format.js formatBytes. |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index 300419e..4e5c9d6 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -1280,11 +1280,7 @@ struct NativeFeedView: View { |
| private func stage(data: Data, name: String, type: UTType?) { |
| let isImage = type?.conforms(to: .image) ?? false |
| let isVideo = type?.conforms(to: .movie) ?? false |
| - guard isImage || isVideo || fileAllowed(name) else { |
| - busyText = "\(name): that file type isn't supported" |
| - DispatchQueue.main.asyncAfter(deadline: .now() + 3) { busyText = nil } |
| - return |
| - } |
| + // Any file type stages — size is the only gate (matching the web). |
| if isVideo, data.count > maxVideoUploadBytes { |
| busyText = "\(name): videos are capped at 50 MB" |
| DispatchQueue.main.asyncAfter(deadline: .now() + 3) { busyText = nil } |
diff --git a/test/ui.test.mjs b/test/ui.test.mjs| index bd40068..5ea6abc 100644 |
| --- a/test/ui.test.mjs |
| +++ b/test/ui.test.mjs |
| @@ -65,12 +65,10 @@ await test("fileExt: lowercased extension without the dot", () => { |
| assert.equal(fileExt(undefined), ""); |
| }); |
| -await test("fileAllowed: docs yes, executables/scripts no", () => { |
| - for (const ok of ["a.pdf", "notes.txt", "CLAUDE.local.md", "data.csv", "x.json", "deck.pptx", "sheet.xlsx", "memo.docx"]) { |
| - assert.equal(fileAllowed(ok), true, ok); |
| - } |
| - for (const bad of ["evil.exe", "run.sh", "app.js", "lib.so", "photo.png", "noext", ""]) { |
| - assert.equal(fileAllowed(bad), false, bad); |
| +await test("fileAllowed: every file type may be attached (2026-07-09)", () => { |
| + for (const name of ["a.pdf", "notes.txt", "CLAUDE.local.md", "data.csv", "x.json", |
| + "deck.pptx", "evil.exe", "run.sh", "app.js", "lib.so", "noext", ""]) { |
| + assert.equal(fileAllowed(name), true, name); |
| } |
| }); |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index 0d48dbd..e6acc9d 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -26,7 +26,7 @@ import { defineApp, keyedList, runCommand, runAppMention, messageActionsFor, ren |
| import { blockTokens, richTokens, replySnippet, formatBytes, emojiOnlyCount, firstLink, youtubeId, typingSummary } from "../format.js"; |
| import { renderCard, CARD_CSS } from "../cards.js"; |
| import { emojify } from "../emoji.js"; |
| -import { fileIcon, shouldSend, composerToken, fileAllowed, ownMessage } from "../ui.js"; |
| +import { fileIcon, shouldSend, composerToken, ownMessage } from "../ui.js"; |
| // The host bridge: chat.js injects the pieces that are server mechanisms, |
| // key-holding machinery, or host state — the app never touches crypto/tokens. |
| @@ -2673,7 +2673,6 @@ function buildSkeleton(el, ctx) { |
| // only to word the "too big" message; the host bridge does the real limiting. |
| const MAX_UPLOAD = 25 * 1024 * 1024; |
| const MAX_VIDEO_UPLOAD = 50 * 1024 * 1024; // clips get a larger cap (see shell.js) |
| - const UNSUPPORTED_TYPE = "That file type isn't supported. You can share images, video, PDFs, text, CSV, JSON, and Office documents."; |
| // alert() would freeze an embedded webview (modal blocks the native message |
| // channel) — route the error to the shell instead. |
| const stageError = (msg) => { |
| @@ -2699,12 +2698,7 @@ function buildSkeleton(el, ctx) { |
| for (const file of files) { |
| const isImage = !!(file.type && file.type.startsWith("image/")); |
| const isVideo = !!(file.type && file.type.startsWith("video/")); |
| - // Reject unsupported types before staging so no loading chip flashes for a |
| - // file we were never going to accept (the same allow-list the bridge uses). |
| - if (!isImage && !isVideo && !fileAllowed(file.name)) { |
| - stageError(UNSUPPORTED_TYPE); |
| - continue; |
| - } |
| + // Any file type stages — size is the only gate (the bridge enforces it). |
| const entry = { att: null, isImage, isVideo }; |
| pending.push(entry); |
| refreshStaged(); |
| @@ -2732,7 +2726,6 @@ function buildSkeleton(el, ctx) { |
| const msg = att === "photo" ? "Couldn't add that photo. Try a smaller one." |
| : att === "size" ? `Couldn't add that file (max ${formatBytes(MAX_UPLOAD)}).` |
| : att === "video" ? `Couldn't add that video (max ${formatBytes(MAX_VIDEO_UPLOAD)}).` |
| - : att === "type" ? UNSUPPORTED_TYPE |
| : "Couldn't add that file."; |
| stageError(msg); |
| } |
diff --git a/web/static/shell.js b/web/static/shell.js| index 9714320..b8086b7 100644 |
| --- a/web/static/shell.js |
| +++ b/web/static/shell.js |
| @@ -3035,7 +3035,8 @@ async function chatAppUpload(threadId, file, onProgress) { |
| if (!key || !file) return "generic"; |
| const isImage = !!(file.type && file.type.startsWith("image/")); |
| const isVideo = !!(file.type && file.type.startsWith("video/")); |
| - if (!isImage && !isVideo && !fileAllowed(file.name)) return "type"; |
| + // Any file type uploads — it's an opaque encrypted blob to the server, and |
| + // the name/MIME ride inside the payload. Size stays the only gate. |
| try { |
| // Every blob is tagged with its thread so the server can GC it with the |
| // thread / when the referencing message is deleted — it never sees the blob |
diff --git a/web/static/ui.js b/web/static/ui.js| index 78d4445..90a7598 100644 |
| --- a/web/static/ui.js |
| +++ b/web/static/ui.js |
| @@ -162,11 +162,14 @@ export function pinnedFirst(list, isPinned, lastOf = () => 0) { |
| } |
| // ---- attachment policy -------------------------------------------------- |
| -// Which non-image file types may be shared, keyed by extension. It's extension- |
| -// driven (not MIME) because browsers report inconsistent — often empty — types |
| -// for documents like .md and .csv. This allowlist IS the safety guard: there's |
| -// no entry for executables or scripts, so they're rejected. Images take a |
| -// separate path (downscaled + re-encoded to JPEG before upload). |
| +// ANY file may be shared (2026-07-09 — the old extension allowlist bit real |
| +// files, e.g. extensionless text; the server only ever stores an encrypted |
| +// blob and the name/MIME ride inside the payload, so a type gate bought |
| +// nothing). This map now only picks icons and download MIMEs for the types we |
| +// recognise; anything else gets 📎 + octet-stream. It's extension-driven (not |
| +// MIME) because browsers report inconsistent — often empty — types for |
| +// documents like .md and .csv. Images still take a separate path (downscaled |
| +// + re-encoded to JPEG before upload). |
| const FILE_TYPES = { |
| pdf: { mime: "application/pdf", icon: "📕" }, |
| txt: { mime: "text/plain", icon: "📄" }, |
| @@ -188,9 +191,10 @@ export function fileExt(name) { |
| return m ? m[1].toLowerCase() : ""; |
| } |
| -// True if a (non-image) file is allowed to be attached, decided by extension. |
| -export function fileAllowed(name) { |
| - return Object.prototype.hasOwnProperty.call(FILE_TYPES, fileExt(name)); |
| +// Any file may be attached — kept (always true) so older call sites keep |
| +// working; the icon/MIME maps below are the only per-type behaviour left. |
| +export function fileAllowed() { |
| + return true; |
| } |
| // Best-effort MIME for an attachment: the browser's reported type if it gave |