8 Commits

Author SHA1 Message Date
debont80 4421627a64 chore: bump version to 0.7.4 2026-07-16 14:03:15 -04:00
debont80 44230c757e feat: retarget the auto-updater from Gitea to GitHub
The source repo moved to github.com/debont80/AeroFetch; the in-app
updater and release links still pointed at the old self-hosted Gitea
instance. Repoint RELEASE_API at the GitHub REST API, replace the
single-host download trust check with an allowlist (github.com plus
its release-asset CDN hosts, which GitHub 302s downloads through —
mirrors the existing FFMPEG_TRUSTED_HOSTS pattern), and add the
User-Agent header GitHub's API requires.

No bridge: installs on a Gitea-pointed build (<=0.7.3) won't
auto-discover releases published to GitHub and need one manual
download, matching the precedent set by the earlier releases-repo
migration.
2026-07-16 12:47:27 -04:00
debont80 79ab94248d chore: stop tracking ffmpeg/ffprobe dev-seed binaries
They're >100MB each, redundant with the app's existing first-run download
(ffmpegSetup.ts), and blocked publishing the repo to GitHub (100MB file
limit). Gitignore them instead; npm run dev falls back to that download
when the seed is absent. Working-tree copies are kept locally for dev.
2026-07-16 12:00:21 -04:00
debont80 09fde16004 chore: bump version to 0.7.3
Refs the MIT licensing change (also aligns the lockfile's cached license
field with package.json, which was still UNLICENSED).
2026-07-10 16:40:01 -04:00
debont80 614f56086f Merge pull request 'chore: license the project under MIT' (#15) from chore/add-mit-license into main 2026-07-10 16:35:13 -04:00
debont80 e8f0b83a7e chore: license the project under MIT
Replace the placeholder "UNLICENSED" with the MIT License so the now-public
repo is genuinely free and open source. Adds a LICENSE file and documents the
app-vs-bundled-binary licensing split in the README (yt-dlp/FFmpeg/aria2 are
invoked as separate executables, so they remain under their own licenses).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:09:52 -04:00
debont80 a7edde8a32 Merge pull request 'docs: add README with feature overview and screenshots' (#14) from docs/readme-screenshots into main 2026-07-10 13:18:22 -04:00
debont80 4204483792 docs: add README with feature overview and screenshots
Give the now-public repo a proper front page: pitch, grouped feature
list, light/dark screenshots of Downloads/Library/History/Settings,
download + build-from-source instructions, and a note on the bundled
yt-dlp/FFmpeg/aria2 licenses.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:15:59 -04:00
20 changed files with 208 additions and 54 deletions
+5
View File
@@ -8,6 +8,11 @@ dist
# Local screen-capture frames (not source)
.frames/
# Dev-seed only — too large for GitHub (>100MB); app downloads on first run
# (src/main/ffmpegSetup.ts). See resources/bin/README.md to seed them manually.
resources/bin/ffmpeg.exe
resources/bin/ffprobe.exe
# Secrets — never commit
.gitea-token
# Retired: the baked update token was removed from the build and its service
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Devon Bontrager
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+91
View File
@@ -0,0 +1,91 @@
<div align="center">
# AeroFetch
**A clean, native-feeling video &amp; audio downloader for Windows — a friendly front-end for [yt-dlp](https://github.com/yt-dlp/yt-dlp).**
Paste a link, pick a quality, and go. No command line, no Python setup, no chasing down FFmpeg — everything is bundled.
![AeroFetch downloads screen](docs/screenshots/downloads-dark.png)
</div>
---
## What it is
AeroFetch wraps the power of yt-dlp in a modern Windows desktop app. It handles single videos, whole playlists, and watched channels; it can grab audio-only, embed thumbnails and metadata, trim clips, skip sponsor segments, and keep a library of channels in sync — all from a point-and-click interface built with Fluent UI.
It ships as a **regular installer** *and* a **single portable `.exe`** (no admin rights required), with `yt-dlp`, `FFmpeg`, and `aria2c` bundled in — so it runs on a fresh machine with nothing else installed.
## Features
- **One-paste downloads** — drop in a video or playlist URL and press Enter. Clipboard detection can offer the link automatically.
- **Format &amp; quality control** — pick a resolution/codec (H.264 / VP9 / AV1), or extract audio to MP3, M4A, Opus, FLAC, WAV, or AAC.
- **Playlists &amp; channels** — select which videos to grab, or add a channel to your **Library** and let AeroFetch watch it for new uploads.
- **Post-processing** — embed thumbnails, metadata, chapters, and subtitles; **SponsorBlock** to skip or mark sponsor segments; trim to a start/end timestamp.
- **Queue that behaves** — concurrent downloads with a configurable cap, pause/resume, cancel, retry-all, and live speed/ETA.
- **Library &amp; sync** — organized per-source folders, scheduled daily sync, retention rules, and quality-upgrade selectors.
- **History** — searchable, filterable record of everything you've downloaded, with quick open-file / show-in-folder / re-download.
- **Fast &amp; convenient** — `aria2c` acceleration, a `Ctrl+K` command palette, light / dark / auto themes, and native Windows notifications.
- **Portable-friendly** — runs from a USB stick or Downloads folder without installing or needing admin rights.
## Screenshots
| Downloads (light) | Downloads (dark) |
| :---: | :---: |
| ![Downloads, light theme](docs/screenshots/downloads-light.png) | ![Downloads, dark theme](docs/screenshots/downloads-dark.png) |
| Library — watched channels &amp; playlists | History — searchable &amp; filterable |
| :---: | :---: |
| ![Library](docs/screenshots/library-dark.png) | ![History](docs/screenshots/history-dark.png) |
<div align="center">
**Settings — grouped &amp; searchable**
![Settings](docs/screenshots/settings-light.png)
</div>
## Download &amp; install
Grab the latest build from the [**Releases**](https://github.com/debont80/AeroFetch/releases) page:
- **`AeroFetch Setup <version>.exe`** — standard installer. Installs per-user (no admin), adds a Start-menu shortcut, and auto-updates.
- **`AeroFetch-<version>-portable.exe`** — single self-contained executable. No install, no admin; keeps its data in an `AeroFetch-data` folder beside the `.exe`. Ideal for locked-down or shared PCs.
Each release also publishes a `.sha256` next to every executable so you can verify the download.
> Because the executables aren't code-signed, Windows SmartScreen may warn on first launch — choose **More info → Run anyway**. No administrator rights are required either way.
## Build from source
Requires **Node.js 24.x**.
```bash
git clone https://github.com/debont80/AeroFetch.git
cd AeroFetch
npm install
npm run dev # launch the app in development
npm run ui # preview just the renderer in a browser (http://localhost:5173)
npm test # run the test suite
npm run build:win # produce the installer + portable .exe in dist/
```
The bundled `yt-dlp.exe`, `ffmpeg.exe`, `ffprobe.exe`, and `aria2c.exe` live in `resources/bin/` and are packaged automatically.
## Tech stack
Built with **Electron** + **electron-vite** + **electron-builder**, a **React 19** / **TypeScript** renderer, **Fluent UI**, **Zustand** for state, and **Zod** for boundary validation. yt-dlp runs only in the main process; the renderer talks to it over a typed IPC bridge with context isolation enabled.
## A note on responsible use
AeroFetch is a tool for downloading media you have the right to download — your own uploads, content offered for download, or material whose license permits it. Respect the terms of service of the sites you use and the rights of content creators.
## License
AeroFetch's own source code is released under the [**MIT License**](LICENSE) — free to use, modify, and redistribute.
It also bundles third-party binaries that remain under their own licenses: **yt-dlp** (Unlicense), **FFmpeg** (LGPL/GPL), and **aria2** (GPL). AeroFetch invokes these as separate executables rather than linking against them, so they are distributed as an aggregate; their licenses apply to the respective files in `resources/bin/`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+3 -3
View File
@@ -1,13 +1,13 @@
{
"name": "aerofetch",
"version": "0.7.2",
"version": "0.7.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "aerofetch",
"version": "0.7.2",
"license": "UNLICENSED",
"version": "0.7.4",
"license": "MIT",
"dependencies": {
"@electron-toolkit/utils": "^4.0.0",
"@fluentui/react-components": "^9.74.1",
+2 -2
View File
@@ -1,10 +1,10 @@
{
"name": "aerofetch",
"version": "0.7.2",
"version": "0.7.4",
"description": "A yt-dlp frontend for Windows",
"main": "./out/main/index.js",
"author": "AeroFetch",
"license": "UNLICENSED",
"license": "MIT",
"homepage": "https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch",
"repository": {
"type": "git",
+7 -5
View File
@@ -6,15 +6,17 @@ asar archive) and are committed here so a fresh clone builds and runs.
`ffmpeg.exe` + `ffprobe.exe` are **NOT shipped in the installer** — they're the bulk of
its size, so they're downloaded on first run into `userData/bin` (see
`src/main/ffmpegSetup.ts`, pinned in `src/main/config.ts`). The copies here are used only
as a **dev seed**`ensureManagedFfmpeg` copies them into `userData/bin` so `npm run dev`
needs no download — and are excluded from the shipped build.
`src/main/ffmpegSetup.ts`, pinned in `src/main/config.ts`). Dropping copies here is an
**optional dev seed**`ensureManagedFfmpeg` copies them into `userData/bin` so
`npm run dev` needs no download — but at ~107MB each (over GitHub's 100MB file limit)
they're **gitignored, not committed**. Without the seed, `npm run dev` just triggers the
same first-run download the shipped app uses.
```
resources/bin/
├── yt-dlp.exe (bundled + committed)
├── ffmpeg.exe (dev seed only — excluded from the installer)
├── ffprobe.exe (dev seed only — excluded from the installer)
├── ffmpeg.exe (optional dev seed — gitignored, excluded from the installer)
├── ffprobe.exe (optional dev seed — gitignored, excluded from the installer)
└── aria2c.exe (optional, bundled)
```
Binary file not shown.
Binary file not shown.
+14 -6
View File
@@ -2,19 +2,27 @@
* Build/host configuration (CC11): the deploy-specific constants that identify
* WHERE this build talks to, separated from runtime tunables (constants.ts —
* timeouts, caps, tickers) and from user settings (settings.ts). Retarget a
* fork's update source by editing these three values.
* fork's update source by editing these values.
*
* The update source is the main AeroFetch repo itself, made PUBLIC — so anonymous
* reads work and the updater sends no auth at all; there is no secret in the bundle
* to leak. (A prior design split releases into a separate releases-only repo so the
* source could stay private; that's retired now that the source repo is public
* itself — one fewer moving part.) A private fork retargets its updater by editing
* to leak. (Earlier designs split releases into a separate releases-only repo, then
* moved the whole source repo off a self-hosted Gitea to GitHub — each retirement
* left one fewer moving part.) A private fork retargets its updater by editing
* these constants and rebuilding.
*/
export const UPDATE_HOST = 'https://gitea.netbird.zimspace.uk:5938'
export const UPDATE_OWNER = 'debont80'
export const UPDATE_REPO = 'AeroFetch'
export const RELEASE_API = `${UPDATE_HOST}/api/v1/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`
export const UPDATE_API_HOST = 'https://api.github.com'
export const RELEASE_API = `${UPDATE_API_HOST}/repos/${UPDATE_OWNER}/${UPDATE_REPO}/releases/latest`
// Hosts a release asset download may touch, HTTPS-only, re-checked on EVERY redirect
// hop (mirrors FFMPEG_TRUSTED_HOSTS below): github.com serves browser_download_url,
// which GitHub 302s to one of these asset-storage CDN hosts.
export const UPDATE_TRUSTED_HOSTS = [
'github.com',
'release-assets.githubusercontent.com',
'objects.githubusercontent.com'
] as const
// --- ffmpeg dependency source ------------------------------------------------
// ffmpeg/ffprobe are NOT bundled in the installer (they're the bulk of its size);
+20 -15
View File
@@ -1,25 +1,25 @@
import { app, net, shell, type WebContents } from 'electron'
import { stat } from 'fs/promises'
import { join, normalize, dirname } from 'path'
import { UPDATE_HOST, RELEASE_API } from './config'
import { UPDATE_TRUSTED_HOSTS, RELEASE_API } from './config'
import { streamVerifiedFile } from './lib/verifiedDownload'
import { IpcChannels, type AppUpdateInfo, type AppUpdateDownload } from '@shared/ipc'
// --- Update source -----------------------------------------------------------
// The Gitea repo whose Releases host the AeroFetch installers lives in
// The GitHub repo whose Releases host the AeroFetch installers lives in
// config.ts (CC11) with the other build/host constants. The updater reads the
// repo's latest release over the public REST API and downloads the installer
// asset attached to it.
// Security: only ever download or execute a file served by the trusted update
// host over HTTPS. downloadUrl comes from the release JSON, and Gitea 302-redirects
// release downloads to its attachment store — so this is re-checked on EVERY
// Security: only ever download or execute a file served by an allowlisted update
// host over HTTPS. downloadUrl comes from the release JSON, and GitHub 302-redirects
// release downloads to an asset-storage CDN host — so this is re-checked on EVERY
// redirect hop (see downloadAppUpdate), which is what stops a tampered/MITM'd
// response from bouncing us off the trusted host.
// response from bouncing us off the trusted hosts.
export function isTrustedDownloadUrl(url: string): boolean {
try {
const u = new URL(url)
return u.protocol === 'https:' && u.host === new URL(UPDATE_HOST).host
return u.protocol === 'https:' && (UPDATE_TRUSTED_HOSTS as readonly string[]).includes(u.host)
} catch {
return false
}
@@ -60,15 +60,15 @@ export function compareVersions(a: string, b: string): number {
return 0
}
interface GiteaAsset {
interface GithubAsset {
name: string
browser_download_url: string
}
interface GiteaRelease {
interface GithubRelease {
tag_name: string
body?: string
html_url?: string
assets?: GiteaAsset[]
assets?: GithubAsset[]
}
/**
@@ -102,7 +102,7 @@ export function extractSha256(text: string, fileName?: string): string | null {
}
/** Pick the Windows installer asset — prefer the NSIS "Setup" .exe, else any .exe. */
function pickInstaller(assets: GiteaAsset[]): GiteaAsset | undefined {
function pickInstaller(assets: GithubAsset[]): GithubAsset | undefined {
const exes = assets.filter((a) => /\.exe$/i.test(a.name))
return exes.find((a) => /setup/i.test(a.name)) ?? exes[0]
}
@@ -116,7 +116,12 @@ export async function checkForAppUpdate(): Promise<AppUpdateInfo> {
const timer = setTimeout(() => controller.abort(), 15_000)
try {
const res = await fetch(RELEASE_API, {
headers: { Accept: 'application/json' },
headers: {
Accept: 'application/vnd.github+json',
// GitHub's REST API rejects requests with no User-Agent (403).
'User-Agent': 'AeroFetch-Updater',
'X-GitHub-Api-Version': '2022-11-28'
},
// The API lives at an exact pinned URL and answers 200 directly. Refusing
// redirects keeps the check pinned to that origin; a stray redirect (e.g. to
// a sign-in page) fails safe into the catch below rather than being followed.
@@ -130,11 +135,11 @@ export async function checkForAppUpdate(): Promise<AppUpdateInfo> {
available: false,
currentVersion,
error: auth
? 'Could not reach the update server (it may require sign-in for anonymous access).'
? 'Could not reach the update server (rate-limited, or the repo/release is unavailable).'
: `Update check failed (HTTP ${res.status}).`
}
}
const rel = (await res.json()) as GiteaRelease
const rel = (await res.json()) as GithubRelease
const latestVersion = (rel.tag_name ?? '').replace(/^v/i, '')
const asset = pickInstaller(rel.assets ?? [])
const available = latestVersion !== '' && compareVersions(latestVersion, currentVersion) > 0
@@ -302,7 +307,7 @@ export async function downloadAppUpdate(wc: WebContents, url: string): Promise<A
url,
filePath,
expectedSha,
// Re-check the trusted host on every redirect hop (Gitea 302s to its attachment store).
// Re-check the trusted hosts on every redirect hop (GitHub 302s to its asset CDN).
isTrusted: isTrustedDownloadUrl,
onProgress: (progress) => {
if (!wc.isDestroyed()) wc.send(IpcChannels.appUpdateProgress, progress)
+1 -1
View File
@@ -44,7 +44,7 @@ const api = {
/** AeroFetch's own version string (e.g. '0.3.1'). */
getAppVersion: (): Promise<string> => ipcRenderer.invoke(IpcChannels.appVersion),
/** Check the configured Gitea repo for a newer AeroFetch release. */
/** Check the configured GitHub repo for a newer AeroFetch release. */
checkForAppUpdate: (): Promise<AppUpdateInfo> => ipcRenderer.invoke(IpcChannels.appUpdateCheck),
/** Download the latest release's installer to a temp file. */
+2 -2
View File
@@ -67,8 +67,8 @@ export const mockApi: Api = {
latestVersion: '0.5.0',
notes:
'### Whats new in v0.5.0\n- In-app updater (this screen!)\n- Faster downloads\n- Bug fixes',
htmlUrl: 'https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases',
downloadUrl: 'https://gitea.netbird.zimspace.uk:5938/example/AeroFetch-Setup-0.5.0.exe',
htmlUrl: 'https://github.com/debont80/AeroFetch/releases',
downloadUrl: 'https://github.com/debont80/AeroFetch/releases/download/v0.5.0/AeroFetch-Setup-0.5.0.exe',
assetName: 'AeroFetch-Setup-0.5.0.exe'
}
},
+3 -3
View File
@@ -6,7 +6,7 @@
export const IpcChannels = {
/** the AeroFetch app version (package.json / app.getVersion) */
appVersion: 'app:version',
/** check the configured Gitea repo for a newer AeroFetch release */
/** check the configured GitHub repo for a newer AeroFetch release */
appUpdateCheck: 'app:update-check',
/** download the latest release's installer to a temp file */
appUpdateDownload: 'app:update-download',
@@ -328,7 +328,7 @@ export interface FfmpegSetupResult {
error?: string
}
/** Result of checking the configured Gitea repo for a newer AeroFetch release. */
/** Result of checking the configured GitHub repo for a newer AeroFetch release. */
export interface AppUpdateInfo {
ok: boolean
/** true when latestVersion is newer than the running app */
@@ -339,7 +339,7 @@ export interface AppUpdateInfo {
latestVersion?: string
/** the release notes / changelog body (Markdown), shown before updating */
notes?: string
/** the release's web page on Gitea (for "View release") */
/** the release's web page on GitHub (for "View release") */
htmlUrl?: string
/** direct download URL of the Windows installer asset, when the release has one */
downloadUrl?: string
+22 -16
View File
@@ -69,35 +69,41 @@ describe('isAllowedLoginUrl (audit T4)', () => {
})
})
// --- app-updater: isTrustedDownloadUrl — host pin across redirect hops -------
// --- app-updater: isTrustedDownloadUrl — host allowlist across redirect hops -
describe('isTrustedDownloadUrl (app-updater host pin)', () => {
const onHost =
'https://gitea.netbird.zimspace.uk:5938/debont80/AeroFetch/releases/download/v1/AeroFetch-Setup.exe'
describe('isTrustedDownloadUrl (app-updater host allowlist)', () => {
const onHost = 'https://github.com/debont80/AeroFetch/releases/download/v1/AeroFetch-Setup.exe'
const onCdnHost =
'https://release-assets.githubusercontent.com/github-production-release-asset/123/abc'
it('accepts https URLs on the exact update host (incl. port)', () => {
it('accepts https URLs on github.com and its asset CDN hosts', () => {
expect(isTrustedDownloadUrl(onHost)).toBe(true)
expect(isTrustedDownloadUrl(onCdnHost)).toBe(true)
expect(
isTrustedDownloadUrl(
'https://objects.githubusercontent.com/github-production-release-asset/x'
)
).toBe(true)
})
it('rejects a different host — the redirect/MITM bounce vector', () => {
expect(isTrustedDownloadUrl('https://evil.example/AeroFetch-Setup.exe')).toBe(false)
})
it('rejects the right hostname on the wrong port', () => {
// host comparison includes the port, so :443 (default) is not the same origin
expect(isTrustedDownloadUrl('https://gitea.netbird.zimspace.uk/AeroFetch-Setup.exe')).toBe(
false
)
it('rejects a lookalike subdomain', () => {
expect(isTrustedDownloadUrl('https://github.com.evil.example/x.exe')).toBe(false)
})
it('rejects plain http even on the update host', () => {
expect(isTrustedDownloadUrl('http://gitea.netbird.zimspace.uk:5938/x.exe')).toBe(false)
it('rejects a trusted hostname on an unexpected port', () => {
expect(isTrustedDownloadUrl('https://github.com:1234/x.exe')).toBe(false)
})
it('is not fooled by the trusted host placed in the userinfo', () => {
expect(isTrustedDownloadUrl('https://gitea.netbird.zimspace.uk:5938@evil.example/x.exe')).toBe(
false
)
it('rejects plain http even on a trusted host', () => {
expect(isTrustedDownloadUrl('http://github.com/x.exe')).toBe(false)
})
it('is not fooled by a trusted host placed in the userinfo', () => {
expect(isTrustedDownloadUrl('https://github.com@evil.example/x.exe')).toBe(false)
})
it('rejects unparseable input', () => {
+17 -1
View File
@@ -73,7 +73,8 @@ import type { WebContents } from 'electron'
// --- helpers ------------------------------------------------------------------
const HOST = 'https://gitea.netbird.zimspace.uk:5938'
const HOST = 'https://github.com'
const CDN_HOST = 'https://release-assets.githubusercontent.com'
const ASSET = `${HOST}/debont80/AeroFetch/releases/download/v9.9.9/AeroFetch-Setup-9.9.9.exe`
const INSTALLER = Buffer.from('FAKE-INSTALLER-BYTES-'.repeat(64))
const GOOD_SHA = createHash('sha256').update(INSTALLER).digest('hex')
@@ -213,6 +214,21 @@ describe('downloadAppUpdate (CL4 behavior pins)', () => {
expect(madeRequests[1]!.redirectsFollowed).toBe(1)
})
it('follows a redirect to the allowlisted asset CDN host (github.com → githubusercontent.com)', async () => {
requestScripts.push(checksumOk(`${GOOD_SHA} AeroFetch-Setup-9.9.9.exe\n`))
requestScripts.push((req) => {
req.emit('redirect', 302, 'GET', `${CDN_HOST}/github-production-release-asset/abc123`)
const res = new FakeResponse(200, { 'content-length': String(INSTALLER.length) })
req.emit('response', res)
res.emit('data', INSTALLER)
res.emit('end')
})
const { wc } = fakeWc()
const r = await downloadAppUpdate(wc, ASSET)
expect(r.ok).toBe(true)
expect(madeRequests[1]!.redirectsFollowed).toBe(1)
})
it('refuses a checksum-fetch redirect off the trusted host', async () => {
requestScripts.push((req) => {
req.emit('redirect', 302, 'GET', 'https://evil.example/x.sha256')