|
| 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 | + |
1 | 14 | 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; |
3 | 20 |
|
4 | 21 | 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); |
7 | 35 | }, |
8 | | - removeListener: (_callback: (downloadDelta: chrome.downloads.DownloadDelta) => void) => { |
9 | | - this.onChangedCallback = null; |
| 36 | + removeListener: (callback: DetermineFilenameListener) => { |
| 37 | + this.hook.removeListener("onDeterminingFilename", callback); |
10 | 38 | }, |
| 39 | + hasListener: (callback: DetermineFilenameListener) => |
| 40 | + this.hook.listeners("onDeterminingFilename").includes(callback), |
| 41 | + hasListeners: () => this.hook.listenerCount("onDeterminingFilename") > 0, |
11 | 42 | }; |
12 | 43 |
|
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); |
18 | 84 | }); |
19 | 85 | } |
| 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 | + } |
20 | 293 | } |
0 commit comments