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
+18 -11
View File
@@ -488,18 +488,25 @@ api.onBridgeState(renderBridgeState);
void (async () => {
renderBridgeState({ stage: 'idle', progress: null, message: null });
const state = await api.ensureBridge();
renderBridgeState(state);
try {
const state = await api.ensureBridge();
renderBridgeState(state);
const snapshot = await api.getSnapshot();
renderSources(snapshot.sources, snapshot.selectedSourceId);
const snapshot = await api.getSnapshot();
renderSources(snapshot.sources, snapshot.selectedSourceId);
if (state.stage === 'ready' && snapshot.sources.length > 0) {
searchInput.focus();
await runSearch('');
} else if (state.stage === 'ready') {
setStatus(state.message ?? 'No extensions installed.', 'error');
} else {
setStatus(state.message ?? 'The extension bridge is not available.', 'error');
if (state.stage === 'ready' && snapshot.sources.length > 0) {
searchInput.focus();
await runSearch('');
} else if (state.stage === 'ready') {
setStatus(state.message ?? 'No extensions installed.', 'error');
} else {
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}`);
}
/**
* 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(
container: HTMLElement,
view: SourcePreferenceView,
@@ -46,10 +69,14 @@ function renderPreferenceField(
state.removeAttribute('data-tone');
state.textContent = 'Saving…';
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.textContent = 'Saved';
renderPreferences(container, refreshed, commit);
if (schemaShape(refreshed) !== renderedShapes.get(container)) {
renderPreferences(container, refreshed, commit);
}
} catch (error) {
state.dataset.tone = 'error';
state.textContent = describe(error);
@@ -120,11 +147,33 @@ function renderPreferenceField(
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(
container: HTMLElement,
views: SourcePreferenceView[],
commit: PreferenceCommit,
): void {
renderedShapes.set(container, schemaShape(views));
container.replaceChildren(...views.map((view) => renderPreferenceField(container, view, commit)));
if (views.length === 0) {
const empty = document.createElement('p');