Skip to content
230 changes: 150 additions & 80 deletions src/app/service/content/gm_api/gm_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -991,6 +991,85 @@ export default class GMApi extends GM_Base {
}
return retParam as K;
};
// 保证「主回调」(onload/onerror/ontimeout) 与 onloadend 各自独立执行,且无论是否抛错,
// settle(resolve/reject retPromise,必要时 releaseResources)最终都会被调用一次。
// onError 用于 native xhr 的 ontimeout/onerror:不能让使用者回调的例外同步传播回
// GM_xmlhttpRequest 的内部消息处理循环(会中断其状态机,导致内部请求永久卡住),
// 改为异步重新抛出,让例外仍可被观察到(如全局错误上报),但不阻断内部生命周期收尾。
const withLoadEnd = (
primary: () => void,
loadendData: Record<string, any>,
settle: () => void,
onError?: (err: unknown) => void
) => {
try {
try {
primary();
} finally {
details.onloadend?.(makeCallbackParam(loadendData));
}
} catch (err) {
if (onError) {
onError(err);
} else {
throw err;
}
} finally {
settle();
}
};
// browser 与 native 两条路径最终都经由后台 GM_download 回传同一组消息(onload/save_cancelled/
// onerror),此处共用同一份处理逻辑;releaseResources 仅 native 路径需要,browser 路径不传。
const handleDownloadMessage = (data: { action?: string; data?: any }, releaseResources?: () => void) => {
switch (data.action) {
case "onload":
case "save_cancelled": // saveAs cancelled by user,TM 视为下载成功
withLoadEnd(
() => details.onload?.(makeCallbackParam({ ...data.data })),
{ ...data.data },
() => {
retPromiseResolve?.(data.data);
releaseResources?.();
}
);
break;
case "onprogress":
details.onprogress?.(makeCallbackParam({ ...data.data, mode: "browser" }));
retPromiseReject?.(new Error("Timeout ERROR"));
break;
case "ontimeout":
withLoadEnd(
() => details.ontimeout?.(makeCallbackParam({})),
{},
() => {
retPromiseReject?.(new Error("Timeout ERROR"));
releaseResources?.();
}
);
break;
case "onerror":
withLoadEnd(
() => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError),
{ error: "unknown" },
() => {
retPromiseReject?.(new Error("Unknown ERROR"));
releaseResources?.();
}
);
break;
default:
LoggerCore.logger().warn("GM_download resp is error", { data });
withLoadEnd(
() => {},
{},
() => {
retPromiseReject?.(new Error("Unexpected Internal ERROR"));
releaseResources?.();
}
);
break;
}
};
const handle = async () => {
const url = await urlPromiseLike;
const downloadMode = details.downloadMode || "native"; // native = sc_default; browser = chrome api
Expand All @@ -1007,60 +1086,49 @@ export default class GMApi extends GM_Base {
// ignored
}
}
const con = await a.connect("GM_download", [
{
method: details.method,
downloadMode: "browser", // 默认使用xhr下载
url: url as string,
name: details.name,
headers: details.headers,
saveAs: details.saveAs,
conflictAction: details.conflictAction,
timeout: details.timeout,
cookie: details.cookie,
anonymous: details.anonymous,
} as GMTypes.DownloadDetails<string>,
]);
let con: MessageConnect;
try {
con = await a.connect("GM_download", [
{
method: details.method,
downloadMode: "browser", // 默认使用xhr下载
url: url as string,
name: details.name,
headers: details.headers,
saveAs: details.saveAs,
conflictAction: details.conflictAction,
timeout: details.timeout,
cookie: details.cookie,
anonymous: details.anonymous,
} as GMTypes.DownloadDetails<string>,
]);
} catch (e) {
// 后台连接失败:通过 onerror / reject 通知调用方,行为与 “onMessage 收到 onerror” 一致,
// 否则 GM.download 的 promise 会永远 pending(issue: 无 native XHR 后备路径可兜底)。
if (!aborted) {
withLoadEnd(
() => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError),
{ error: "unknown" },
() => retPromiseReject?.(e instanceof Error ? e : new Error("GM_download connect ERROR"))
);
}
return;
}
if (aborted) return;
connect = con;
connect.onMessage((data) => {
switch (data.action) {
case "onload":
details.onload?.(makeCallbackParam({ ...data.data }));
retPromiseResolve?.(data.data);
break;
case "save_cancelled": // saveAs cancelled by user,TM 视为下载成功
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;
}
});
connect.onMessage((data) => handleDownloadMessage(data));
} else {
// native
// xhr 已因 ontimeout/onerror 失败:即使失败前已收到部分数据,底层 XHR 实现仍会在其
// onloadend 中带上由已收数据拼出的 Blob。必须以此标志位拦截,否则会把这段部分数据
// 当作下载成功,误经 chrome.downloads 落盘为被截断的文件。
let xhrFailed = false;
const xhrParams = {
url: url,
fetch: true, // 跟随TM使用 fetch; 使用 fetch 避免 1) 大量数据存放offscreen xhr 2) vivaldi offscreen client block
responseType: "blob",
onloadend: async (res) => {
if (aborted) return;
if (aborted || xhrFailed) return;
const response = res.response;
if (!(response instanceof Blob)) return;

Expand Down Expand Up @@ -1099,8 +1167,11 @@ export default class GMApi extends GM_Base {
// 行为与 “onMessage 收到 onerror” 一致,保持外层 contract 不变。
releaseResources();
if (!aborted) {
details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError);
retPromiseReject?.(e instanceof Error ? e : new Error("GM_download connect ERROR"));
withLoadEnd(
() => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError),
{ error: "unknown" },
() => retPromiseReject?.(e instanceof Error ? e : new Error("GM_download connect ERROR"))
);
}
return;
}
Expand All @@ -1117,37 +1188,7 @@ export default class GMApi extends GM_Base {
}

connect = con;
connect.onMessage((data) => {
switch (data.action) {
case "onload":
details.onload?.(makeCallbackParam({ ...data.data }));
retPromiseResolve?.(data.data);
releaseResources();
break;
case "save_cancelled": // saveAs cancelled by user,TM 视为下载成功
details.onload?.(makeCallbackParam({ ...data.data }));
retPromiseResolve?.(data.data);
releaseResources();
break;
case "ontimeout":
details.ontimeout?.(makeCallbackParam({}));
retPromiseReject?.(new Error("Timeout ERROR"));
releaseResources();
break;
case "onerror":
details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError);
retPromiseReject?.(new Error("Unknown ERROR"));
releaseResources();
break;
default:
LoggerCore.logger().warn("GM_download resp is error", {
data,
});
retPromiseReject?.(new Error("Unexpected Internal ERROR"));
releaseResources();
break;
}
});
connect.onMessage((data) => handleDownloadMessage(data, releaseResources));

// 后台主动断连(例如 SW 重启、扩展更新)也释放 URL,避免长尾泄漏。
// releaseResources 通过 released 标志位幂等,与 onMessage 内部的释放调用顺序无关。
Expand All @@ -1162,10 +1203,39 @@ export default class GMApi extends GM_Base {
details.onprogress?.(makeCallbackParam({ ...e, mode: "native" }));
},
ontimeout: () => {
details.ontimeout?.(makeCallbackParam({}));
xhrFailed = true;
// XHR 阶段已终结:清空 nativeAbort,避免使用者在 onloadend 内呼叫
// download.abort() 时经由 nativeAbort() 强制断开内部 XHR 的消息连线
// (其 abort() 会无条件 disconnect),导致内部 XHR 收不到自己真正的
// onloadend 消息、其 retPromise 永久 pending、refCleanup 也无法执行。
// 清空后 abort() 中的 nativeAbort?.() 变为空操作,内部 XHR 可自然收尾。
nativeAbort = null;
// 必须在此直接 settle:若使用者在 onloadend 内呼叫 abort(),只会设置 aborted
// 旗标,不代表这次下载没有失败。原本仅靠 GM_xmlhttpRequest 内部 retPromise 之后
// 才 reject 的路径,会被外层 `if (aborted) return` 短路,导致 GM.download 的
// promise 永久 pending。settle 具幂等性,重复调用是安全的。
withLoadEnd(
() => details.ontimeout?.(makeCallbackParam({})),
{},
() => retPromiseReject?.(new Error("Native Download ERROR")),
(err) =>
queueMicrotask(() => {
throw err;
})
);
},
onerror: () => {
details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError);
xhrFailed = true;
nativeAbort = null;
withLoadEnd(
() => details.onerror?.(makeCallbackParam({ error: "unknown" }) as GMTypes.DownloadError),
{ error: "unknown" },
() => retPromiseReject?.(new Error("Native Download ERROR")),
(err) =>
queueMicrotask(() => {
throw err;
})
);
},
} as GMTypes.XHRDetails;
if (typeof details.headers === "object") {
Expand Down
Loading
Loading