Skip to content

Commit b214ca7

Browse files
authored
🐛 browser API chrome.downloads.download 代码及Mock修正 (#1410)
* fix: browser API `chrome.downloads.download` 代码及Mock修正 * 修复 `GM.download` 回传值 * typo * 清除缓存 * code update for Copilot comments * add `example/tests/gm_download_test.js` * Update gm_download_test.js * update
1 parent 6ff70eb commit b214ca7

8 files changed

Lines changed: 1974 additions & 87 deletions

File tree

example/tests/gm_download_test.js

Lines changed: 1126 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 283 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,293 @@
1+
import EventEmitter from "eventemitter3";
2+
3+
type DownloadChangedListener = (downloadDelta: chrome.downloads.DownloadDelta) => void;
4+
type DetermineFilenameListener = (
5+
downloadItem: chrome.downloads.DownloadItem,
6+
suggest: (suggestion?: chrome.downloads.FilenameSuggestion) => void
7+
) => void | boolean;
8+
9+
type Callback<T> = (value: T) => void;
10+
type DownloadItem = chrome.downloads.DownloadItem & {
11+
conflictAction?: `${chrome.downloads.FilenameConflictAction}`;
12+
};
13+
114
export default class Downloads {
2-
onChangedCallback: ((downloadDelta: chrome.downloads.DownloadDelta) => void) | null = null;
15+
downloadIdAccum: number = 0;
16+
hook = new EventEmitter<string, any>();
17+
items = new Map<number, DownloadItem>();
18+
autoComplete = true;
19+
autoCompleteDelay = 1;
320

421
onChanged = {
5-
addListener: (callback: (downloadDelta: chrome.downloads.DownloadDelta) => void) => {
6-
this.onChangedCallback = callback;
22+
addListener: (callback: DownloadChangedListener) => {
23+
this.hook.addListener("onChanged", callback);
24+
},
25+
removeListener: (callback: DownloadChangedListener) => {
26+
this.hook.removeListener("onChanged", callback);
27+
},
28+
hasListener: (callback: DownloadChangedListener) => this.hook.listeners("onChanged").includes(callback),
29+
hasListeners: () => this.hook.listenerCount("onChanged") > 0,
30+
};
31+
32+
onDeterminingFilename = {
33+
addListener: (callback: DetermineFilenameListener) => {
34+
this.hook.addListener("onDeterminingFilename", callback);
735
},
8-
removeListener: (_callback: (downloadDelta: chrome.downloads.DownloadDelta) => void) => {
9-
this.onChangedCallback = null;
36+
removeListener: (callback: DetermineFilenameListener) => {
37+
this.hook.removeListener("onDeterminingFilename", callback);
1038
},
39+
hasListener: (callback: DetermineFilenameListener) =>
40+
this.hook.listeners("onDeterminingFilename").includes(callback),
41+
hasListeners: () => this.hook.listenerCount("onDeterminingFilename") > 0,
1142
};
1243

13-
download(_: any, callback: (downloadId: number) => void) {
14-
callback && callback(1);
15-
this.onChangedCallback?.({
16-
id: 1,
17-
state: { current: "complete" },
44+
reset() {
45+
this.downloadIdAccum = 0;
46+
this.items.clear();
47+
this.hook.removeAllListeners();
48+
this.autoComplete = true;
49+
this.autoCompleteDelay = 1;
50+
this.clearLastError();
51+
}
52+
53+
download(options: chrome.downloads.DownloadOptions, callback?: Callback<number>) {
54+
this.clearLastError();
55+
if (!options?.url) {
56+
const error = new Error("The download url is required.");
57+
if (callback) {
58+
this.withLastError(error.message, () => callback(undefined as unknown as number));
59+
return;
60+
}
61+
return Promise.reject(error);
62+
}
63+
64+
const id = ++this.downloadIdAccum;
65+
const item = this.createDownloadItem(id, options);
66+
this.items.set(id, item);
67+
68+
// Chrome 会先把 id 返回给调用方,随后才进入文件名决定和状态变化事件。
69+
const delayed = async () => {
70+
await this.determineFilename(item);
71+
if (this.autoComplete && item.state === "in_progress") {
72+
this.complete(id);
73+
}
74+
};
75+
76+
if (callback) {
77+
callback(id);
78+
setTimeout(delayed, this.autoCompleteDelay);
79+
return;
80+
}
81+
return new Promise<number>((resolve) => {
82+
resolve(id);
83+
setTimeout(delayed, this.autoCompleteDelay);
1884
});
1985
}
86+
87+
cancel(downloadId: number, callback?: () => void) {
88+
this.clearLastError();
89+
const item = this.items.get(downloadId);
90+
if (!item) return this.maybeAsync(undefined, callback);
91+
if (item.state === "in_progress") {
92+
item.state = "interrupted";
93+
item.error = "USER_CANCELED";
94+
item.endTime = new Date().toISOString();
95+
this.emitChanged({
96+
id: downloadId,
97+
state: { previous: "in_progress", current: "interrupted" },
98+
error: { current: "USER_CANCELED" },
99+
});
100+
}
101+
return this.maybeAsync(undefined, callback);
102+
}
103+
104+
search(query: chrome.downloads.DownloadQuery, callback?: Callback<chrome.downloads.DownloadItem[]>) {
105+
this.clearLastError();
106+
const result = [...this.items.values()].filter((item) => this.matchQuery(item, query));
107+
return this.maybeAsync(result, callback);
108+
}
109+
110+
erase(query: chrome.downloads.DownloadQuery, callback?: Callback<number[]>) {
111+
this.clearLastError();
112+
const ids = [...this.items.values()].filter((item) => this.matchQuery(item, query)).map((item) => item.id);
113+
ids.forEach((id) => this.items.delete(id));
114+
return this.maybeAsync(ids, callback);
115+
}
116+
117+
pause(downloadId: number, callback?: () => void) {
118+
this.clearLastError();
119+
const item = this.items.get(downloadId);
120+
if (item && item.state === "in_progress" && !item.paused) {
121+
item.paused = true;
122+
this.emitChanged({
123+
id: downloadId,
124+
paused: { previous: false, current: true },
125+
});
126+
}
127+
return this.maybeAsync(undefined, callback);
128+
}
129+
130+
resume(downloadId: number, callback?: () => void) {
131+
this.clearLastError();
132+
const item = this.items.get(downloadId);
133+
if (item && item.paused) {
134+
item.paused = false;
135+
this.emitChanged({
136+
id: downloadId,
137+
paused: { previous: true, current: false },
138+
});
139+
}
140+
return this.maybeAsync(undefined, callback);
141+
}
142+
143+
show(_downloadId: number) {
144+
this.clearLastError();
145+
}
146+
147+
showDefaultFolder() {
148+
this.clearLastError();
149+
}
150+
151+
open(_downloadId: number, callback?: () => void) {
152+
this.clearLastError();
153+
return this.maybeAsync(undefined, callback);
154+
}
155+
156+
removeFile(_downloadId: number, callback?: () => void) {
157+
this.clearLastError();
158+
return this.maybeAsync(undefined, callback);
159+
}
160+
161+
complete(downloadId: number) {
162+
const item = this.items.get(downloadId);
163+
if (!item || item.state !== "in_progress") return;
164+
item.state = "complete";
165+
item.bytesReceived = item.totalBytes >= 0 ? item.totalBytes : item.bytesReceived;
166+
item.endTime = new Date().toISOString();
167+
this.emitChanged({
168+
id: downloadId,
169+
state: { previous: "in_progress", current: "complete" },
170+
});
171+
}
172+
173+
interrupt(downloadId: number, error: `${chrome.downloads.InterruptReason}` = "NETWORK_FAILED") {
174+
const item = this.items.get(downloadId);
175+
if (!item || item.state !== "in_progress") return;
176+
item.state = "interrupted";
177+
item.error = error;
178+
item.endTime = new Date().toISOString();
179+
this.emitChanged({
180+
id: downloadId,
181+
state: { previous: "in_progress", current: "interrupted" },
182+
error: { current: error },
183+
});
184+
}
185+
186+
private createDownloadItem(id: number, options: chrome.downloads.DownloadOptions): DownloadItem {
187+
const filename = options.filename || this.inferFilename(options.url);
188+
return {
189+
id,
190+
url: options.url,
191+
finalUrl: options.url,
192+
referrer: "",
193+
filename,
194+
danger: "safe",
195+
mime: "",
196+
startTime: new Date().toISOString(),
197+
endTime: undefined,
198+
estimatedEndTime: undefined,
199+
state: "in_progress",
200+
paused: false,
201+
canResume: false,
202+
error: undefined,
203+
bytesReceived: 0,
204+
totalBytes: -1,
205+
fileSize: -1,
206+
exists: true,
207+
byExtensionId: globalThis.chrome?.runtime?.id,
208+
byExtensionName: "ScriptCat Mock",
209+
incognito: false,
210+
conflictAction: options.conflictAction,
211+
} as DownloadItem;
212+
}
213+
214+
private inferFilename(url: string) {
215+
try {
216+
const pathname = new URL(url).pathname;
217+
return decodeURIComponent(pathname.split("/").filter(Boolean).pop() || "download");
218+
} catch {
219+
return "download";
220+
}
221+
}
222+
223+
private async determineFilename(item: DownloadItem) {
224+
const listeners = this.hook.listeners("onDeterminingFilename") as DetermineFilenameListener[];
225+
if (listeners.length === 0) return;
226+
227+
const suggestion = await new Promise<chrome.downloads.FilenameSuggestion | undefined>((resolve) => {
228+
let settled = false;
229+
const suggest = (value?: chrome.downloads.FilenameSuggestion) => {
230+
if (settled) return;
231+
settled = true;
232+
resolve(value);
233+
};
234+
listeners.forEach((listener) => listener({ ...item } as chrome.downloads.DownloadItem, suggest));
235+
setTimeout(() => suggest(), 50);
236+
});
237+
238+
if (suggestion?.filename) {
239+
const previous = item.filename;
240+
item.filename = suggestion.filename;
241+
item.conflictAction = suggestion.conflictAction;
242+
this.emitChanged({
243+
id: item.id,
244+
filename: { previous, current: suggestion.filename },
245+
});
246+
}
247+
}
248+
249+
private matchQuery(item: DownloadItem, query: chrome.downloads.DownloadQuery) {
250+
if (query.id !== undefined && item.id !== query.id) return false;
251+
if (query.url && item.url !== query.url) return false;
252+
if (query.filename && item.filename !== query.filename) return false;
253+
if (query.state && item.state !== query.state) return false;
254+
return true;
255+
}
256+
257+
private emitChanged(delta: chrome.downloads.DownloadDelta) {
258+
this.hook.emit("onChanged", delta);
259+
}
260+
261+
private maybeAsync<T>(value: T, callback?: Callback<T>) {
262+
if (callback) {
263+
callback(value);
264+
return;
265+
}
266+
return Promise.resolve(value);
267+
}
268+
269+
private withLastError(message: string, fn: () => void) {
270+
(
271+
globalThis.chrome.runtime as typeof chrome.runtime & {
272+
lastError?: chrome.runtime.LastError;
273+
}
274+
).lastError = {
275+
message,
276+
};
277+
try {
278+
fn();
279+
} finally {
280+
this.clearLastError();
281+
}
282+
}
283+
284+
private clearLastError() {
285+
if (globalThis.chrome?.runtime) {
286+
delete (
287+
globalThis.chrome.runtime as typeof chrome.runtime & {
288+
lastError?: chrome.runtime.LastError;
289+
}
290+
).lastError;
291+
}
292+
}
20293
}

packages/chrome-extension-mock/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ const chromeMock = {
2626
extension: new Extension(),
2727
userScripts: new MockUserScripts(),
2828
action: new Action(),
29-
init() {},
29+
init() {
30+
this.downloads.reset();
31+
},
3032
};
3133

3234
export default chromeMock;

0 commit comments

Comments
 (0)