mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-01 07:21:33 -07:00
fix(anime): harden the extension bridge against untrusted repos and hangs
Addresses CodeRabbit review feedback on the Anime Browser: - reject repository package/apk names that are not plain identifiers, and verify the install target resolves inside the extensions directory - count mpv's %n% option escape in UTF-8 bytes, and escape backslashes in header values so a trailing one cannot eat the list separator - key the bridge extension-id cache by APK content, so an in-place upgrade re-uploads instead of running the previous build - bound every bridge, release-listing, and download request with a timeout - enforce the APK size limit while streaming rather than after buffering - read APK bytes on demand instead of holding a base64 copy per extension for the lifetime of the browser - serialize preference mutations and write the file atomically - handle the sidecar spawn error event, and wait for the child to exit in stop() before returning - report a failed Anime Browser bootstrap instead of showing the starting banner forever - keep the preferences panel's save confirmation and in-flight multi-select edits by re-rendering only on a structural schema change
This commit is contained in:
@@ -4,7 +4,12 @@ import { AnimeBridgeClient, BridgeExtensionError } from './bridge-client';
|
|||||||
import { BRIDGE_CONTEXT_KEY } from './types';
|
import { BRIDGE_CONTEXT_KEY } from './types';
|
||||||
|
|
||||||
const EXTENSION_ID = 'a'.repeat(64);
|
const EXTENSION_ID = 'a'.repeat(64);
|
||||||
const source = { apkBase64: 'QVBLLUJZVEVT', sourceId: 'source-1' };
|
const APK_BASE64 = 'QVBLLUJZVEVT';
|
||||||
|
const source = {
|
||||||
|
fingerprint: 'sha-1',
|
||||||
|
loadApkBase64: async () => APK_BASE64,
|
||||||
|
sourceId: 'source-1',
|
||||||
|
};
|
||||||
|
|
||||||
interface Recorded {
|
interface Recorded {
|
||||||
url: string;
|
url: string;
|
||||||
@@ -69,7 +74,7 @@ test('getVideoList posts the APK and episode url with a bridge context preferenc
|
|||||||
assert.equal(calls[0]?.url, 'http://127.0.0.1:9/dalvik');
|
assert.equal(calls[0]?.url, 'http://127.0.0.1:9/dalvik');
|
||||||
assert.equal(calls[0]?.body.method, 'getVideoList');
|
assert.equal(calls[0]?.body.method, 'getVideoList');
|
||||||
assert.deepEqual(calls[0]?.body.episodeData, { url: 'https://origin.example/ep/1' });
|
assert.deepEqual(calls[0]?.body.episodeData, { url: 'https://origin.example/ep/1' });
|
||||||
assert.equal(calls[0]?.body.data, source.apkBase64);
|
assert.equal(calls[0]?.body.data, APK_BASE64);
|
||||||
assert.deepEqual(calls[0]?.body.preferences, [{ key: BRIDGE_CONTEXT_KEY, sourceId: 'source-1' }]);
|
assert.deepEqual(calls[0]?.body.preferences, [{ key: BRIDGE_CONTEXT_KEY, sourceId: 'source-1' }]);
|
||||||
assert.equal(videos.length, 1);
|
assert.equal(videos.length, 1);
|
||||||
});
|
});
|
||||||
@@ -81,12 +86,25 @@ test('a cached extension id replaces the APK upload on later calls', async () =>
|
|||||||
await client.getVideoList(source, 'https://origin.example/ep/1');
|
await client.getVideoList(source, 'https://origin.example/ep/1');
|
||||||
await client.getVideoList(source, 'https://origin.example/ep/2');
|
await client.getVideoList(source, 'https://origin.example/ep/2');
|
||||||
|
|
||||||
assert.equal(calls[0]?.body.data, source.apkBase64);
|
assert.equal(calls[0]?.body.data, APK_BASE64);
|
||||||
assert.equal(calls[0]?.body.extensionId, undefined);
|
assert.equal(calls[0]?.body.extensionId, undefined);
|
||||||
assert.equal(calls[1]?.body.data, undefined);
|
assert.equal(calls[1]?.body.data, undefined);
|
||||||
assert.equal(calls[1]?.body.extensionId, EXTENSION_ID);
|
assert.equal(calls[1]?.body.extensionId, EXTENSION_ID);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('an upgraded APK re-uploads instead of reusing the previous extension id', async () => {
|
||||||
|
const { fetchImpl, calls } = stubFetch(() => jsonResponse([], EXTENSION_ID));
|
||||||
|
const client = new AnimeBridgeClient({ baseUrl: 'http://127.0.0.1:9', fetchImpl });
|
||||||
|
|
||||||
|
await client.getVideoList(source, 'https://origin.example/ep/1');
|
||||||
|
// Same source id, new build in the same file: the id cache must miss.
|
||||||
|
const upgraded = { ...source, fingerprint: 'sha-2', loadApkBase64: async () => 'TkVXLUFQSw==' };
|
||||||
|
await client.getVideoList(upgraded, 'https://origin.example/ep/2');
|
||||||
|
|
||||||
|
assert.equal(calls[1]?.body.extensionId, undefined);
|
||||||
|
assert.equal(calls[1]?.body.data, 'TkVXLUFQSw==');
|
||||||
|
});
|
||||||
|
|
||||||
test('a 409 re-uploads the APK once and succeeds', async () => {
|
test('a 409 re-uploads the APK once and succeeds', async () => {
|
||||||
const { fetchImpl, calls } = stubFetch((call, index) => {
|
const { fetchImpl, calls } = stubFetch((call, index) => {
|
||||||
if (index === 0) return jsonResponse([], EXTENSION_ID);
|
if (index === 0) return jsonResponse([], EXTENSION_ID);
|
||||||
@@ -101,7 +119,7 @@ test('a 409 re-uploads the APK once and succeeds', async () => {
|
|||||||
|
|
||||||
assert.equal(calls.length, 3);
|
assert.equal(calls.length, 3);
|
||||||
assert.equal(calls[1]?.body.extensionId, EXTENSION_ID);
|
assert.equal(calls[1]?.body.extensionId, EXTENSION_ID);
|
||||||
assert.equal(calls[2]?.body.data, source.apkBase64);
|
assert.equal(calls[2]?.body.data, APK_BASE64);
|
||||||
assert.equal(videos.length, 1);
|
assert.equal(videos.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,8 +13,16 @@ const EXTENSION_ID_HEADER = 'x-mangatan-extension-id';
|
|||||||
const EXTENSION_ID_PATTERN = /^[0-9a-f]{64}$/;
|
const EXTENSION_ID_PATTERN = /^[0-9a-f]{64}$/;
|
||||||
|
|
||||||
export interface BridgeSource {
|
export interface BridgeSource {
|
||||||
/** Base64-encoded extension APK. */
|
/**
|
||||||
apkBase64: string;
|
* Identity of the APK's contents. Keys the extension-id cache, so an upgraded
|
||||||
|
* APK is re-uploaded instead of reusing the previous build's id.
|
||||||
|
*/
|
||||||
|
fingerprint: string;
|
||||||
|
/**
|
||||||
|
* Reads and base64-encodes the APK. Called only when the bridge actually
|
||||||
|
* needs the bytes, so multi-megabyte payloads are not held on the heap.
|
||||||
|
*/
|
||||||
|
loadApkBase64: () => Promise<string>;
|
||||||
/** Selects one source inside a multi-source (SourceFactory) APK. */
|
/** Selects one source inside a multi-source (SourceFactory) APK. */
|
||||||
sourceId?: string;
|
sourceId?: string;
|
||||||
preferences?: BridgePreference[];
|
preferences?: BridgePreference[];
|
||||||
@@ -24,8 +32,19 @@ export interface BridgeClientOptions {
|
|||||||
/** Loopback base URL of the running bridge, e.g. `http://127.0.0.1:53112`. */
|
/** Loopback base URL of the running bridge, e.g. `http://127.0.0.1:53112`. */
|
||||||
baseUrl: string;
|
baseUrl: string;
|
||||||
fetchImpl?: typeof fetch;
|
fetchImpl?: typeof fetch;
|
||||||
|
/**
|
||||||
|
* Per-request deadline. Node's `fetch` has none, so a sidecar that accepts
|
||||||
|
* the socket and then stalls would leave every call pending forever.
|
||||||
|
*/
|
||||||
|
requestTimeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Extension calls can be slow (a source may scrape several pages). */
|
||||||
|
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
/** The readiness probe is a local health check; it should answer at once. */
|
||||||
|
const CAPABILITIES_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
/** The bridge reports extension failures as HTTP 200 with an error body. */
|
/** The bridge reports extension failures as HTTP 200 with an error body. */
|
||||||
export class BridgeExtensionError extends Error {
|
export class BridgeExtensionError extends Error {
|
||||||
readonly code?: number;
|
readonly code?: number;
|
||||||
@@ -46,15 +65,19 @@ export class BridgeExtensionError extends Error {
|
|||||||
export class AnimeBridgeClient {
|
export class AnimeBridgeClient {
|
||||||
private readonly baseUrl: string;
|
private readonly baseUrl: string;
|
||||||
private readonly fetchImpl: typeof fetch;
|
private readonly fetchImpl: typeof fetch;
|
||||||
|
private readonly requestTimeoutMs: number;
|
||||||
private readonly extensionIds = new Map<string, string>();
|
private readonly extensionIds = new Map<string, string>();
|
||||||
|
|
||||||
constructor(options: BridgeClientOptions) {
|
constructor(options: BridgeClientOptions) {
|
||||||
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
||||||
this.fetchImpl = options.fetchImpl ?? fetch;
|
this.fetchImpl = options.fetchImpl ?? fetch;
|
||||||
|
this.requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCapabilities(): Promise<BridgeCapabilities> {
|
async getCapabilities(): Promise<BridgeCapabilities> {
|
||||||
const response = await this.fetchImpl(`${this.baseUrl}/capabilities`);
|
const response = await this.fetchImpl(`${this.baseUrl}/capabilities`, {
|
||||||
|
signal: AbortSignal.timeout(Math.min(CAPABILITIES_TIMEOUT_MS, this.requestTimeoutMs)),
|
||||||
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Anime bridge capabilities check failed (${response.status}).`);
|
throw new Error(`Anime bridge capabilities check failed (${response.status}).`);
|
||||||
}
|
}
|
||||||
@@ -153,7 +176,10 @@ export class AnimeBridgeClient {
|
|||||||
extras: Record<string, unknown>,
|
extras: Record<string, unknown>,
|
||||||
changedPreferenceKey?: string,
|
changedPreferenceKey?: string,
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const cacheKey = source.sourceId ?? source.apkBase64;
|
// Keyed by APK contents, not by source id: an in-place upgrade keeps the
|
||||||
|
// same source id, and reusing its cached extension id would silently run
|
||||||
|
// the previous build (the bridge has no reason to answer 409).
|
||||||
|
const cacheKey = `${source.fingerprint}:${source.sourceId ?? ''}`;
|
||||||
const cachedId = this.extensionIds.get(cacheKey);
|
const cachedId = this.extensionIds.get(cacheKey);
|
||||||
|
|
||||||
let response = await this.post(method, extras, source, cachedId, changedPreferenceKey);
|
let response = await this.post(method, extras, source, cachedId, changedPreferenceKey);
|
||||||
@@ -177,7 +203,7 @@ export class AnimeBridgeClient {
|
|||||||
return body;
|
return body;
|
||||||
}
|
}
|
||||||
|
|
||||||
private post(
|
private async post(
|
||||||
method: string,
|
method: string,
|
||||||
extras: Record<string, unknown>,
|
extras: Record<string, unknown>,
|
||||||
source: BridgeSource,
|
source: BridgeSource,
|
||||||
@@ -188,7 +214,7 @@ export class AnimeBridgeClient {
|
|||||||
method,
|
method,
|
||||||
...extras,
|
...extras,
|
||||||
preferences: this.buildPreferences(source, changedPreferenceKey),
|
preferences: this.buildPreferences(source, changedPreferenceKey),
|
||||||
...(extensionId === undefined ? { data: source.apkBase64 } : { extensionId }),
|
...(extensionId === undefined ? { data: await source.loadApkBase64() } : { extensionId }),
|
||||||
};
|
};
|
||||||
|
|
||||||
return this.fetchImpl(`${this.baseUrl}/dalvik`, {
|
return this.fetchImpl(`${this.baseUrl}/dalvik`, {
|
||||||
@@ -198,6 +224,7 @@ export class AnimeBridgeClient {
|
|||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
},
|
},
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
|
signal: AbortSignal.timeout(this.requestTimeoutMs),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,6 +138,49 @@ test('an oversized download is refused even when the length header lies', async
|
|||||||
assert.equal(existsSync(path.join(dir, `${PKG}.apk`)), false);
|
assert.equal(existsSync(path.join(dir, `${PKG}.apk`)), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the byte limit stops the read instead of buffering the whole body', async () => {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||||
|
let pushed = 0;
|
||||||
|
// Endless body: if the limit were only checked after buffering, this hangs.
|
||||||
|
const body = new ReadableStream<Uint8Array>({
|
||||||
|
pull(controller) {
|
||||||
|
pushed += 1;
|
||||||
|
controller.enqueue(new Uint8Array(512));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fetchImpl = (async () => new Response(body, { status: 200 })) as typeof fetch;
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
installExtension({
|
||||||
|
extensionsDir: dir,
|
||||||
|
extension: repoExtension(),
|
||||||
|
fetchImpl,
|
||||||
|
maxBytes: 1024,
|
||||||
|
}),
|
||||||
|
/larger than the 1024 byte limit/,
|
||||||
|
);
|
||||||
|
// Only enough chunks to cross the limit were ever read.
|
||||||
|
assert.ok(pushed <= 4, `read ${pushed} chunks before aborting`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a package name carrying path separators cannot escape the extensions dir', async () => {
|
||||||
|
const root = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||||
|
const dir = path.join(root, 'extensions');
|
||||||
|
const escaping = `eu.kanade.tachiyomi.animeextension${path.sep}..${path.sep}..${path.sep}pwned`;
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() =>
|
||||||
|
installExtension({
|
||||||
|
extensionsDir: dir,
|
||||||
|
extension: repoExtension({ pkg: escaping }),
|
||||||
|
fetchImpl: respondWith(apkBytes()),
|
||||||
|
}),
|
||||||
|
/not a valid file name/,
|
||||||
|
);
|
||||||
|
assert.equal(existsSync(path.join(root, 'pwned.apk')), false);
|
||||||
|
});
|
||||||
|
|
||||||
test('removeExtension deletes the file and tolerates a missing one', async () => {
|
test('removeExtension deletes the file and tolerates a missing one', async () => {
|
||||||
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-install-'));
|
||||||
const file = path.join(dir, `${PKG}.apk`);
|
const file = path.join(dir, `${PKG}.apk`);
|
||||||
|
|||||||
@@ -15,11 +15,18 @@ export interface InstallExtensionOptions {
|
|||||||
fetchImpl?: typeof fetch;
|
fetchImpl?: typeof fetch;
|
||||||
/** Guards against a mistyped repo serving something enormous. */
|
/** Guards against a mistyped repo serving something enormous. */
|
||||||
maxBytes?: number;
|
maxBytes?: number;
|
||||||
|
/** Cancels a stalled download; without it a hung repo blocks the install. */
|
||||||
|
signal?: AbortSignal;
|
||||||
|
/** Applied when no `signal` is given, so a download can never hang forever. */
|
||||||
|
timeoutMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** APKs are a few MB; anything far past that is not an extension. */
|
/** APKs are a few MB; anything far past that is not an extension. */
|
||||||
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
|
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Generous enough for a large APK on a slow link, short of hanging forever. */
|
||||||
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
||||||
|
|
||||||
const APK_MAGIC = [0x50, 0x4b, 0x03, 0x04]; // "PK\x03\x04" — APKs are zip archives.
|
const APK_MAGIC = [0x50, 0x4b, 0x03, 0x04]; // "PK\x03\x04" — APKs are zip archives.
|
||||||
|
|
||||||
export function looksLikeApk(bytes: Uint8Array): boolean {
|
export function looksLikeApk(bytes: Uint8Array): boolean {
|
||||||
@@ -35,8 +42,9 @@ export function looksLikeApk(bytes: Uint8Array): boolean {
|
|||||||
export async function installExtension(options: InstallExtensionOptions): Promise<string> {
|
export async function installExtension(options: InstallExtensionOptions): Promise<string> {
|
||||||
const fetchImpl = options.fetchImpl ?? fetch;
|
const fetchImpl = options.fetchImpl ?? fetch;
|
||||||
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
|
||||||
|
const signal = options.signal ?? AbortSignal.timeout(options.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
||||||
|
|
||||||
const response = await fetchImpl(options.extension.apkUrl);
|
const response = await fetchImpl(options.extension.apkUrl, { signal });
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Downloading ${options.extension.name} failed (${response.status}).`);
|
throw new Error(`Downloading ${options.extension.name} failed (${response.status}).`);
|
||||||
}
|
}
|
||||||
@@ -46,21 +54,74 @@ export async function installExtension(options: InstallExtensionOptions): Promis
|
|||||||
throw new Error(`${options.extension.name} is larger than the ${maxBytes} byte limit.`);
|
throw new Error(`${options.extension.name} is larger than the ${maxBytes} byte limit.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const bytes = new Uint8Array(await response.arrayBuffer());
|
const bytes = await readBounded(response, maxBytes, options.extension.name);
|
||||||
if (bytes.byteLength > maxBytes) {
|
|
||||||
throw new Error(`${options.extension.name} is larger than the ${maxBytes} byte limit.`);
|
|
||||||
}
|
|
||||||
if (!looksLikeApk(bytes)) {
|
if (!looksLikeApk(bytes)) {
|
||||||
throw new Error(`${options.extension.name} did not download as an APK.`);
|
throw new Error(`${options.extension.name} did not download as an APK.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
await mkdir(options.extensionsDir, { recursive: true });
|
await mkdir(options.extensionsDir, { recursive: true });
|
||||||
const target = path.join(options.extensionsDir, extensionFileName(options.extension.pkg));
|
const target = resolveTarget(options.extensionsDir, options.extension.pkg);
|
||||||
await writeFile(target, bytes);
|
await writeFile(target, bytes);
|
||||||
return target;
|
return target;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the body incrementally and stop the moment the limit is passed.
|
||||||
|
*
|
||||||
|
* Buffering first and measuring afterwards would let a repo that lies about
|
||||||
|
* (or omits) `content-length` push an unbounded amount into memory before the
|
||||||
|
* check ever runs.
|
||||||
|
*/
|
||||||
|
async function readBounded(
|
||||||
|
response: Response,
|
||||||
|
maxBytes: number,
|
||||||
|
name: string,
|
||||||
|
): Promise<Uint8Array> {
|
||||||
|
const reader = response.body?.getReader();
|
||||||
|
if (!reader) {
|
||||||
|
const bytes = new Uint8Array(await response.arrayBuffer());
|
||||||
|
if (bytes.byteLength > maxBytes) {
|
||||||
|
throw new Error(`${name} is larger than the ${maxBytes} byte limit.`);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunks: Uint8Array[] = [];
|
||||||
|
let total = 0;
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
total += value.byteLength;
|
||||||
|
if (total > maxBytes) {
|
||||||
|
await reader.cancel();
|
||||||
|
throw new Error(`${name} is larger than the ${maxBytes} byte limit.`);
|
||||||
|
}
|
||||||
|
chunks.push(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
const bytes = new Uint8Array(total);
|
||||||
|
let offset = 0;
|
||||||
|
for (const chunk of chunks) {
|
||||||
|
bytes.set(chunk, offset);
|
||||||
|
offset += chunk.byteLength;
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defence in depth against a repository index that smuggles path separators
|
||||||
|
* into a package name: the write target must stay inside `extensionsDir`.
|
||||||
|
*/
|
||||||
|
function resolveTarget(extensionsDir: string, pkg: string): string {
|
||||||
|
const root = path.resolve(extensionsDir);
|
||||||
|
const target = path.resolve(root, extensionFileName(pkg));
|
||||||
|
if (path.dirname(target) !== root) {
|
||||||
|
throw new Error(`Refusing to install ${pkg}: the package name is not a valid file name.`);
|
||||||
|
}
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
/** Delete an installed extension. Missing files are treated as already gone. */
|
/** Delete an installed extension. Missing files are treated as already gone. */
|
||||||
export async function removeExtension(extensionsDir: string, pkg: string): Promise<void> {
|
export async function removeExtension(extensionsDir: string, pkg: string): Promise<void> {
|
||||||
await rm(path.join(extensionsDir, extensionFileName(pkg)), { force: true });
|
await rm(resolveTarget(extensionsDir, pkg), { force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,6 +87,26 @@ test('malformed entries are skipped rather than failing the repo', () => {
|
|||||||
assert.equal(parsed.length, 1);
|
assert.equal(parsed.length, 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('a package name that is not a plain identifier is rejected', () => {
|
||||||
|
// The package name becomes the on-disk file name, and a repo index is
|
||||||
|
// unauthenticated: path separators here would write outside the extensions
|
||||||
|
// directory even though the prefix check passes.
|
||||||
|
const parsed = parseRepoIndex(INDEX, [
|
||||||
|
animeEntry({ pkg: 'eu.kanade.tachiyomi.animeextension/../../../../etc/cron.d/x' }),
|
||||||
|
animeEntry({ pkg: 'eu.kanade.tachiyomi.animeextension\\..\\evil' }),
|
||||||
|
animeEntry({ pkg: 'eu.kanade.tachiyomi.animeextension.all.ok' }),
|
||||||
|
]);
|
||||||
|
assert.deepEqual(
|
||||||
|
parsed.map((extension) => extension.pkg),
|
||||||
|
['eu.kanade.tachiyomi.animeextension.all.ok'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an apk file name with path characters is rejected', () => {
|
||||||
|
const parsed = parseRepoIndex(INDEX, [animeEntry({ apk: '../../../etc/passwd' })]);
|
||||||
|
assert.deepEqual(parsed, []);
|
||||||
|
});
|
||||||
|
|
||||||
test('parseRepoIndex tolerates a non-array payload', () => {
|
test('parseRepoIndex tolerates a non-array payload', () => {
|
||||||
assert.deepEqual(parseRepoIndex(INDEX, { message: 'Not Found' }), []);
|
assert.deepEqual(parseRepoIndex(INDEX, { message: 'Not Found' }), []);
|
||||||
assert.deepEqual(parseRepoIndex(INDEX, null), []);
|
assert.deepEqual(parseRepoIndex(INDEX, null), []);
|
||||||
|
|||||||
@@ -9,6 +9,16 @@
|
|||||||
/** Aniyomi extension packages carry this prefix; manga packages are ignored. */
|
/** Aniyomi extension packages carry this prefix; manga packages are ignored. */
|
||||||
const ANIME_PACKAGE_PREFIX = 'eu.kanade.tachiyomi.animeextension';
|
const ANIME_PACKAGE_PREFIX = 'eu.kanade.tachiyomi.animeextension';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A package name becomes the on-disk APK file name, and a repository index is
|
||||||
|
* unauthenticated content the user pointed us at. Only plain dotted identifiers
|
||||||
|
* are accepted, so nothing in an index can carry `/` or `..` into a file path.
|
||||||
|
*/
|
||||||
|
const PACKAGE_NAME_PATTERN = /^[A-Za-z0-9_.]+$/;
|
||||||
|
|
||||||
|
/** The APK file name is appended to the repo URL, so keep it a bare name. */
|
||||||
|
const APK_FILE_NAME_PATTERN = /^[A-Za-z0-9_.+-]+$/;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Repos are identified by their index URL. The file name is not fixed:
|
* Repos are identified by their index URL. The file name is not fixed:
|
||||||
* `index.min.json` is the Aniyomi convention, but repositories publish under
|
* `index.min.json` is the Aniyomi convention, but repositories publish under
|
||||||
@@ -81,7 +91,8 @@ export function parseRepoIndex(indexUrl: string, payload: unknown): RepoExtensio
|
|||||||
if (raw === null || typeof raw !== 'object') continue;
|
if (raw === null || typeof raw !== 'object') continue;
|
||||||
const pkg = typeof raw.pkg === 'string' ? raw.pkg : '';
|
const pkg = typeof raw.pkg === 'string' ? raw.pkg : '';
|
||||||
const apk = typeof raw.apk === 'string' ? raw.apk : '';
|
const apk = typeof raw.apk === 'string' ? raw.apk : '';
|
||||||
if (!pkg.startsWith(ANIME_PACKAGE_PREFIX) || apk.length === 0) continue;
|
if (!pkg.startsWith(ANIME_PACKAGE_PREFIX) || !PACKAGE_NAME_PATTERN.test(pkg)) continue;
|
||||||
|
if (apk.length === 0 || !APK_FILE_NAME_PATTERN.test(apk)) continue;
|
||||||
|
|
||||||
const versionCode = Number(raw.code);
|
const versionCode = Number(raw.code);
|
||||||
extensions.push({
|
extensions.push({
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import {
|
import {
|
||||||
@@ -22,18 +23,36 @@ async function makeExtensionDir(files: Record<string, string>): Promise<string>
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fakeClient(
|
function fakeClient(
|
||||||
impl: (source: { apkBase64: string }) => Promise<unknown[]>,
|
impl: (source: { fingerprint: string }) => Promise<unknown[]>,
|
||||||
): AnimeBridgeClient {
|
): AnimeBridgeClient {
|
||||||
return { listAnimeSources: impl } as unknown as AnimeBridgeClient;
|
return { listAnimeSources: impl } as unknown as AnimeBridgeClient;
|
||||||
}
|
}
|
||||||
|
|
||||||
test('readInstalledExtensions reads apks and base64-encodes them', async () => {
|
test('readInstalledExtensions fingerprints apks without holding their bytes', async () => {
|
||||||
const dir = await makeExtensionDir({ 'my-source.apk': 'APK-BYTES' });
|
const dir = await makeExtensionDir({ 'my-source.apk': 'APK-BYTES' });
|
||||||
const extensions = await readInstalledExtensions(dir);
|
const extensions = await readInstalledExtensions(dir);
|
||||||
|
|
||||||
assert.equal(extensions.length, 1);
|
assert.equal(extensions.length, 1);
|
||||||
assert.equal(extensions[0]?.fallbackName, 'my-source');
|
assert.equal(extensions[0]?.fallbackName, 'my-source');
|
||||||
assert.equal(Buffer.from(extensions[0]!.apkBase64, 'base64').toString(), 'APK-BYTES');
|
assert.equal(extensions[0]?.sha256, createHash('sha256').update('APK-BYTES').digest('hex'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the fingerprint changes when an apk is replaced in place', async () => {
|
||||||
|
const dir = await makeExtensionDir({ 'my-source.apk': 'V1' });
|
||||||
|
const before = (await readInstalledExtensions(dir))[0]?.sha256;
|
||||||
|
await writeFile(path.join(dir, 'my-source.apk'), 'V2');
|
||||||
|
const after = (await readInstalledExtensions(dir))[0]?.sha256;
|
||||||
|
|
||||||
|
assert.notEqual(before, after);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('toBridgeSource reads the apk only when the bridge asks for it', async () => {
|
||||||
|
const dir = await makeExtensionDir({ 'lazy.apk': 'APK-BYTES' });
|
||||||
|
const extension = (await readInstalledExtensions(dir))[0]!;
|
||||||
|
|
||||||
|
const bridgeSource = toBridgeSource(extension);
|
||||||
|
assert.equal(bridgeSource.fingerprint, extension.sha256);
|
||||||
|
assert.equal(Buffer.from(await bridgeSource.loadApkBase64(), 'base64').toString(), 'APK-BYTES');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('readInstalledExtensions ignores non-apk files and subdirectories', async () => {
|
test('readInstalledExtensions ignores non-apk files and subdirectories', async () => {
|
||||||
@@ -50,21 +69,15 @@ test('readInstalledExtensions returns empty for a missing directory', async () =
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('toBridgeSource includes sourceId only when selecting inside a factory apk', () => {
|
test('toBridgeSource includes sourceId only when selecting inside a factory apk', () => {
|
||||||
const extension: InstalledExtension = {
|
const extension: InstalledExtension = { file: '/x/a.apk', fallbackName: 'a', sha256: 'hash-a' };
|
||||||
file: '/x/a.apk',
|
assert.equal(toBridgeSource(extension).sourceId, undefined);
|
||||||
fallbackName: 'a',
|
assert.equal(toBridgeSource(extension, 'src-1').sourceId, 'src-1');
|
||||||
apkBase64: 'QQ==',
|
assert.equal(toBridgeSource(extension, 'src-1').fingerprint, 'hash-a');
|
||||||
};
|
|
||||||
assert.deepEqual(toBridgeSource(extension), { apkBase64: 'QQ==' });
|
|
||||||
assert.deepEqual(toBridgeSource(extension, 'src-1'), {
|
|
||||||
apkBase64: 'QQ==',
|
|
||||||
sourceId: 'src-1',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('listExtensionSources flattens every source a factory apk provides', async () => {
|
test('listExtensionSources flattens every source a factory apk provides', async () => {
|
||||||
const extensions: InstalledExtension[] = [
|
const extensions: InstalledExtension[] = [
|
||||||
{ file: '/x/multi.apk', fallbackName: 'multi', apkBase64: 'QQ==' },
|
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a' },
|
||||||
];
|
];
|
||||||
const client = fakeClient(async () => [
|
const client = fakeClient(async () => [
|
||||||
{ id: 101, name: 'Source One', lang: 'en' },
|
{ id: 101, name: 'Source One', lang: 'en' },
|
||||||
@@ -82,7 +95,7 @@ test('listExtensionSources flattens every source a factory apk provides', async
|
|||||||
|
|
||||||
test('listExtensionSources falls back to the file name and a default language', async () => {
|
test('listExtensionSources falls back to the file name and a default language', async () => {
|
||||||
const extensions: InstalledExtension[] = [
|
const extensions: InstalledExtension[] = [
|
||||||
{ file: '/x/my-ext.apk', fallbackName: 'my-ext', apkBase64: 'QQ==' },
|
{ file: '/x/my-ext.apk', fallbackName: 'my-ext', sha256: 'hash-a' },
|
||||||
];
|
];
|
||||||
const client = fakeClient(async () => [{ id: '1', name: ' ' }]);
|
const client = fakeClient(async () => [{ id: '1', name: ' ' }]);
|
||||||
|
|
||||||
@@ -94,14 +107,14 @@ test('listExtensionSources falls back to the file name and a default language',
|
|||||||
test('listExtensionSources drops descriptors with no usable id', async () => {
|
test('listExtensionSources drops descriptors with no usable id', async () => {
|
||||||
const client = fakeClient(async () => [{ name: 'No Id' }, { id: '', name: 'Empty' }]);
|
const client = fakeClient(async () => [{ name: 'No Id' }, { id: '', name: 'Empty' }]);
|
||||||
const sources = await listExtensionSources(client, [
|
const sources = await listExtensionSources(client, [
|
||||||
{ file: '/x/a.apk', fallbackName: 'a', apkBase64: 'QQ==' },
|
{ file: '/x/a.apk', fallbackName: 'a', sha256: 'hash-a' },
|
||||||
]);
|
]);
|
||||||
assert.deepEqual(sources, []);
|
assert.deepEqual(sources, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('toInstalledExtensionViews names an extension after the sources it provides', () => {
|
test('toInstalledExtensionViews names an extension after the sources it provides', () => {
|
||||||
const extensions: InstalledExtension[] = [
|
const extensions: InstalledExtension[] = [
|
||||||
{ file: '/x/multi.apk', fallbackName: 'multi', apkBase64: 'QQ==' },
|
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a' },
|
||||||
];
|
];
|
||||||
const sources: ExtensionSource[] = [
|
const sources: ExtensionSource[] = [
|
||||||
{ id: '1', name: 'One', lang: 'en', pkg: 'multi', file: '/x/multi.apk' },
|
{ id: '1', name: 'One', lang: 'en', pkg: 'multi', file: '/x/multi.apk' },
|
||||||
@@ -115,7 +128,7 @@ test('toInstalledExtensionViews names an extension after the sources it provides
|
|||||||
|
|
||||||
test('toInstalledExtensionViews lists an extension that loaded nothing, with its reason', () => {
|
test('toInstalledExtensionViews lists an extension that loaded nothing, with its reason', () => {
|
||||||
const extensions: InstalledExtension[] = [
|
const extensions: InstalledExtension[] = [
|
||||||
{ file: '/x/broken.apk', fallbackName: 'broken', apkBase64: 'QQ==' },
|
{ file: '/x/broken.apk', fallbackName: 'broken', sha256: 'hash-a' },
|
||||||
];
|
];
|
||||||
|
|
||||||
// A broken APK is still installed, so it must stay listed and removable.
|
// A broken APK is still installed, so it must stay listed and removable.
|
||||||
@@ -127,12 +140,12 @@ test('toInstalledExtensionViews lists an extension that loaded nothing, with its
|
|||||||
|
|
||||||
test('one broken extension does not hide the working ones', async () => {
|
test('one broken extension does not hide the working ones', async () => {
|
||||||
const extensions: InstalledExtension[] = [
|
const extensions: InstalledExtension[] = [
|
||||||
{ file: '/x/broken.apk', fallbackName: 'broken', apkBase64: 'QQ==' },
|
{ file: '/x/broken.apk', fallbackName: 'broken', sha256: 'hash-a' },
|
||||||
{ file: '/x/good.apk', fallbackName: 'good', apkBase64: 'Qg==' },
|
{ file: '/x/good.apk', fallbackName: 'good', sha256: 'hash-b' },
|
||||||
];
|
];
|
||||||
const failures: string[] = [];
|
const failures: string[] = [];
|
||||||
const client = fakeClient(async (source) => {
|
const client = fakeClient(async (source) => {
|
||||||
if (source.apkBase64 === 'QQ==') throw new Error('dex2jar failed');
|
if (source.fingerprint === 'hash-a') throw new Error('dex2jar failed');
|
||||||
return [{ id: '7', name: 'Good Source', lang: 'en' }];
|
return [{ id: '7', name: 'Good Source', lang: 'en' }];
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
|
import { createReadStream } from 'node:fs';
|
||||||
import { readdir, readFile } from 'node:fs/promises';
|
import { readdir, readFile } from 'node:fs/promises';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { pipeline } from 'node:stream/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { AnimeBridgeClient } from './bridge-client';
|
import type { AnimeBridgeClient } from './bridge-client';
|
||||||
import type { BridgeSource } from './bridge-client';
|
import type { BridgeSource } from './bridge-client';
|
||||||
@@ -15,7 +18,11 @@ export interface InstalledExtension {
|
|||||||
file: string;
|
file: string;
|
||||||
/** File name without extension, used when the bridge reports no name. */
|
/** File name without extension, used when the bridge reports no name. */
|
||||||
fallbackName: string;
|
fallbackName: string;
|
||||||
apkBase64: string;
|
/**
|
||||||
|
* SHA-256 of the APK. Identifies the build rather than the slot, so the
|
||||||
|
* bridge's extension-id cache misses after an in-place upgrade.
|
||||||
|
*/
|
||||||
|
sha256: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExtensionSource {
|
export interface ExtensionSource {
|
||||||
@@ -27,7 +34,14 @@ export interface ExtensionSource {
|
|||||||
file: string;
|
file: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Read every .apk in `directory`. A missing directory yields no extensions. */
|
/**
|
||||||
|
* Discover every .apk in `directory`. A missing directory yields no extensions.
|
||||||
|
*
|
||||||
|
* Only a hash is kept, never the bytes: APKs run to several MB each and a
|
||||||
|
* base64 copy adds a third on top, so holding the whole set for the lifetime of
|
||||||
|
* the Anime Browser would cost far more than re-reading a file on the rare
|
||||||
|
* upload. Hashing streams, so peak memory stays flat regardless of APK size.
|
||||||
|
*/
|
||||||
export async function readInstalledExtensions(directory: string): Promise<InstalledExtension[]> {
|
export async function readInstalledExtensions(directory: string): Promise<InstalledExtension[]> {
|
||||||
let entries;
|
let entries;
|
||||||
try {
|
try {
|
||||||
@@ -40,16 +54,21 @@ export async function readInstalledExtensions(directory: string): Promise<Instal
|
|||||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||||
if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.apk')) continue;
|
if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.apk')) continue;
|
||||||
const file = path.join(directory, entry.name);
|
const file = path.join(directory, entry.name);
|
||||||
const bytes = await readFile(file);
|
|
||||||
extensions.push({
|
extensions.push({
|
||||||
file,
|
file,
|
||||||
fallbackName: entry.name.replace(/\.apk$/i, ''),
|
fallbackName: entry.name.replace(/\.apk$/i, ''),
|
||||||
apkBase64: bytes.toString('base64'),
|
sha256: await hashFile(file),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return extensions;
|
return extensions;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function hashFile(file: string): Promise<string> {
|
||||||
|
const hash = createHash('sha256');
|
||||||
|
await pipeline(createReadStream(file), hash);
|
||||||
|
return hash.digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Describe what is on disk, for the installed list in the Extensions tab.
|
* Describe what is on disk, for the installed list in the Extensions tab.
|
||||||
*
|
*
|
||||||
@@ -75,9 +94,18 @@ export function toInstalledExtensionViews(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The bridge payload for a specific source inside an extension. */
|
/**
|
||||||
|
* The bridge payload for a specific source inside an extension.
|
||||||
|
*
|
||||||
|
* The APK is read on demand: after the first upload the bridge answers by
|
||||||
|
* extension id, so most calls never touch the file at all.
|
||||||
|
*/
|
||||||
export function toBridgeSource(extension: InstalledExtension, sourceId?: string): BridgeSource {
|
export function toBridgeSource(extension: InstalledExtension, sourceId?: string): BridgeSource {
|
||||||
return { apkBase64: extension.apkBase64, ...(sourceId ? { sourceId } : {}) };
|
return {
|
||||||
|
fingerprint: extension.sha256,
|
||||||
|
loadApkBase64: async () => (await readFile(extension.file)).toString('base64'),
|
||||||
|
...(sourceId ? { sourceId } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -28,6 +28,13 @@ test('toMpvHeaderFields joins entries and escapes commas in values', () => {
|
|||||||
assert.equal(fields, 'Referer: https://origin.example/,Cookie: a=1\\, b=2');
|
assert.equal(fields, 'Referer: https://origin.example/,Cookie: a=1\\, b=2');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('toMpvHeaderFields escapes backslashes so a trailing one cannot eat the separator', () => {
|
||||||
|
const fields = toMpvHeaderFields({ Referer: 'https://origin.example/path\\', Cookie: 'a=1' });
|
||||||
|
// Without doubling, the value's trailing backslash would escape the comma
|
||||||
|
// and merge Cookie into the Referer entry.
|
||||||
|
assert.equal(fields, 'Referer: https://origin.example/path\\\\,Cookie: a=1');
|
||||||
|
});
|
||||||
|
|
||||||
test('toMpvHeaderFields returns an empty string when there are no headers', () => {
|
test('toMpvHeaderFields returns an empty string when there are no headers', () => {
|
||||||
assert.equal(toMpvHeaderFields({}), '');
|
assert.equal(toMpvHeaderFields({}), '');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,11 +19,13 @@ export function parseOkHttpHeaders(headers: OkHttpHeaders | undefined): Record<s
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Render headers as mpv's `--http-header-fields` string list. mpv splits
|
* Render headers as mpv's `--http-header-fields` string list. mpv splits
|
||||||
* entries on commas, so commas inside a value must be escaped.
|
* entries on commas, so commas inside a value must be escaped — and the
|
||||||
|
* backslash that does the escaping has to be escaped first, or a value ending
|
||||||
|
* in `\` would neutralise the separator and swallow the next header.
|
||||||
*/
|
*/
|
||||||
export function toMpvHeaderFields(headers: Record<string, string>): string {
|
export function toMpvHeaderFields(headers: Record<string, string>): string {
|
||||||
return Object.entries(headers)
|
return Object.entries(headers)
|
||||||
.map(([name, value]) => `${name}: ${value.replace(/,/g, '\\,')}`)
|
.map(([name, value]) => `${name}: ${value.replace(/\\/g, '\\\\').replace(/,/g, '\\,')}`)
|
||||||
.join(',');
|
.join(',');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -57,6 +57,17 @@ test('the escape length counts the full header string including separators', ()
|
|||||||
assert.ok(options.includes(`%${fields.length}%${fields}`));
|
assert.ok(options.includes(`%${fields.length}%${fields}`));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('the escape length is counted in utf-8 bytes, not js string units', () => {
|
||||||
|
// An extension may put a non-ASCII value in a header; mpv reads %n% as a
|
||||||
|
// byte count, so counting string units would truncate the value.
|
||||||
|
const options = buildLoadfileOptions({
|
||||||
|
stream: stream({ headers: { 'X-Title': '日本語' } }),
|
||||||
|
});
|
||||||
|
const fields = 'X-Title: 日本語';
|
||||||
|
assert.ok(options.includes(`%${Buffer.byteLength(fields, 'utf8')}%${fields}`));
|
||||||
|
assert.ok(!options.includes(`%${fields.length}%`));
|
||||||
|
});
|
||||||
|
|
||||||
test('no header option is emitted when the stream carries no headers', () => {
|
test('no header option is emitted when the stream carries no headers', () => {
|
||||||
const options = buildLoadfileOptions({ stream: stream() });
|
const options = buildLoadfileOptions({ stream: stream() });
|
||||||
assert.ok(!options.includes('http-header-fields'));
|
assert.ok(!options.includes('http-header-fields'));
|
||||||
|
|||||||
@@ -77,9 +77,13 @@ export function buildLoadfileOptions(options: BuildPlaybackOptions): string {
|
|||||||
* mpv splits `loadfile` options on commas and `=`-separates keys, so a value
|
* mpv splits `loadfile` options on commas and `=`-separates keys, so a value
|
||||||
* containing either must be quoted. Percent-encoding is mpv's own escape for
|
* containing either must be quoted. Percent-encoding is mpv's own escape for
|
||||||
* embedded separators in option values.
|
* embedded separators in option values.
|
||||||
|
*
|
||||||
|
* The count is in UTF-8 bytes of the decoded value, not JS string units, so a
|
||||||
|
* non-ASCII header value (extensions supply these) would otherwise under-count
|
||||||
|
* and mpv would cut the value short.
|
||||||
*/
|
*/
|
||||||
function escapeOptionValue(value: string): string {
|
function escapeOptionValue(value: string): string {
|
||||||
return `%${value.length}%${value}`;
|
return `%${Buffer.byteLength(value, 'utf8')}%${value}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { mkdtemp, readFile, readdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { PreferenceStore } from './preference-store';
|
||||||
|
|
||||||
|
async function storeFile(): Promise<string> {
|
||||||
|
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-prefs-'));
|
||||||
|
return path.join(dir, 'anime-preferences.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
test('values round-trip through the file', async () => {
|
||||||
|
const file = await storeFile();
|
||||||
|
await new PreferenceStore(file).set('src-1', [{ key: 'address' }]);
|
||||||
|
|
||||||
|
assert.deepEqual(await new PreferenceStore(file).get('src-1'), [{ key: 'address' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('concurrent writes on a cold cache do not lose an update', async () => {
|
||||||
|
const file = await storeFile();
|
||||||
|
const store = new PreferenceStore(file);
|
||||||
|
|
||||||
|
// Both start before either has loaded; unserialized they would each get their
|
||||||
|
// own object and the later persist would drop the other's entry.
|
||||||
|
await Promise.all([store.set('src-1', [{ key: 'a' }]), store.set('src-2', [{ key: 'b' }])]);
|
||||||
|
|
||||||
|
const reloaded = new PreferenceStore(file);
|
||||||
|
assert.deepEqual(await reloaded.get('src-1'), [{ key: 'a' }]);
|
||||||
|
assert.deepEqual(await reloaded.get('src-2'), [{ key: 'b' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a clear racing a set is applied in order', async () => {
|
||||||
|
const file = await storeFile();
|
||||||
|
const store = new PreferenceStore(file);
|
||||||
|
await store.set('pkg:src', [{ key: 'password' }]);
|
||||||
|
|
||||||
|
await Promise.all([store.clear('pkg'), store.set('other:src', [{ key: 'x' }])]);
|
||||||
|
|
||||||
|
const reloaded = new PreferenceStore(file);
|
||||||
|
assert.deepEqual(await reloaded.get('pkg:src'), []);
|
||||||
|
assert.deepEqual(await reloaded.get('other:src'), [{ key: 'x' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the file is written owner-only and leaves no temporary behind', async () => {
|
||||||
|
const file = await storeFile();
|
||||||
|
await new PreferenceStore(file).set('src-1', [{ key: 'password' }]);
|
||||||
|
|
||||||
|
const { stat } = await import('node:fs/promises');
|
||||||
|
assert.equal((await stat(file)).mode & 0o777, 0o600);
|
||||||
|
assert.deepEqual(await readdir(path.dirname(file)), [path.basename(file)]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a corrupt file starts empty rather than blocking the browser', async () => {
|
||||||
|
const file = await storeFile();
|
||||||
|
await writeFile(file, '{ not json');
|
||||||
|
|
||||||
|
assert.deepEqual(await new PreferenceStore(file).get('src-1'), []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a write replaces the previous contents wholesale', async () => {
|
||||||
|
const file = await storeFile();
|
||||||
|
const store = new PreferenceStore(file);
|
||||||
|
await store.set('src-1', [{ key: 'first' }]);
|
||||||
|
await store.set('src-1', [{ key: 'second' }]);
|
||||||
|
|
||||||
|
const parsed = JSON.parse(await readFile(file, 'utf8')) as Record<string, unknown[]>;
|
||||||
|
assert.deepEqual(parsed['src-1'], [{ key: 'second' }]);
|
||||||
|
});
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { readFile, writeFile, mkdir } from 'node:fs/promises';
|
import { readFile, writeFile, rename, rm, mkdir } from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import type { BridgePreference } from './types';
|
import type { BridgePreference } from './types';
|
||||||
|
|
||||||
@@ -11,11 +11,24 @@ import type { BridgePreference } from './types';
|
|||||||
export class PreferenceStore {
|
export class PreferenceStore {
|
||||||
private readonly file: string;
|
private readonly file: string;
|
||||||
private cache: Record<string, BridgePreference[]> | null = null;
|
private cache: Record<string, BridgePreference[]> | null = null;
|
||||||
|
/**
|
||||||
|
* Mutations run one at a time. Two concurrent load-modify-persist cycles
|
||||||
|
* starting on a cold cache would each read their own object, and the later
|
||||||
|
* write would drop the earlier one's edit.
|
||||||
|
*/
|
||||||
|
private queue: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
constructor(file: string) {
|
constructor(file: string) {
|
||||||
this.file = file;
|
this.file = file;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
||||||
|
const result = this.queue.then(operation, operation);
|
||||||
|
// Keep the chain alive after a rejection so one failure cannot wedge it.
|
||||||
|
this.queue = result.catch(() => undefined);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
private async load(): Promise<Record<string, BridgePreference[]>> {
|
private async load(): Promise<Record<string, BridgePreference[]>> {
|
||||||
if (this.cache !== null) return this.cache;
|
if (this.cache !== null) return this.cache;
|
||||||
try {
|
try {
|
||||||
@@ -32,14 +45,18 @@ export class PreferenceStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async get(sourceId: string): Promise<BridgePreference[]> {
|
async get(sourceId: string): Promise<BridgePreference[]> {
|
||||||
const all = await this.load();
|
return this.enqueue(async () => {
|
||||||
return all[sourceId] ?? [];
|
const all = await this.load();
|
||||||
|
return all[sourceId] ?? [];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async set(sourceId: string, preferences: BridgePreference[]): Promise<void> {
|
async set(sourceId: string, preferences: BridgePreference[]): Promise<void> {
|
||||||
const all = await this.load();
|
await this.enqueue(async () => {
|
||||||
all[sourceId] = preferences;
|
const all = await this.load();
|
||||||
await this.persist(all);
|
all[sourceId] = preferences;
|
||||||
|
await this.persist(all);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -50,19 +67,35 @@ export class PreferenceStore {
|
|||||||
* package name and this clears anything recorded under it.
|
* package name and this clears anything recorded under it.
|
||||||
*/
|
*/
|
||||||
async clear(prefix: string): Promise<void> {
|
async clear(prefix: string): Promise<void> {
|
||||||
const all = await this.load();
|
await this.enqueue(async () => {
|
||||||
let changed = false;
|
const all = await this.load();
|
||||||
for (const key of Object.keys(all)) {
|
let changed = false;
|
||||||
if (key === prefix || key.startsWith(`${prefix}:`)) {
|
for (const key of Object.keys(all)) {
|
||||||
delete all[key];
|
if (key === prefix || key.startsWith(`${prefix}:`)) {
|
||||||
changed = true;
|
delete all[key];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
if (changed) await this.persist(all);
|
||||||
if (changed) await this.persist(all);
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write through a temporary file and rename into place.
|
||||||
|
*
|
||||||
|
* A write interrupted partway would otherwise leave truncated JSON, and
|
||||||
|
* `load()` treats unparseable content as empty — which would quietly discard
|
||||||
|
* every saved credential.
|
||||||
|
*/
|
||||||
private async persist(all: Record<string, BridgePreference[]>): Promise<void> {
|
private async persist(all: Record<string, BridgePreference[]>): Promise<void> {
|
||||||
await mkdir(path.dirname(this.file), { recursive: true });
|
await mkdir(path.dirname(this.file), { recursive: true });
|
||||||
await writeFile(this.file, JSON.stringify(all, null, 2), { mode: 0o600 });
|
const temporary = `${this.file}.tmp`;
|
||||||
|
try {
|
||||||
|
await writeFile(temporary, JSON.stringify(all, null, 2), { mode: 0o600 });
|
||||||
|
await rename(temporary, this.file);
|
||||||
|
} catch (error) {
|
||||||
|
await rm(temporary, { force: true }).catch(() => undefined);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import type { spawn as spawnType, ChildProcess } from 'node:child_process';
|
||||||
|
import { allocatePort, startSidecar } from './sidecar-process';
|
||||||
|
import type { BundleBinaries } from './sidecar-bundle';
|
||||||
|
|
||||||
|
const binaries: BundleBinaries = {
|
||||||
|
javaPath: '/nonexistent/java',
|
||||||
|
jarPath: '/tmp/MExtensionServer.jar',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A ChildProcess stand-in: an EventEmitter with the bits startSidecar touches. */
|
||||||
|
function fakeChild(): ChildProcess {
|
||||||
|
const child = new EventEmitter();
|
||||||
|
Object.assign(child, { stdout: null, stderr: null, kill: () => true });
|
||||||
|
return child as unknown as ChildProcess;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a failed spawn rejects instead of throwing an unhandled error event', async () => {
|
||||||
|
const port = await allocatePort();
|
||||||
|
const child = fakeChild();
|
||||||
|
const spawnImpl = (() => {
|
||||||
|
// Node emits `error` asynchronously when the binary cannot be executed.
|
||||||
|
queueMicrotask(() => child.emit('error', new Error('spawn ENOENT')));
|
||||||
|
return child;
|
||||||
|
}) as unknown as typeof spawnType;
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => startSidecar({ binaries, port, readyTimeoutMs: 2000, spawnImpl }),
|
||||||
|
/could not start.*ENOENT/,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an early exit is reported with its code rather than waiting out the deadline', async () => {
|
||||||
|
const port = await allocatePort();
|
||||||
|
const child = fakeChild();
|
||||||
|
const spawnImpl = (() => {
|
||||||
|
queueMicrotask(() => child.emit('exit', 1, null));
|
||||||
|
return child;
|
||||||
|
}) as unknown as typeof spawnType;
|
||||||
|
|
||||||
|
await assert.rejects(
|
||||||
|
() => startSidecar({ binaries, port, readyTimeoutMs: 2000, spawnImpl }),
|
||||||
|
/exited before becoming ready \(code 1/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -7,6 +7,12 @@ import type { BundleBinaries } from './sidecar-bundle';
|
|||||||
/** Cold start includes JVM boot plus AndroidCompat init; be generous. */
|
/** Cold start includes JVM boot plus AndroidCompat init; be generous. */
|
||||||
export const DEFAULT_READY_TIMEOUT_MS = 30_000;
|
export const DEFAULT_READY_TIMEOUT_MS = 30_000;
|
||||||
const READY_POLL_INTERVAL_MS = 500;
|
const READY_POLL_INTERVAL_MS = 500;
|
||||||
|
/** How long to wait for the child to go after each signal before escalating. */
|
||||||
|
const STOP_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
|
function delay(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
/** Ask the OS for a free loopback port, then hand it to the JVM. */
|
/** Ask the OS for a free loopback port, then hand it to the JVM. */
|
||||||
export async function allocatePort(): Promise<number> {
|
export async function allocatePort(): Promise<number> {
|
||||||
@@ -68,8 +74,20 @@ export async function startSidecar(options: StartSidecarOptions): Promise<Sideca
|
|||||||
}
|
}
|
||||||
|
|
||||||
let exited: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
let exited: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
||||||
child.once('exit', (code, signal) => {
|
let spawnError: Error | null = null;
|
||||||
exited = { code, signal };
|
const hasExited = new Promise<void>((resolve) => {
|
||||||
|
child.once('exit', (code, signal) => {
|
||||||
|
exited = { code, signal };
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
// A ChildProcess is an EventEmitter: without this listener a failed spawn
|
||||||
|
// (a missing or non-executable java) throws in the main process instead of
|
||||||
|
// failing the readiness loop below.
|
||||||
|
child.once('error', (error: Error) => {
|
||||||
|
spawnError = error;
|
||||||
|
if (exited === null) exited = { code: null, signal: null };
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const stop = async (): Promise<void> => {
|
const stop = async (): Promise<void> => {
|
||||||
@@ -80,13 +98,24 @@ export async function startSidecar(options: StartSidecarOptions): Promise<Sideca
|
|||||||
} catch {
|
} catch {
|
||||||
// Falling through to a signal is fine; the endpoint may already be gone.
|
// Falling through to a signal is fine; the endpoint may already be gone.
|
||||||
}
|
}
|
||||||
if (exited === null) child.kill();
|
if (exited !== null) return;
|
||||||
|
child.kill();
|
||||||
|
// kill() only sends the signal. Wait for the process to actually go, so a
|
||||||
|
// restart cannot race the old one still holding the port.
|
||||||
|
await Promise.race([hasExited, delay(STOP_TIMEOUT_MS)]);
|
||||||
|
if (exited === null) {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
await Promise.race([hasExited, delay(STOP_TIMEOUT_MS)]);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const client = new AnimeBridgeClient({ baseUrl });
|
const client = new AnimeBridgeClient({ baseUrl });
|
||||||
const deadline = Date.now() + (options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
|
const deadline = Date.now() + (options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
|
if (spawnError !== null) {
|
||||||
|
throw new Error(`Anime bridge could not start: ${(spawnError as Error).message}`);
|
||||||
|
}
|
||||||
if (exited !== null) {
|
if (exited !== null) {
|
||||||
const { code, signal } = exited as { code: number | null; signal: NodeJS.Signals | null };
|
const { code, signal } = exited as { code: number | null; signal: NodeJS.Signals | null };
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -94,7 +123,7 @@ export async function startSidecar(options: StartSidecarOptions): Promise<Sideca
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (await client.isReady()) return { baseUrl, port, client, stop };
|
if (await client.isReady()) return { baseUrl, port, client, stop };
|
||||||
await new Promise((resolve) => setTimeout(resolve, READY_POLL_INTERVAL_MS));
|
await delay(READY_POLL_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
await stop();
|
await stop();
|
||||||
|
|||||||
+18
-11
@@ -488,18 +488,25 @@ api.onBridgeState(renderBridgeState);
|
|||||||
|
|
||||||
void (async () => {
|
void (async () => {
|
||||||
renderBridgeState({ stage: 'idle', progress: null, message: null });
|
renderBridgeState({ stage: 'idle', progress: null, message: null });
|
||||||
const state = await api.ensureBridge();
|
try {
|
||||||
renderBridgeState(state);
|
const state = await api.ensureBridge();
|
||||||
|
renderBridgeState(state);
|
||||||
|
|
||||||
const snapshot = await api.getSnapshot();
|
const snapshot = await api.getSnapshot();
|
||||||
renderSources(snapshot.sources, snapshot.selectedSourceId);
|
renderSources(snapshot.sources, snapshot.selectedSourceId);
|
||||||
|
|
||||||
if (state.stage === 'ready' && snapshot.sources.length > 0) {
|
if (state.stage === 'ready' && snapshot.sources.length > 0) {
|
||||||
searchInput.focus();
|
searchInput.focus();
|
||||||
await runSearch('');
|
await runSearch('');
|
||||||
} else if (state.stage === 'ready') {
|
} else if (state.stage === 'ready') {
|
||||||
setStatus(state.message ?? 'No extensions installed.', 'error');
|
setStatus(state.message ?? 'No extensions installed.', 'error');
|
||||||
} else {
|
} else {
|
||||||
setStatus(state.message ?? 'The extension bridge is not available.', 'error');
|
setStatus(state.message ?? 'The extension bridge is not available.', 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Without this the window keeps the "starting" banner up forever, with the
|
||||||
|
// search box disabled and nothing saying why.
|
||||||
|
renderBridgeState({ stage: 'failed', progress: null, message: describe(error) });
|
||||||
|
setStatus(describe(error), 'error');
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -19,6 +19,29 @@ function isSecretKey(view: SourcePreferenceView): boolean {
|
|||||||
return /password|token|api[-_ ]?key|secret/i.test(`${view.key} ${view.title}`);
|
return /password|token|api[-_ ]?key|secret/i.test(`${view.key} ${view.title}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Structure of a schema, ignoring the values.
|
||||||
|
*
|
||||||
|
* A commit hands back the whole schema, but re-rendering on every save would
|
||||||
|
* throw away the "Saved" note, move focus off the control that was just
|
||||||
|
* committed, and rebuild a `multi` group out from under a second toggle that is
|
||||||
|
* still in flight. Only a structural change is worth a rebuild — which is
|
||||||
|
* exactly the case that matters, Jellyfin filling in its library picker after a
|
||||||
|
* successful login.
|
||||||
|
*/
|
||||||
|
function schemaShape(views: SourcePreferenceView[]): string {
|
||||||
|
return JSON.stringify(
|
||||||
|
views.map((view) => [
|
||||||
|
view.key,
|
||||||
|
view.kind,
|
||||||
|
view.title,
|
||||||
|
view.summary,
|
||||||
|
view.entries,
|
||||||
|
view.entryValues,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function renderPreferenceField(
|
function renderPreferenceField(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
view: SourcePreferenceView,
|
view: SourcePreferenceView,
|
||||||
@@ -46,10 +69,14 @@ function renderPreferenceField(
|
|||||||
state.removeAttribute('data-tone');
|
state.removeAttribute('data-tone');
|
||||||
state.textContent = 'Saving…';
|
state.textContent = 'Saving…';
|
||||||
try {
|
try {
|
||||||
const refreshed = await commit(view.key, value);
|
// Commits are chained so a second toggle cannot land before the first
|
||||||
|
// one's round trip finishes and overwrite it with a stale array.
|
||||||
|
const refreshed = await enqueueCommit(container, () => commit(view.key, value));
|
||||||
state.dataset.tone = 'ok';
|
state.dataset.tone = 'ok';
|
||||||
state.textContent = 'Saved';
|
state.textContent = 'Saved';
|
||||||
renderPreferences(container, refreshed, commit);
|
if (schemaShape(refreshed) !== renderedShapes.get(container)) {
|
||||||
|
renderPreferences(container, refreshed, commit);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
state.dataset.tone = 'error';
|
state.dataset.tone = 'error';
|
||||||
state.textContent = describe(error);
|
state.textContent = describe(error);
|
||||||
@@ -120,11 +147,33 @@ function renderPreferenceField(
|
|||||||
return field;
|
return field;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Shape currently on screen, per container, so a save can skip a no-op rebuild. */
|
||||||
|
const renderedShapes = new WeakMap<HTMLElement, string>();
|
||||||
|
|
||||||
|
/** Serializes commits per container; see the comment in `save`. */
|
||||||
|
const commitQueues = new WeakMap<HTMLElement, Promise<unknown>>();
|
||||||
|
|
||||||
|
function enqueueCommit(
|
||||||
|
container: HTMLElement,
|
||||||
|
run: () => Promise<SourcePreferenceView[]>,
|
||||||
|
): Promise<SourcePreferenceView[]> {
|
||||||
|
const previous = commitQueues.get(container) ?? Promise.resolve();
|
||||||
|
const result = previous.then(run, run);
|
||||||
|
// Swallow here only so one failed commit does not wedge the queue; the
|
||||||
|
// caller still sees the rejection.
|
||||||
|
commitQueues.set(
|
||||||
|
container,
|
||||||
|
result.catch(() => undefined),
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderPreferences(
|
export function renderPreferences(
|
||||||
container: HTMLElement,
|
container: HTMLElement,
|
||||||
views: SourcePreferenceView[],
|
views: SourcePreferenceView[],
|
||||||
commit: PreferenceCommit,
|
commit: PreferenceCommit,
|
||||||
): void {
|
): void {
|
||||||
|
renderedShapes.set(container, schemaShape(views));
|
||||||
container.replaceChildren(...views.map((view) => renderPreferenceField(container, view, commit)));
|
container.replaceChildren(...views.map((view) => renderPreferenceField(container, view, commit)));
|
||||||
if (views.length === 0) {
|
if (views.length === 0) {
|
||||||
const empty = document.createElement('p');
|
const empty = document.createElement('p');
|
||||||
|
|||||||
@@ -17,6 +17,10 @@ import {
|
|||||||
|
|
||||||
export type InstallStage = 'locating' | 'downloading' | 'verifying' | 'extracting';
|
export type InstallStage = 'locating' | 'downloading' | 'verifying' | 'extracting';
|
||||||
|
|
||||||
|
/** Neither call has a default deadline, so a hung network would stall install. */
|
||||||
|
const RELEASES_TIMEOUT_MS = 30_000;
|
||||||
|
const DOWNLOAD_TIMEOUT_MS = 300_000;
|
||||||
|
|
||||||
export interface InstallProgress {
|
export interface InstallProgress {
|
||||||
stage: InstallStage;
|
stage: InstallStage;
|
||||||
/** 0-1 during download, otherwise null. */
|
/** 0-1 during download, otherwise null. */
|
||||||
@@ -119,6 +123,7 @@ export async function ensureBridgeBinaries(options: EnsureBridgeOptions): Promis
|
|||||||
options.onProgress?.({ stage: 'locating', progress: null });
|
options.onProgress?.({ stage: 'locating', progress: null });
|
||||||
const releasesResponse = await fetchImpl(BUNDLE_RELEASES_URL, {
|
const releasesResponse = await fetchImpl(BUNDLE_RELEASES_URL, {
|
||||||
headers: { Accept: 'application/vnd.github+json' },
|
headers: { Accept: 'application/vnd.github+json' },
|
||||||
|
signal: AbortSignal.timeout(RELEASES_TIMEOUT_MS),
|
||||||
});
|
});
|
||||||
if (!releasesResponse.ok) {
|
if (!releasesResponse.ok) {
|
||||||
throw new Error(`Could not list anime bridge releases (${releasesResponse.status}).`);
|
throw new Error(`Could not list anime bridge releases (${releasesResponse.status}).`);
|
||||||
@@ -129,7 +134,9 @@ export async function ensureBridgeBinaries(options: EnsureBridgeOptions): Promis
|
|||||||
}
|
}
|
||||||
|
|
||||||
options.onProgress?.({ stage: 'downloading', progress: 0 });
|
options.onProgress?.({ stage: 'downloading', progress: 0 });
|
||||||
const downloadResponse = await fetchImpl(asset.downloadUrl);
|
const downloadResponse = await fetchImpl(asset.downloadUrl, {
|
||||||
|
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
||||||
|
});
|
||||||
if (!downloadResponse.ok) {
|
if (!downloadResponse.ok) {
|
||||||
throw new Error(`Downloading the anime bridge failed (${downloadResponse.status}).`);
|
throw new Error(`Downloading the anime bridge failed (${downloadResponse.status}).`);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user