@having5548/dsh-downloader 0.0.0-stage → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Network diagnostics used by the `dsh_geo_check` tool and by the route
3
+ * decider's "unknown geography" fallback.
4
+ *
5
+ * These helpers are never on the hot download path: routing prefers the
6
+ * offline rule engine, so a sandbox with no DNS or no ICMP still downloads fine.
7
+ * @module dsh-downloader/net-probe
8
+ */
9
+ import dns from "node:dns/promises";
10
+ import { spawn } from "node:child_process";
11
+ import { openTarget, readBody, tcpConnect } from "./http.js";
12
+
13
+ /** Resolve every A/AAAA record for a host. Returns [] on failure. */
14
+ export async function resolveHost(host) {
15
+ try {
16
+ const records = await dns.lookup(host, { all: true, verbatim: true });
17
+ return records.map((record) => record.address);
18
+ } catch {
19
+ return [];
20
+ }
21
+ }
22
+
23
+ /**
24
+ * ICMP ping via the platform `ping` binary.
25
+ * @returns `{ ok, timeMs, raw }` — `ok: false` with a reason when unavailable.
26
+ */
27
+ export function icmpPing(host, timeoutMs = 3000) {
28
+ return new Promise((resolve) => {
29
+ const windows = process.platform === "win32";
30
+ const argv = windows ? ["-n", "1", "-w", String(timeoutMs), host] : ["-c", "1", "-W", String(Math.ceil(timeoutMs / 1000)), host];
31
+ let stdout = "";
32
+ let settled = false;
33
+ const done = (value) => {
34
+ if (settled) return;
35
+ settled = true;
36
+ resolve(value);
37
+ };
38
+ let child;
39
+ try {
40
+ child = spawn(windows ? "ping.exe" : "ping", argv, { windowsHide: true, stdio: ["ignore", "pipe", "ignore"] });
41
+ } catch (error) {
42
+ done({ ok: false, timeMs: null, raw: String(error?.message ?? error) });
43
+ return;
44
+ }
45
+ const timer = setTimeout(() => {
46
+ try {
47
+ child.kill();
48
+ } catch {
49
+ /* already gone */
50
+ }
51
+ done({ ok: false, timeMs: null, raw: "icmp timeout" });
52
+ }, timeoutMs + 1500);
53
+ child.stdout?.on("data", (chunk) => {
54
+ stdout += chunk.toString("utf8");
55
+ });
56
+ child.once("error", (error) => {
57
+ clearTimeout(timer);
58
+ done({ ok: false, timeMs: null, raw: String(error?.message ?? error) });
59
+ });
60
+ child.once("close", (code) => {
61
+ clearTimeout(timer);
62
+ // Both the English and the localized Windows output carry "<n>ms" / "=1ms".
63
+ const match = /[=<]\s*([\d.]+)\s*ms/i.exec(stdout);
64
+ done({ ok: code === 0, timeMs: match === null ? null : Number(match[1]), raw: stdout.trim().split(/\r?\n/).slice(0, 4).join(" | ") });
65
+ });
66
+ });
67
+ }
68
+
69
+ /** TCP connect probe. @returns `{ ok, timeMs, error }`. */
70
+ export async function tcpPing(host, port = 443, timeoutMs = 3000) {
71
+ const started = Date.now();
72
+ try {
73
+ const socket = await tcpConnect(host, port, timeoutMs);
74
+ const elapsed = Date.now() - started;
75
+ socket.destroy();
76
+ return { ok: true, timeMs: elapsed, error: null };
77
+ } catch (error) {
78
+ return { ok: false, timeMs: null, error: error instanceof Error ? error.message : String(error) };
79
+ }
80
+ }
81
+
82
+ /** GeoIP providers tried in order. Both are keyless. */
83
+ const GEO_PROVIDERS = [
84
+ {
85
+ name: "ip-api.com",
86
+ url: (ip) => `http://ip-api.com/json/${encodeURIComponent(ip)}?fields=status,country,countryCode,isp,query`,
87
+ parse: (json) => (json.status === "success"
88
+ ? { ip: json.query ?? null, country: json.country ?? null, countryCode: (json.countryCode ?? "").toUpperCase() || null, isp: json.isp ?? null }
89
+ : null)
90
+ },
91
+ {
92
+ name: "ipwho.is",
93
+ url: (ip) => `https://ipwho.is/${encodeURIComponent(ip)}`,
94
+ parse: (json) => (json.success === true
95
+ ? { ip: json.ip ?? null, country: json.country ?? null, countryCode: (json.country_code ?? "").toUpperCase() || null, isp: json.connection?.isp ?? null }
96
+ : null)
97
+ }
98
+ ];
99
+
100
+ /**
101
+ * Look up the country of an IP address.
102
+ * @param ip - the address to look up.
103
+ * @param options - optional proxy, timeout, and the provider order to try.
104
+ * @returns `{ country, countryCode, isp, provider }` or null.
105
+ */
106
+ export async function geoLookupIP(ip, options = {}) {
107
+ const { proxy = null, timeoutMs = 6000, providers = GEO_PROVIDERS, signal = null } = options;
108
+ for (const provider of providers) {
109
+ try {
110
+ const result = await openTarget(provider.url(ip), { proxy, timeoutMs, signal });
111
+ if (result.status < 200 || result.status >= 300) {
112
+ result.res.resume();
113
+ result.abort();
114
+ continue;
115
+ }
116
+ const parsed = provider.parse(JSON.parse(await readBody(result, 64 * 1024)));
117
+ if (parsed !== null) return { ...parsed, provider: provider.name };
118
+ } catch {
119
+ // Try the next provider.
120
+ }
121
+ }
122
+ return null;
123
+ }
124
+
125
+ /** True when the host resolves to at least one non-private address. */
126
+ export const __internals = { GEO_PROVIDERS };
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Route decision for one download target.
3
+ *
4
+ * The decision is made **offline** from the rule engine (embedded CN domain
5
+ * suffixes and CN CIDR blocks + the AI-platform protection list + the
6
+ * subscription's own rules), so routing never depends on a reachable GeoIP
7
+ * service. Online lookups live in `net-probe.js` and are used only by the
8
+ * `dsh_geo_check` diagnostic tool.
9
+ *
10
+ * 面向模型与用户的文案一律中文;标识符、配置字段名、协议名保持原样。
11
+ * @module dsh-downloader/route
12
+ */
13
+ import { isHttpUrl, isPrivateIp } from "./util.js";
14
+
15
+ /** Raised when a target must go through a proxy but no usable exit exists. */
16
+ export class NoUpstreamError extends Error {
17
+ constructor(message, hint) {
18
+ super(message);
19
+ this.name = "NoUpstreamError";
20
+ this.hint = hint;
21
+ }
22
+ }
23
+
24
+ /** Extract the hostname from a URL without throwing. */
25
+ export function hostOf(rawUrl) {
26
+ try {
27
+ return new URL(rawUrl).hostname.toLowerCase();
28
+ } catch {
29
+ return "";
30
+ }
31
+ }
32
+
33
+ /**
34
+ * True for hosts that must never be sent to a proxy: loopback, `.local`
35
+ * names, and private/loopback IP literals. The rule tables cannot express
36
+ * "this machine", so the check lives here rather than in `RuleEngine`.
37
+ */
38
+ function isLocalHost(host) {
39
+ if (host.length === 0) return false;
40
+ if (host === "localhost" || host.endsWith(".localhost") || host.endsWith(".local")) return true;
41
+ if (host.startsWith("[") && host.endsWith("]")) {
42
+ const inner = host.slice(1, -1);
43
+ return inner === "::1" || isPrivateIp(inner);
44
+ }
45
+ return isPrivateIp(host);
46
+ }
47
+
48
+ /**
49
+ * Decide whether a target is fetched directly or through the rule core.
50
+ *
51
+ * @param target - the download URL.
52
+ * @param upstream - the resolved upstream (`{ mode, engine, available, description }`).
53
+ * @param forceRoute - `auto` | `direct` | `proxy`.
54
+ * @returns `{ route, reason, host, warning }`.
55
+ * @throws {NoUpstreamError} when the target needs a proxy and none is usable.
56
+ */
57
+ export function decideRoute(target, upstream, forceRoute = "auto") {
58
+ if (!isHttpUrl(target)) throw new Error(`不是 http/https 下载地址:${target}`);
59
+ const host = hostOf(target);
60
+ const forced = String(forceRoute ?? "auto").toLowerCase();
61
+
62
+ if (forced === "direct") {
63
+ return { route: "direct", reason: "调用方指定直连", host, warning: null };
64
+ }
65
+ if (forced === "proxy") {
66
+ if (upstream.available !== true) {
67
+ throw new NoUpstreamError(
68
+ `调用方指定走代理,但没有可用的代理出口(${upstream.reason})`,
69
+ "请在「设置 → 下载代理」里填写订阅地址或 proxyUrl。"
70
+ );
71
+ }
72
+ if (isLocalHost(host)) {
73
+ return { route: "direct", reason: `${host} 是本机或私有地址,回环流量永不经过代理`, host, warning: null };
74
+ }
75
+ return { route: "proxy", reason: "调用方指定走代理", host, warning: null };
76
+ }
77
+
78
+ if (isLocalHost(host)) {
79
+ return { route: "direct", reason: `${host} 是本机或私有地址`, host, warning: null };
80
+ }
81
+
82
+ if (upstream.engine === null || upstream.engine === undefined) {
83
+ return {
84
+ route: "direct",
85
+ reason: upstream.reason ?? "未配置代理,直接下载",
86
+ host,
87
+ warning: "未配置代理,所有目标都会直接下载"
88
+ };
89
+ }
90
+
91
+ const decision = upstream.engine.decide(host);
92
+ if (decision === "reject") throw new Error(`当前规则拒绝访问该主机:${host}`);
93
+ if (decision === "direct") {
94
+ return { route: "direct", reason: `当前规则判定 ${host} 直连`, host, warning: null };
95
+ }
96
+ if (upstream.available !== true) {
97
+ throw new NoUpstreamError(
98
+ `当前规则要求 ${host} 走代理,但没有可用节点(${upstream.reason})`,
99
+ "请在「设置 → 下载代理」检查订阅,或传 force_route=\"direct\" 绕过。"
100
+ );
101
+ }
102
+ return { route: "proxy", reason: `当前规则判定 ${host} 走代理`, host, warning: null };
103
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Segmented (multi-connection) download.
3
+ *
4
+ * Modelled on gopeed's `internal/protocol/http/fetcher.go`: instead of splitting
5
+ * the file into fixed equal parts, every connection owns a chunk and, when it
6
+ * finishes, **steals the tail half of the chunk with the most work left**. That
7
+ * is what keeps a slow server from holding up the whole transfer — a static
8
+ * split is only as fast as its slowest connection.
9
+ *
10
+ * Everything here is opt-in: the caller falls back to a single stream whenever
11
+ * the server does not advertise byte ranges, the resource is too small for
12
+ * threads to pay off, or a range response turns out to be unusable.
13
+ * @module dsh-downloader/segmented
14
+ */
15
+ import fs from "node:fs";
16
+ import { createHash } from "node:crypto";
17
+ import { httpFlow } from "./http.js";
18
+ import { humanSize } from "./util.js";
19
+
20
+ /** Smallest slice worth a separate request (gopeed's `stealMinChunkSize`). */
21
+ export const STEAL_MIN_BYTES = 512 * 1024;
22
+
23
+ /** Below this, one connection is faster than the coordination overhead. */
24
+ export const SEGMENT_MIN_TOTAL = 1024 * 1024;
25
+
26
+ /** Raised when the segmented attempt cannot proceed and the caller must retry single-stream. */
27
+ export class RangeUnusableError extends Error {
28
+ constructor(message) {
29
+ super(message);
30
+ this.name = "RangeUnusableError";
31
+ }
32
+ }
33
+
34
+ /** True when the response advertises byte-range support. */
35
+ export function supportsRanges(status, headers) {
36
+ if (status === 206) return true;
37
+ const accept = String(headers?.["accept-ranges"] ?? "").toLowerCase();
38
+ return accept.includes("bytes");
39
+ }
40
+
41
+ /**
42
+ * Parse a `Content-Range: bytes start-end/total` header.
43
+ * @returns `{ start, end, total }`; `total` is null for `*`.
44
+ */
45
+ export function parseContentRange(value) {
46
+ const match = /^bytes\s+(\d+)-(\d+)\/(\d+|\*)$/i.exec(String(value ?? "").trim());
47
+ if (match === null) return null;
48
+ return {
49
+ start: Number.parseInt(match[1], 10),
50
+ end: Number.parseInt(match[2], 10),
51
+ total: match[3] === "*" ? null : Number.parseInt(match[3], 10)
52
+ };
53
+ }
54
+
55
+ /** Split `total` into up to `threads` starting chunks. */
56
+ export function planChunks(total, threads) {
57
+ const count = Math.max(1, Math.min(Number(threads) || 1, Math.max(1, Math.floor(total / STEAL_MIN_BYTES))));
58
+ const size = Math.ceil(total / count);
59
+ const chunks = [];
60
+ for (let begin = 0; begin < total; begin += size) {
61
+ chunks.push({ begin, end: Math.min(begin + size, total) - 1 });
62
+ }
63
+ return chunks;
64
+ }
65
+
66
+ /**
67
+ * Take the next slice of work.
68
+ *
69
+ * Picks the chunk with the most bytes left and cuts **half of it** off, so a
70
+ * worker that just finished keeps pulling its weight while a slow one still
71
+ * makes progress. A chunk smaller than two steal-minimums is handed over whole.
72
+ * @returns `{ begin, end }` or null when everything is claimed.
73
+ */
74
+ export function stealSlice(chunks) {
75
+ let best = null;
76
+ for (const chunk of chunks) {
77
+ const remain = chunk.end - chunk.begin + 1;
78
+ if (remain <= 0) continue;
79
+ if (best === null || remain > best.end - best.begin + 1) best = chunk;
80
+ }
81
+ if (best === null) return null;
82
+ const remain = best.end - best.begin + 1;
83
+ const take = remain >= 2 * STEAL_MIN_BYTES ? Math.floor(remain / 2) : remain;
84
+ const slice = { begin: best.begin, end: best.begin + take - 1 };
85
+ best.begin += take;
86
+ return slice;
87
+ }
88
+
89
+ /** Bytes still unclaimed across every chunk. */
90
+ export function remainingBytes(chunks) {
91
+ let total = 0;
92
+ for (const chunk of chunks) total += Math.max(0, chunk.end - chunk.begin + 1);
93
+ return total;
94
+ }
95
+
96
+ /**
97
+ * Write a whole buffer at an absolute offset.
98
+ *
99
+ * `fs.promises.FileHandle.write` is promise-only — passing it a callback the way
100
+ * `fs.write` takes one silently ignores the callback and the promise never
101
+ * settles (which hangs the whole download).
102
+ */
103
+ async function writeAt(handle, buffer, offset) {
104
+ const { bytesWritten } = await handle.write(buffer, 0, buffer.length, offset);
105
+ if (bytesWritten !== buffer.length) {
106
+ throw new Error(`写入不完整:偏移 ${offset} 期望 ${buffer.length} 字节,实际 ${bytesWritten}`);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Fetch one slice into the file at its absolute offset.
112
+ * @throws {RangeUnusableError} when the response is not the requested 206.
113
+ */
114
+ async function fetchSlice(slice, handle, context) {
115
+ const { url, proxy, insecure, maxRedirects, validator, timeoutMs, controller, bump, headers } = context;
116
+ const requestHeaders = { ...(headers ?? {}), range: `bytes=${slice.begin}-${slice.end}` };
117
+ if (validator !== null) requestHeaders["if-range"] = validator;
118
+
119
+ const flow = await httpFlow(url, {
120
+ proxy,
121
+ headers: requestHeaders,
122
+ insecure,
123
+ maxRedirects,
124
+ timeoutMs,
125
+ signal: controller.signal
126
+ });
127
+ if (flow.status !== 206) {
128
+ try {
129
+ flow.res.resume();
130
+ flow.abort();
131
+ } catch {
132
+ /* already gone */
133
+ }
134
+ throw new RangeUnusableError(`服务端对 Range 请求返回了 HTTP ${flow.status}`);
135
+ }
136
+ const range = parseContentRange(flow.headers["content-range"]);
137
+ if (range === null) {
138
+ flow.res.resume();
139
+ flow.abort();
140
+ throw new RangeUnusableError("Range 响应缺少合法的 Content-Range");
141
+ }
142
+ if (range.start !== slice.begin) {
143
+ flow.res.resume();
144
+ flow.abort();
145
+ throw new RangeUnusableError(`Range 响应起点不符:请求 ${slice.begin},返回 ${range.start}`);
146
+ }
147
+
148
+ let offset = slice.begin;
149
+ let pending = Promise.resolve();
150
+ await new Promise((resolve, reject) => {
151
+ let settled = false;
152
+ const fail = (error) => {
153
+ if (settled) return;
154
+ settled = true;
155
+ try {
156
+ flow.res.destroy();
157
+ } catch {
158
+ /* already gone */
159
+ }
160
+ reject(error);
161
+ };
162
+ flow.res.on("data", (chunk) => {
163
+ if (settled) return;
164
+ // Positional writes let every worker share one file descriptor.
165
+ pending = pending.then(() => {
166
+ if (settled) return undefined;
167
+ return writeAt(handle, chunk, offset).then(() => {
168
+ offset += chunk.length;
169
+ bump(chunk.length);
170
+ });
171
+ }).catch(fail);
172
+ });
173
+ flow.res.once("end", () => {
174
+ pending.then(() => {
175
+ if (settled) return;
176
+ settled = true;
177
+ resolve();
178
+ }, fail);
179
+ });
180
+ flow.res.once("error", fail);
181
+ });
182
+ return offset - slice.begin;
183
+ }
184
+
185
+ /**
186
+ * Download `url` into `handle` using several connections.
187
+ *
188
+ * @param options - target, an open file handle, size, thread count and the same
189
+ * transport knobs the single-stream path takes.
190
+ * @returns `{ bytes, sha256, segments }`.
191
+ * @throws {RangeUnusableError} when the segmented path cannot be used.
192
+ */
193
+ export async function downloadSegmented(options) {
194
+ const {
195
+ url,
196
+ handle,
197
+ totalBytes,
198
+ threads = 4,
199
+ proxy = null,
200
+ insecure = false,
201
+ maxRedirects = 10,
202
+ timeoutMs = 20000,
203
+ stallMs = 30000,
204
+ maxBytes = 512 * 1024 * 1024,
205
+ signal = null,
206
+ validator = null,
207
+ headers = null,
208
+ onProgress = null
209
+ } = options;
210
+
211
+ if (totalBytes > maxBytes) {
212
+ throw new Error(`服务端声明大小 ${humanSize(totalBytes)},超过配置上限(${humanSize(maxBytes)});提高 max_mb 后重试`);
213
+ }
214
+
215
+ const chunks = planChunks(totalBytes, threads);
216
+ const controller = new AbortController();
217
+ const forwardAbort = () => controller.abort(new Error("下载已取消"));
218
+ if (signal !== null && signal !== undefined) {
219
+ if (signal.aborted) throw new Error("下载已取消");
220
+ signal.addEventListener("abort", forwardAbort, { once: true });
221
+ }
222
+
223
+ let written = 0;
224
+ let lastTick = Date.now();
225
+ let failure = null;
226
+ const context = {
227
+ url, proxy, insecure, maxRedirects, validator, timeoutMs, controller, headers,
228
+ bump: (bytes) => {
229
+ written += bytes;
230
+ lastTick = Date.now();
231
+ onProgress?.(written);
232
+ }
233
+ };
234
+
235
+ const stallWatch = setInterval(() => {
236
+ if (failure === null && Date.now() - lastTick > stallMs) {
237
+ failure = new Error(`下载停滞:已 ${Math.round(stallMs / 1000)} 秒没有收到数据`);
238
+ controller.abort(failure);
239
+ }
240
+ }, 1000);
241
+
242
+ try {
243
+ const workerCount = Math.max(1, Math.min(Number(threads) || 1, chunks.length));
244
+ const workers = [];
245
+ for (let index = 0; index < workerCount; index += 1) {
246
+ workers.push((async () => {
247
+ for (;;) {
248
+ if (failure !== null) return;
249
+ const slice = stealSlice(chunks);
250
+ if (slice === null) return;
251
+ await fetchSlice(slice, handle, context);
252
+ }
253
+ })());
254
+ }
255
+ await Promise.all(workers);
256
+ if (failure !== null) throw failure;
257
+
258
+ const shortfall = totalBytes - written;
259
+ if (shortfall !== 0) throw new RangeUnusableError(`分片合计 ${written} 字节,与声明大小 ${totalBytes} 不符`);
260
+ } catch (error) {
261
+ if (failure !== null) throw failure;
262
+ throw error;
263
+ } finally {
264
+ clearInterval(stallWatch);
265
+ signal?.removeEventListener("abort", forwardAbort);
266
+ }
267
+
268
+ // Hash after the merge: sha256 must cover the file in order, and the workers
269
+ // write out of order.
270
+ const hash = createHash("sha256");
271
+ await new Promise((resolve, reject) => {
272
+ const reader = handle.createReadStream({ autoClose: false, start: 0 });
273
+ reader.on("data", (chunk) => hash.update(chunk));
274
+ reader.once("error", reject);
275
+ reader.once("end", resolve);
276
+ });
277
+
278
+ return { bytes: written, sha256: hash.digest("hex"), segments: chunks.length };
279
+ }
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Filename / size / address helpers shared by the download pipeline.
3
+ * @module dsh-downloader/util
4
+ */
5
+ import net from "node:net";
6
+
7
+ /** Render a byte count as a short human string. */
8
+ export function humanSize(n) {
9
+ const units = ["B", "KB", "MB", "GB", "TB"];
10
+ let value = Number(n);
11
+ let unit = 0;
12
+ while (value >= 1024 && unit < units.length - 1) {
13
+ value /= 1024;
14
+ unit += 1;
15
+ }
16
+ return `${unit === 0 ? value : value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${units[unit]}`;
17
+ }
18
+
19
+ /** True for loopback / link-local / private / CGNAT addresses (v4 and v6). */
20
+ export function isPrivateIp(ip) {
21
+ const family = net.isIP(ip);
22
+ if (family === 4) {
23
+ const parts = ip.split(".").map(Number);
24
+ if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false;
25
+ const [a, b] = parts;
26
+ if (a === 0 || a === 10 || a === 127) return true;
27
+ if (a === 169 && b === 254) return true;
28
+ if (a === 172 && b >= 16 && b <= 31) return true;
29
+ if (a === 192 && b === 168) return true;
30
+ if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT 100.64/10
31
+ if (a >= 224) return true; // multicast + reserved
32
+ return false;
33
+ }
34
+ if (family === 6) {
35
+ const host = ip.toLowerCase().split("%")[0];
36
+ if (host === "::" || host === "::1") return true;
37
+ if (host.startsWith("fe80") || host.startsWith("fec0")) return true; // link-local
38
+ if (/^f[cd]/.test(host)) return true; // unique local fc00::/7
39
+ if (host.startsWith("::ffff:")) return isPrivateIp(host.slice(7));
40
+ return false;
41
+ }
42
+ return false;
43
+ }
44
+
45
+ /** Strip path separators, characters Windows rejects, and control characters. */
46
+ export function sanitizeFilename(name) {
47
+ const cleaned = String(name ?? "")
48
+ .replace(/[\\/]+/g, "_")
49
+ .replace(/[<>:"|?*]+/g, "_")
50
+ // eslint-disable-next-line no-control-regex
51
+ .replace(/[\u0000-\u001f\u007f]/g, "")
52
+ .replace(/^\.+$/, "")
53
+ .trim();
54
+ return cleaned.length > 0 ? cleaned.slice(0, 180) : "";
55
+ }
56
+
57
+ /** Best-effort file name from a URL path, or "" when the URL has none. */
58
+ export function filenameFromUrl(rawUrl) {
59
+ try {
60
+ const url = new URL(rawUrl);
61
+ const last = url.pathname.split("/").filter(Boolean).pop() ?? "";
62
+ return sanitizeFilename(decodeURIComponent(last));
63
+ } catch {
64
+ return "";
65
+ }
66
+ }
67
+
68
+ /** File name from a Content-Disposition header, or "" when absent/unusable. */
69
+ export function filenameFromDisposition(header) {
70
+ if (typeof header !== "string" || header.length === 0) return "";
71
+ const extended = /filename\*\s*=\s*([^;]+)/i.exec(header);
72
+ if (extended !== null) {
73
+ const value = extended[1].trim().replace(/^["']|["']$/g, "");
74
+ const withoutCharset = value.includes("''") ? value.slice(value.indexOf("''") + 2) : value;
75
+ try {
76
+ return sanitizeFilename(decodeURIComponent(withoutCharset));
77
+ } catch {
78
+ return sanitizeFilename(withoutCharset);
79
+ }
80
+ }
81
+ const plain = /filename\s*=\s*("([^"]*)"|([^;]+))/i.exec(header);
82
+ if (plain === null) return "";
83
+ return sanitizeFilename((plain[2] ?? plain[3] ?? "").trim());
84
+ }
85
+
86
+ /** Decide the file name to save under: Content-Disposition first, then the URL. */
87
+ export function pickFilename(rawUrl, headers) {
88
+ const fromHeader = filenameFromDisposition(headers?.["content-disposition"]);
89
+ if (fromHeader.length > 0) return fromHeader;
90
+ const fromUrl = filenameFromUrl(rawUrl);
91
+ if (fromUrl.length > 0) return fromUrl;
92
+ return "download.bin";
93
+ }
94
+
95
+ /** True when the URL scheme is one this plugin downloads. */
96
+ export function isHttpUrl(value) {
97
+ try {
98
+ const url = new URL(value);
99
+ return url.protocol === "http:" || url.protocol === "https:";
100
+ } catch {
101
+ return false;
102
+ }
103
+ }