fix: in-app browser download opens file inline

- Detect in-app WebViews (WeChat / TokenPocket / imToken / Telegram / iOS WKWebView, etc.) and show a guide modal asking the user to open the link in their system browser, with a copy-link action.
- For normal browsers, fetch the attachment as a Blob and trigger download from a same-origin object URL so the file always lands in the user's Downloads folder with the original filename, even when the browser would otherwise inline-preview the response.
- Fall back to the anchor download for files larger than 50MB (avoid loading them entirely into memory) or when fetch fails.
- Pass `sizeBytes` from known call sites so the threshold actually applies.
- Add localized strings for the guide modal in all 7 locales.

See .unipi/docs/debug/2026-06-05-in-app-browser-download-debug.md.
This commit is contained in:
TerryM
2026-06-05 19:06:53 +08:00
parent abfd92b16a
commit 7a33a62c8f
16 changed files with 494 additions and 106 deletions

View File

@@ -1,4 +1,18 @@
import { assetUrl } from "../../../api";
import { isInAppBrowser } from "../../../utils/inAppBrowser";
export const IN_APP_DOWNLOAD_GUIDE_EVENT = "ark:in-app-download-guide";
export type InAppDownloadGuideDetail = { url: string; filename: string };
/**
* Files larger than this skip the fetch→Blob path so mobile WebViews and
* memory-constrained devices do not OOM. They fall back to the anchor
* download (relies on the backend's `Content-Disposition: attachment`).
*/
const MAX_BLOB_DOWNLOAD_BYTES = 50 * 1024 * 1024;
export type DownloadOptions = { sizeBytes?: number };
export function pauseActiveVideos() {
document.querySelectorAll("video").forEach((video) => {
@@ -18,12 +32,62 @@ export async function downloadAttachment(
postId: string,
attachmentId: string,
filename: string,
options?: DownloadOptions,
) {
return downloadFile(attachmentDownloadUrl(postId, attachmentId), filename);
return downloadFile(
attachmentDownloadUrl(postId, attachmentId),
filename,
options,
);
}
export async function downloadFile(url: string, filename: string) {
triggerDownload(url, filename || "download");
export async function downloadFile(
url: string,
filename: string,
options?: DownloadOptions,
) {
const safeFilename = filename || "download";
// In-app WebViews (WeChat / TokenPocket / Telegram / iOS WKWebView / …)
// ignore `Content-Disposition: attachment` and have no system download
// manager, so an anchor click would just open the file inline. Surface an
// "open in external browser" guide instead of silently failing the user.
if (typeof window !== "undefined" && isInAppBrowser()) {
window.dispatchEvent(
new CustomEvent<InAppDownloadGuideDetail>(IN_APP_DOWNLOAD_GUIDE_EVENT, {
detail: { url, filename: safeFilename },
}),
);
return;
}
// Normal browsers: prefer fetch → Blob → object URL so the file always lands
// in the Downloads folder with the original filename, even when the browser
// would otherwise inline-preview the response (Chrome and Safari do this for
// PDFs / images regardless of Content-Disposition). Fall back to the anchor
// download for large files (avoid loading them entirely into memory) or when
// fetch fails for any reason.
const sizeBytes = options?.sizeBytes ?? 0;
if (sizeBytes <= MAX_BLOB_DOWNLOAD_BYTES) {
try {
const res = await fetch(url, { credentials: "omit" });
if (res.ok) {
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
try {
triggerDownload(objectUrl, safeFilename);
} finally {
// Give the browser a moment to start the download before revoking.
window.setTimeout(() => URL.revokeObjectURL(objectUrl), 4000);
}
return;
}
} catch {
// fall through to anchor fallback
}
}
triggerDownload(url, safeFilename);
}
function triggerDownload(url: string, filename: string) {