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:
2026-07-31 17:57:01 -07:00
parent e64ff1a0ee
commit f5e98dfa9d
19 changed files with 567 additions and 81 deletions
+22 -4
View File
@@ -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);
}); });
+33 -6
View File
@@ -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`);
+68 -7
View File
@@ -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 });
} }
+20
View File
@@ -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), []);
+12 -1
View File
@@ -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({
+34 -21
View File
@@ -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' }];
}); });
+34 -6
View File
@@ -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 } : {}),
};
} }
/** /**
+7
View File
@@ -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({}), '');
}); });
+4 -2
View File
@@ -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(',');
} }
+11
View File
@@ -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'));
+5 -1
View File
@@ -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}`;
} }
/** /**
+69
View File
@@ -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' }]);
});
+48 -15
View File
@@ -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;
}
} }
} }
+47
View File
@@ -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/,
);
});
+33 -4
View File
@@ -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
View File
@@ -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');
} }
})(); })();
+51 -2
View File
@@ -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');
+8 -1
View File
@@ -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}).`);
} }