Skip to content

✨ 为 GM.download/GM_download 添加 onloadend 回调#1563

Draft
cyfung1031 wants to merge 5 commits into
scriptscat:mainfrom
cyfung1031:pr/onloadend-GM_download
Draft

✨ 为 GM.download/GM_download 添加 onloadend 回调#1563
cyfung1031 wants to merge 5 commits into
scriptscat:mainfrom
cyfung1031:pr/onloadend-GM_download

Conversation

@cyfung1031

@cyfung1031 cyfung1031 commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Checklist / 检查清单

  • Fixes API Compatibility Issue
  • Code reviewed by human / 代码通过人工检查
  • Changes tested / 已完成测试

背景

当前 GM.download / GM_download 支持通过 onloadonerrorontimeout 分别处理不同的下载终止状态,但缺少一个无论成功、失败还是超时都会执行的统一收尾回调。

这使用户脚本不得不在多个终止回调中重复执行相同的清理、状态更新或 UI 收尾逻辑,也与 GM_xmlhttpRequest 已提供的 onloadend 生命周期接口不一致。

本次改动

  • GMTypes.DownloadDetails 新增 onloadend 回调。

  • 同步更新以下类型声明:

    • src/types/scriptcat.d.ts
    • src/types/scriptcat.zh-CN.d.ts
    • src/template/scriptcat.d.tpl
  • 在以下下载路径中接入 onloadend

    • downloadMode: "browser":直接通过后台 chrome.downloads 下载;
    • downloadMode: "native":先通过 GM_xmlhttpRequest 获取 Blob,再交由后台保存;
    • Blob URL 等直接进入 browser 下载流程的请求。
  • 在下载通过以下状态结束时,先执行对应主回调,再执行 onloadend

    • onload
    • onerror
    • ontimeout
    • save_cancelled,沿用现有兼容行为,将用户取消“另存为”视为成功。
  • 将 browser 和 native 最终保存阶段的消息处理合并为统一逻辑,避免两条路径的终止语义出现差异。

  • 补充单元和回归测试,覆盖成功、失败、超时、后台连接失败、部分数据、回调抛错、主动中止竞态、Promise 收尾及 Blob URL 释放。

实现考虑

1. 保证主回调与 onloadend 相互独立

终止处理统一通过 withLoadEnd 执行:

  1. 调用对应的主回调,例如 onloadonerrorontimeout
  2. 无论主回调是否抛出异常,都继续调用 onloadend
  3. 无论任一用户回调是否抛出异常,都继续完成 Promise 的 resolve/reject;
  4. native 模式下同时保证 Blob URL 等资源最终得到释放。

这可以避免以下情况:

  • onload / onerror / ontimeout 抛错后,onloadend 被跳过;
  • onloadend 抛错后,GM.download 返回的 Promise 永久 pending;
  • 用户回调异常导致 native 模式的 Blob URL 无法释放。

2. 统一 browser 与 native 的最终下载阶段

browser 模式会直接连接后台执行下载;native 模式则分为两个阶段:

  1. 使用 GM_xmlhttpRequest 获取完整 Blob;
  2. 创建 Blob URL,再连接后台执行实际保存。

两条路径最终都会收到相同的后台消息,例如 onloadsave_cancelledontimeoutonerror,因此共用同一套终止处理逻辑。

native 模式额外传入幂等的 releaseResources,在以下场景中释放 Blob URL:

  • 下载成功;
  • 下载失败或超时;
  • 后台连接失败;
  • 下载过程中被中止;
  • 后台主动断开连接;
  • 用户回调抛出异常。

3. 避免将失败请求的部分数据保存为文件

native 模式下,底层 XHR 即使已经触发 onerrorontimeout,后续 onloadend 仍可能携带由已接收数据组成的非空 Blob。

如果仅判断 response instanceof Blob,这部分不完整数据可能被误认为下载成功,并继续交给 chrome.downloads 保存为截断文件,同时造成用户侧 onloadend 被重复触发。

实现通过 xhrFailed 标记记录 XHR 阶段是否已经失败。失败后收到的 XHR onloadend 不再进入 Blob 保存流程。

4. 避免 abort() 破坏内部 XHR 的收尾流程

native 模式的 XHR 阶段进入 onerrorontimeout 后,会在调用用户回调前清空 nativeAbort

这样即使用户在 onloadend 内调用外层 download.abort(),也不会再次中止并断开内部 GM_xmlhttpRequest 的消息通道,避免内部请求无法收到自己的 onloadend、Promise 永久 pending 或引用清理无法执行。

外层 GM.download 的 Promise 会直接在 XHR 失败回调的收尾阶段 reject,而不是仅依赖内部 XHR Promise 的延迟失败结果。

5. 用户回调异常不能中断内部状态机

在 native XHR 的 onerror / ontimeout 阶段,用户回调的异常不能同步传播回 GM_xmlhttpRequest 的消息处理循环。

否则可能中断内部的完成状态更新、合成 onloadend 排程及引用清理。

因此这一路径会先完成:

  • 主回调和 onloadend 调用;
  • 外层 Promise reject;
  • 内部 XHR 生命周期收尾。

随后再通过微任务异步重新抛出用户回调异常,使异常仍可被全局错误处理捕获,但不会破坏内部状态机。

已知限制

1. 主动调用 abort() 不会新增触发 onloadend

本次改动只为成功、失败、超时及下载保存阶段的终止结果补充 onloadend

主动调用返回对象的 abort() 仍沿用现有行为,不额外合成 onabortonloadend。如需统一主动中止的生命周期,需要单独设计 GM.download 的中止事件语义。

2. onloadend 参数沿用对应终止事件的数据

onloadend 不定义新的专用响应结构,而是尽量复用对应终止状态的数据:

  • 成功时携带与 onload 相同的下载结果;
  • 失败时携带 { error: "unknown" }
  • 超时时使用空对象;
  • 保留调用方传入的 context

因此它不是原生 DOM ProgressEvent 的完整映射。

3. save_cancelled 继续视为成功

用户在“另存为”窗口中取消操作时,现有实现会将 save_cancelled 视为成功,并依次调用 onloadonloadend、resolve GM.download Promise。

本 PR 保留该兼容行为,不调整取消保存的结果语义。

建议审查重点

  • onload / onerror / ontimeoutonloadend 的调用顺序是否符合预期;
  • 任一用户回调抛错时,另一个回调、Promise settlement 和资源清理是否仍能完成;
  • native XHR 失败后,xhrFailed 是否能完整阻止部分 Blob 被继续保存;
  • onloadend 内调用 abort() 时,外层和内部 Promise 是否都能正常结束;
  • browser/native 共用终止处理后,是否保留了各路径原有的下载结果和错误语义;
  • Blob URL 在连接失败、后台断连、中止及回调异常场景下是否都会释放;
  • 编辑器模板及中英文公开类型声明是否保持一致。

对齐 GM_xmlhttpRequest 的 onloadend 语义:无论下载以 onload/save_cancelled/
ontimeout/onerror 中的哪一种结束,onloadend 都会紧随其后触发一次,覆盖
browser 与 native 两种 downloadMode 的所有终态分支。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cyfung1031 cyfung1031 changed the title ✨ 为 GM.download/GM_download 实现 onloadend 回调 ✨ 为 GM.download/GM_download 添加 onloadend 回调 Jul 11, 2026
@cyfung1031 cyfung1031 marked this pull request as draft July 11, 2026 10:25
- native 模式下 xhr 因 ontimeout/onerror 失败后,底层实现仍会在其 onloadend
  带上已收数据拼出的 Blob;补充 xhrFailed 标志位拦截,避免把部分数据当作
  下载成功经 chrome.downloads 落盘为被截断的文件,且避免用户 onloadend 被
  触发两次。
- browser 模式下 a.connect() 失败此前未被捕获,导致 GM.download 返回的
  promise 永远 pending;补上与 native 模式一致的 try/catch,通过 onerror/
  onloadend/reject 通知调用方。
- 所有终态分支(onload/save_cancelled/ontimeout/onerror/default)新增
  try/finally,防止用户 onloadend 抛错时跳过 retPromiseResolve/Reject 与
  releaseResources(避免 promise 悬挂、blob URL 泄漏)。
- 补上 src/template/scriptcat.d.tpl 中 DownloadDetails.onloadend 声明,
  修复内置编辑器仍将 onloadend 报告为未知属性的问题。

新增 6 个回归测试覆盖上述场景。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cyfung1031

This comment was marked as outdated.

- 新增 withLoadEnd 共享辅助函数,将「主回调」(onload/onerror/ontimeout) 与
  onloadend 分别置于内层 try/finally:此前两者共享同一个 try 区块,一旦
  主回调抛错会直接跳到外层 finally,导致 onloadend 从未被调用,违反其
  “总是紧随主回调之后触发”的约定。该辅助函数应用于 browser 模式、native
  模式浏览器下载阶段的所有终态分支,以及 native 模式 xhr 阶段的
  ontimeout/onerror。
- native 模式 xhr 阶段的 ontimeout/onerror 新增无条件的 retPromiseReject
  调用(置于 finally 内,不再依赖 GM_xmlhttpRequest 内部 retPromise 延后
  reject 再经由 `if (aborted) return` 转发)。此前若使用者在 onloadend
  回调内呼叫 download.abort(),aborted 旗标会被提前设为 true,导致延后的
  reject 被短路跳过,GM.download 返回的 promise 永久 pending。Promise
  的 resolve/reject 本身具幂等性,早/晚两处调用互不冲突。

新增 2 个回归测试:browser 模式 onerror 主回调抛错、native 模式 xhr 阶段
失败后于 onloadend 内呼叫 abort()。均已验证在修复前会失败(前者断言失败,
后者测试超时挂起),修复后转为通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cyfung1031

Copy link
Copy Markdown
Collaborator Author

Addressed the two additional findings in d67f672:

  1. Throwing primary callback suppresses onloadend — the previous fix put the primary callback (onload/onerror/ontimeout) and onloadend in the same try block, so a throwing primary callback jumped straight to the outer finally and onloadend never ran. Added a shared withLoadEnd(primary, loadend) helper that nests them in independent try/finally blocks, so each fires regardless of whether the other throws. Applied consistently across browser mode, the native browser-download stage, and the native XHR-level ontimeout/onerror handlers.

  2. abort() called from onloadend could leave GM.download pending forever — during the native XHR's first stage, ontimeout/onerror only invoked user callbacks; the outer promise's rejection was deferred to GM_xmlhttpRequest's own internal retPromise.catch(), guarded by if (aborted) return. If the user's onloadend called download.abort(), that guard would see aborted === true by the time the deferred rejection fired and silently swallow it. Fixed by rejecting the outer promise unconditionally in a finally directly inside the XHR-level ontimeout/onerror handlers — Promise settlement is idempotent, so this doesn't conflict with the existing deferred fallback path.

Added 2 regression tests to gm_download.test.ts:

  • browser mode onerror primary callback throwing → asserts onloadend still runs and retPromise still rejects.
  • native mode: XHR-stage failure, then download.abort() called from inside the user's onloadend → asserts retPromise still rejects instead of hanging.

Verified both by temporarily reverting just the fix and confirming: the first assertion fails, and the second times out (hangs) exactly as described. Full suite (3097 tests / 290 files) + tsc --noEmit + eslint all pass after restoring the fix.

- xhr 阶段(ontimeout/onerror)改为在呼叫使用者回调前先清空 nativeAbort。
  此前若使用者在 onloadend 内呼叫 download.abort(),会经由 nativeAbort()
  无条件断开内部 GM_xmlhttpRequest 的消息连线;而该内部请求要到
  details.onerror 返回后才会置位 reqDone,其 abort() 又只在提供了
  onabort 回调时才合成 loadend——下载场景未提供 onabort,等于内部请求
  永远收不到自己的 onloadend,其 retPromise 永久 pending,也无法完成
  refCleanup。清空 nativeAbort 后,abort() 中的 nativeAbort?.() 变为
  空操作,内部请求得以自然走完生命周期。
- xhr 阶段的使用者回调(含新增的 onloadend)现在被 try/catch 包裹:
  例外不再同步向上传播回 GM_xmlhttpRequest 内部的消息处理循环——那里
  在 code===-1 协议错误分支会在 details.onerror 返回后才安排合成
  onloadend(因为该分支预期不会有真正的 onloadend 消息到达),一旦
  同步抛出会跳过这个排程,同样导致内部请求永久卡住。改为透过
  queueMicrotask 异步重新抛出,例外仍可被观察到,但不再阻断内部
  生命周期收尾。

新增 1 个回归测试并强化既有 abort 竞态测试:
- 验证 xhr 阶段失败后于 onloadend 内呼叫 abort(),不会再触发内部
  GM_xmlhttpRequest 自身的 abort(即不中断其消息连线)。
- 验证 xhr 阶段使用者回调抛错时,不会同步向上传播,而是透过
  queueMicrotask 异步重新抛出。

均已验证在修复前会失败(前者断言失败,后者同步抛出未被捕获),
修复后转为通过。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@cyfung1031

This comment was marked as outdated.

@cyfung1031 cyfung1031 added the GM API 支持一下TM/VM/FM的API label Jul 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

GM API 支持一下TM/VM/FM的API

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant