Skip to content

fix: browser API chrome.downloads.download 代码及Mock修正#1410

Open
cyfung1031 wants to merge 8 commits into
mainfrom
fix/download-api-custom-naming
Open

fix: browser API chrome.downloads.download 代码及Mock修正#1410
cyfung1031 wants to merge 8 commits into
mainfrom
fix/download-api-custom-naming

Conversation

@cyfung1031
Copy link
Copy Markdown
Collaborator

@cyfung1031 cyfung1031 commented May 6, 2026

Checklist / 检查清单

  • Fixes mentioned issues / 修复已提及的问题
  • Code reviewed by human / 代码通过人工检查
  • Changes tested / 已完成测试

Description / 描述

Close #1409

  1. 解决 扩充冲突问题,必须引入 chrome.downloads.onDeterminingFilename 。这是官方已知的 Chromium Bug。 https://issues.chromium.org/issues/40706258 。实作起来可以解决问题,所以就先这样吧。
  2. 配合 chrome.downloads.onDeterminingFilename,重新包装了 chrome.downloads.download 相关的操作。
  3. 重写了 chrome.downloads 的 mock
  4. 新增了一连串的 单元测试

Screenshots / 截图


Filename for chrome.downloads.download is ignored if onDeterminingFilename listener exists
https://issues.chromium.org/issues/40734759

@cyfung1031 cyfung1031 added the P1 🔥 重要但是不紧急的内容 label May 6, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

该 PR 旨在修复 chrome.downloads.download 在存在 onDeterminingFilename 监听器时可能忽略 filename 的 Chromium 已知问题(Close #1409),通过在 service worker 侧引入统一的下载封装来稳定处理文件名覆盖,并同步完善 chrome.downloads 的 mock 与单元测试,确保行为可回归验证。

Changes:

  • 新增 startDownload 下载封装:集中处理 onDeterminingFilename + onChanged,并为调用方提供完成/中断回调。
  • 将 service worker 中相关下载调用(如备份导出、GM_download)改为使用新封装,以规避文件名覆盖失效问题。
  • 重写 chrome.downloads mock,补齐 Promise/callback 形态与事件模拟,并新增对应单测覆盖。

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
src/app/service/service_worker/synchronize.ts 备份导出下载改为走 startDownload 封装
src/app/service/service_worker/gm_api/gm_api.ts GM_download 下载流程改为使用 startDownload + 回调通知
src/app/service/service_worker/download.ts 新增下载封装:注册/卸载监听、文件名覆盖与完成/中断回调
src/app/service/service_worker/download.test.ts 为下载封装与 mock 下载行为新增单测覆盖
src/app/service/content/gm_api/gm_api.ts 优化 native->browser 下载链路的 Object URL 释放时机(覆盖更多回包分支)
packages/chrome-extension-mock/index.ts chromeMock.init 时重置 downloads mock 状态
packages/chrome-extension-mock/downloads.ts 重写 downloads mock:支持 Promise/callback、事件与文件名决策流程

Comment thread src/app/service/service_worker/download.ts
Comment thread src/app/service/service_worker/download.ts
Comment thread src/app/service/service_worker/download.ts Outdated
Comment on lines +1046 to +1072
@@ -1060,30 +1061,44 @@ export default class GMApi extends GM_Base {
} as GMTypes.DownloadDetails<string>,
]);
if (aborted) return;
let released = false;
const releaseResources = () => {
if (released) return;
released = true;
setTimeout(() => {
// 释放不需要的 URL
URL.revokeObjectURL(url);
}, 1);
};
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolved in d64cd41

Comment thread packages/chrome-extension-mock/downloads.ts
Comment thread src/app/service/service_worker/download.ts Outdated
@cyfung1031 cyfung1031 marked this pull request as draft May 10, 2026 05:55
@cyfung1031
Copy link
Copy Markdown
Collaborator Author

cyfung1031 commented May 10, 2026

先研究一下 Copilot 的说法

DRAFT PR

@cyfung1031 cyfung1031 marked this pull request as ready for review May 11, 2026 03:47
@CodFrm
Copy link
Copy Markdown
Member

CodFrm commented May 15, 2026

Code review

Found 1 functional issue + a few CLAUDE.md hygiene points worth a look:

  1. save_cancelled 只在 native 路径生效,直接 browser 路径漏处理(功能回归)

src/app/service/content/gm_api/gm_api.ts 里 GM_download 有两条独立的 connect.onMessage switch。PR 在 native (xhr→blob) 路径里加了 case "save_cancelled":,但 downloadMode === "browser"blob: URL 走的直连分支没加,仍会落到 default: 触发 retPromiseReject(new Error("Unexpected Internal ERROR")) + onerror,与 PR 想保证的"save_cancelled → onload"契约相反。讽刺的是 example/tests/gm_download_test.js 注释里 "Cancel via chrome://downloads (browser mode)" 这条用例正好覆盖到这条会出 bug 的路径。

connect.onMessage((data) => {
switch (data.action) {
case "onload":
details.onload?.(makeCallbackParam({ ...data.data }));
retPromiseResolve?.(data.data);
break;
case "onprogress":
details.onprogress?.(makeCallbackParam({ ...data.data, mode: "browser" }));
retPromiseReject?.(new Error("Timeout ERROR"));
break;
case "ontimeout":
details.ontimeout?.(makeCallbackParam({}));
retPromiseReject?.(new Error("Timeout ERROR"));
break;
case "onerror":
details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError);
retPromiseReject?.(new Error("Unknown ERROR"));
break;
default:
LoggerCore.logger().warn("GM_download resp is error", {
data,
});
retPromiseReject?.(new Error("Unexpected Internal ERROR"));
break;
}
});
} else {

而 SW 是无差别给两条 conn 都发 save_cancelled 的:

if (!isConnDisconnected && !reqCompleteWith) {
reqCompleteWith = "save_cancelled";
msgConn.sendMessage({
action: "save_cancelled",
data: { loaded: o.loaded, total: o.total, mode: "native" }, // compatible with GM.download in TM
});
}
} else if (o.state === "interrupted") {
if (!isConnDisconnected && !reqCompleteWith) {
reqCompleteWith = "interrupted";
// 这情况须进一步确认 TM 的 GM.download 回传值

  1. try { ... } catch { /* ignored */ } 吞错 (CLAUDE.md: "No as any / // @ts-ignore / try-catch swallow / defensive skips to make errors disappear")

onDeterminingFilename 主逻辑里的吞错最值得回头看 —— 它包住的是 responseMap.set / 读 entity.nameOverride / 调用 suggest(),万一这里出错只会静默落到无 filename 的 fallback,没有诊断信号:

conflictAction: pendingOverride.conflictAction,
});
called = true;
}
} catch {
// ignored
}
// 与当前逻辑无关的下载也必须调用 suggest,否则 Chrome 会认为事件未被消费。
if (!called) {
suggest();

  1. cDownloadId! 非空断言绕过收窄 (CLAUDE.md: "Fix root causes, not symptoms")

cDownloadId 改成 number | undefined 后用 ! 跳过 TS 收窄;改成 if (typeof cDownloadId === "number" && cDownloadId > 0) 后续就不需要 ! 了。

isConnDisconnected = true;
if (cDownloadId! > 0 && !reqCompleteWith) {
reqCompleteWith = "disconnected";
chrome.downloads.cancel(cDownloadId!, () => {
const lastError = chrome.runtime.lastError;
if (lastError) {
console.error("chrome.runtime.lastError in chrome.downloads.cancel:", lastError);
}
});
detachDownloadCallback(cDownloadId);
}

  1. detachDownloadCallback(id) 在读取 responseMap 之前调用,使 onDeterminingFilename 缓存数据失效

onChangedListener 进入终态分支第一行就 detachDownloadCallback(id),而该函数会同时 responseMap.delete(id)。紧接着 !responseMap.has(id) 的判断恒为 true,导致 chrome.downloads.search 兜底永远跑;若 search 拿不到 item,回调的 loaded/total 会是 undefined。建议把清理挪到通知 callback 之后,或只在 detach 时清 requestMap

const onChangedListener = (downloadDelta: chrome.downloads.DownloadDelta) => {
const lastError = chrome.runtime.lastError;
if (lastError) {
console.error("chrome.runtime.lastError in chrome.downloads.onChanged:", lastError);
return;
}
stackAsyncTask("browser_api_download", async () => {
const id = downloadDelta.id;
const entry = requestMap.get(id);
if (!entry) return;
const state = downloadDelta.state?.current;
if (state === STATE.COMPLETE || state === STATE.INTERRUPTED) {
detachDownloadCallback(id);
// 查询最终的 DownloadItem,以便:
// 1) 在 responseMap 尚未填充时补齐 totalBytes/fileSize/bytesReceived;
// 2) 拿到权威的 error 字段,用于区分用户取消(saveAs / 主动取消)与其他中断。
// 仅当本次 delta 携带 error 信息或 responseMap 未填充时才发起 search,
// 避免对每个 delta 都额外调用 chrome.downloads.search 造成不必要开销。
let interruptError: InterruptReason | undefined = downloadDelta.error?.current as InterruptReason | undefined;
const needSearch = !responseMap.has(id) || (state === STATE.INTERRUPTED && interruptError === undefined);
if (needSearch) {
const downloadItem = (await chrome.downloads.search({ id: id }))?.[0];
if (downloadItem && downloadItem.id === id) {
if (!responseMap.has(id)) {
responseMap.set(id, {
downloadItem: {
bytesReceived: downloadItem.bytesReceived,
fileSize: downloadItem.fileSize,
totalBytes: downloadItem.totalBytes,
},
});
}
if (state === STATE.INTERRUPTED && interruptError === undefined && downloadItem.error) {
interruptError = downloadItem.error as InterruptReason;
}
}
}
const downloadItem = responseMap.get(id);
// save_cancelled 仅在 chrome 报告 USER_CANCELED 时回报。
// 早期版本用 “interrupted + 已触发过 onDeterminingFilename” 推断,但
// onDeterminingFilename 对普通下载也会先触发,NETWORK_FAILED 等中断会被误报。
const isSaveCancelled = state === STATE.INTERRUPTED && interruptError === "USER_CANCELED";

  1. CLAUDE.md "Comments in Simplified Chinese"download.tsgm_api/gm_api.ts 新增几处英文注释(Each listener must call suggest exactly once...compatible with GM.download in TM 等)。

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

Copy link
Copy Markdown
Member

@CodFrm CodFrm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

几个具体修改建议附在 inline 评论里。其中第一条是功能回归,建议合并前一并补上。

retPromiseResolve?.(data.data);
releaseResources();
break;
case "save_cancelled": // saveAs cancelled by user
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

功能回归:直接 browser 路径漏了同样的 save_cancelled case。

本文件里 GM_download 有两条独立的 connect.onMessage switch:

  • 这里(native xhr → blob 内层,L1102-)—— ✅ PR 已加 save_cancelled
  • 上面 L1012-1037 的直连分支(downloadMode === "browser"blob: URL)—— ❌ 没加,会落到 default: 触发 retPromiseReject(new Error("Unexpected Internal ERROR")) + onerror

SW 现在是无差别给两条 conn 都发 save_cancelled 的(src/app/service/service_worker/gm_api/gm_api.ts 新增的 else if (o.state === "save_cancelled")),所以走 downloadMode: "browser" + saveAs: true 然后点取消的脚本会拿到错误回调,正好和这条 PR 想保证的契约相反。example/tests/gm_download_test.js 的 "Cancel via chrome://downloads (browser mode)" 用例覆盖到的就是这条会出 bug 的路径。

建议在 L1014 同样加一条(直连分支没有 releaseResources,所以更短):

            case "onload":
            case "save_cancelled": // saveAs cancelled by user,TM 兼容契约:视作下载成功
              details.onload?.(makeCallbackParam({ ...data.data }));
              retPromiseResolve?.(data.data);
              break;

if (!entry) return;
const state = downloadDelta.state?.current;
if (state === STATE.COMPLETE || state === STATE.INTERRUPTED) {
detachDownloadCallback(id);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

缓存路径恒失效。

detachDownloadCallback(id) 内部会 responseMap.delete(downloadId)(L177-181),紧接着 L126 !responseMap.has(id) 永远为 true,onDeterminingFilename 之前缓存的 bytesReceived/fileSize/totalBytes 在这里其实拿不到,每次都得调 chrome.downloads.search 兜底;若 search 拿不到 item,最终 callback 的 loaded/totalundefined

建议把 detach 挪到 notifyDownloadCallback 之后(L155 处),并去掉 L156 重复的 responseMap.delete(id)

      await notifyDownloadCallback(entry.callback, {
        downloadId: id,
        state: isSaveCancelled ? "save_cancelled" : state,
        loaded: downloadItem?.downloadItem?.totalBytes,
        total: downloadItem?.downloadItem?.totalBytes,
      });

      detachDownloadCallback(id); // 通知完成后再清理,避免 responseMap 缓存被提前删除

Comment on lines +1311 to +1313
if (cDownloadId! > 0 && !reqCompleteWith) {
reqCompleteWith = "disconnected";
chrome.downloads.cancel(cDownloadId, () => {
chrome.downloads.cancel(cDownloadId!, () => {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

非空断言绕过收窄(CLAUDE.md: "Fix root causes, not symptoms. No as any / // @ts-ignore / try-catch swallow ...")。

typeof 检查替代 !,运行时等价,TS 也能自动收窄:

Suggested change
if (cDownloadId! > 0 && !reqCompleteWith) {
reqCompleteWith = "disconnected";
chrome.downloads.cancel(cDownloadId, () => {
chrome.downloads.cancel(cDownloadId!, () => {
if (typeof cDownloadId === "number" && cDownloadId > 0 && !reqCompleteWith) {
reqCompleteWith = "disconnected";
chrome.downloads.cancel(cDownloadId, () => {

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P1 🔥 重要但是不紧急的内容

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] 推特下载文件的脚本,无法正确使用重命名功能,会显示代码串

3 participants