feat(anime): add version-aware extension updates

- Compare installed APK version codes before offering updates
- Add update status labels and an Update all action
This commit is contained in:
2026-09-02 19:10:03 -07:00
parent 484a9e047d
commit 18beac13f4
22 changed files with 887 additions and 81 deletions
@@ -29,3 +29,19 @@ test('anime browser preference IPC coerces values at the renderer boundary', ()
['source', 'invalid', ''],
]);
});
test('anime browser bulk update IPC returns the runtime result', async () => {
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
registerAnimeBrowserIpcHandlers({
ipcMain: {
handle: (channel, listener) => handlers.set(channel, listener),
},
runtime: {
updateAllExtensions: async () => 3,
} as never,
});
const updateAll = handlers.get(IPC_CHANNELS.request.animeBrowserUpdateAllExtensions);
assert.ok(updateAll);
assert.equal(await updateAll({}), 3);
});
@@ -82,6 +82,7 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
handle(channels.animeBrowserInstallExtension, (_event, pkg) =>
runtime.installExtension(String(pkg)),
);
handle(channels.animeBrowserUpdateAllExtensions, () => runtime.updateAllExtensions());
handle(channels.animeBrowserRemoveExtension, (_event, pkg) =>
runtime.removeExtension(String(pkg)),
);
@@ -0,0 +1,137 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { existsSync } from 'node:fs';
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type { AnimeBridgeClient } from '../../anime-bridge/bridge-client';
import { createAnimeBrowserRuntime } from './anime-browser-runtime';
const REPO_URL = 'https://repo.example/anime/index.json';
const PKG = 'eu.kanade.tachiyomi.animeextension.all.example';
function createTestRuntime(
directory: string,
client: AnimeBridgeClient,
repos: readonly string[] = [],
) {
return createAnimeBrowserRuntime({
extensionsDir: () => directory,
repos: () => [...repos],
setRepos: () => undefined,
preferencesFile: path.join(directory, 'preferences.json'),
ensureBinaries: async () => ({}) as never,
checkBridgeUpdate: async () => null,
stageBridgeUpdate: async () => {
throw new Error('not under test');
},
sendMpvCommand: () => undefined,
ensureMpvConnected: async () => true,
onBridgeState: () => undefined,
log: () => undefined,
startSidecar: async () => ({
client,
baseUrl: 'http://127.0.0.1:12345',
port: 12345,
stop: async () => undefined,
onExit: () => undefined,
}),
startStreamStripProxy: async () => ({
origin: 'http://127.0.0.1:12346',
port: 12346,
close: async () => undefined,
}),
});
}
test('extension mutations run in request order while a download is pending', async () => {
const directory = await mkdtemp(path.join(tmpdir(), 'subminer-extension-mutations-'));
const apkPath = path.join(directory, `${PKG}.apk`);
await writeFile(apkPath, 'old apk');
let notifyDownloadStarted: () => void = () => undefined;
const downloadStarted = new Promise<void>((resolve) => {
notifyDownloadStarted = resolve;
});
let releaseDownload: () => void = () => undefined;
const downloadGate = new Promise<void>((resolve) => {
releaseDownload = resolve;
});
const originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL) => {
if (String(input) === REPO_URL) {
return new Response(
JSON.stringify([
{
name: 'Aniyomi: Example',
pkg: PKG,
apk: 'example.apk',
lang: 'all',
code: 2,
version: '2.0',
},
]),
);
}
notifyDownloadStarted();
await downloadGate;
const body = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x01]) as unknown as BodyInit;
return new Response(body);
}) as typeof fetch;
const client = {
listAnimeSources: async () => [{ id: 'one', name: 'Example', lang: 'all' }],
} as unknown as AnimeBridgeClient;
const runtime = createTestRuntime(directory, client, [REPO_URL]);
try {
await runtime.ensureBridge();
const install = runtime.installExtension(PKG);
await downloadStarted;
const remove = runtime.removeExtension(PKG);
releaseDownload();
await Promise.all([install, remove]);
assert.equal(existsSync(apkPath), false);
} finally {
globalThis.fetch = originalFetch;
await runtime.dispose();
}
});
test('startup scanning cannot restore an extension removed concurrently', async () => {
const directory = await mkdtemp(path.join(tmpdir(), 'subminer-extension-startup-'));
const apkPath = path.join(directory, `${PKG}.apk`);
await writeFile(apkPath, 'old apk');
let notifyScanStarted: () => void = () => undefined;
const scanStarted = new Promise<void>((resolve) => {
notifyScanStarted = resolve;
});
let releaseScan: () => void = () => undefined;
const scanGate = new Promise<void>((resolve) => {
releaseScan = resolve;
});
const client = {
listAnimeSources: async () => {
notifyScanStarted();
await scanGate;
return [{ id: 'one', name: 'Example', lang: 'all' }];
},
} as unknown as AnimeBridgeClient;
const runtime = createTestRuntime(directory, client);
try {
const start = runtime.ensureBridge();
await scanStarted;
const remove = runtime.removeExtension(PKG);
releaseScan();
await Promise.all([start, remove]);
assert.equal(existsSync(apkPath), false);
assert.deepEqual(runtime.getSnapshot().installed, []);
assert.deepEqual(runtime.getSnapshot().sources, []);
} finally {
await runtime.dispose();
}
});
+72 -15
View File
@@ -50,6 +50,7 @@ import type {
ExtensionLoadFailure,
} from '../../types/anime-browser';
import type { BridgeAnimePage, BridgePreference } from '../../anime-bridge/types';
import { findExtensionUpdates, hasExtensionUpdate } from '../../shared/extension-updates';
import { createAnimeBrowserPlayback } from './anime-browser-playback';
import { createAnimeBrowserQueue } from './anime-browser-queue';
import type { AnimeBrowserRuntimeDeps } from './anime-browser-runtime-deps';
@@ -72,6 +73,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
let stripProxy: StreamStripProxyHandle | null = null;
let starting: Promise<SidecarHandle> | null = null;
let updating: Promise<AnimeBrowserBridgeState> | null = null;
let extensionMutationTail = Promise.resolve();
let extensions: InstalledExtension[] = [];
let sources: ExtensionSource[] = [];
let loadFailures: ExtensionLoadFailure[] = [];
@@ -81,6 +83,15 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
>();
const preferenceStore = new PreferenceStore(deps.preferencesFile);
function withExtensionMutation<T>(operation: () => Promise<T>): Promise<T> {
const result = extensionMutationTail.then(operation);
extensionMutationTail = result.then(
() => undefined,
() => undefined,
);
return result;
}
function getBrowserSession(sessionId = 'default') {
const existing = browserSessions.get(sessionId);
if (existing) return existing;
@@ -187,7 +198,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
deps.log(`[anime-browser] stream proxy failed to start: ${describeError(error)}`);
}
await scanExtensions(handle);
await withExtensionMutation(() => scanExtensions(handle));
void checkForBridgeUpdate(handle);
return handle;
}
@@ -336,8 +347,12 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
return updating;
}
async function installExtensionFrom(extension: RepoExtension): Promise<void> {
async function downloadExtension(extension: RepoExtension): Promise<void> {
await installExtension({ extensionsDir: deps.extensionsDir(), extension });
}
async function installExtensionFrom(extension: RepoExtension): Promise<void> {
await downloadExtension(extension);
if (sidecar) await scanExtensions(sidecar);
}
@@ -538,6 +553,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
name: extension.name,
lang: extension.lang,
version: extension.version,
versionCode: extension.versionCode,
nsfw: extension.nsfw,
repoUrl: extension.repoUrl,
iconUrl: extension.iconUrl,
@@ -549,22 +565,61 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
},
/** Download an extension by package name, then rescan. */
async installExtension(pkg: string): Promise<void> {
const repos = deps.repos();
if (repos.length === 0) throw new Error('No extension repository is configured.');
installExtension(pkg: string): Promise<void> {
return withExtensionMutation(async () => {
const repos = deps.repos();
if (repos.length === 0) throw new Error('No extension repository is configured.');
const catalogue = await fetchRepoCatalogue(repos);
const match = catalogue.extensions.find((candidate) => candidate.pkg === pkg);
if (!match) throw new Error(`${pkg} is not offered by any configured repository.`);
const catalogue = await fetchRepoCatalogue(repos);
const match = catalogue.extensions.find((candidate) => candidate.pkg === pkg);
if (!match) throw new Error(`${pkg} is not offered by any configured repository.`);
await installExtensionFrom(match);
const current = extensions.find((candidate) => candidate.fallbackName === pkg);
if (
current &&
current.versionCode !== null &&
!hasExtensionUpdate(current.versionCode, match.versionCode)
) {
return;
}
await installExtensionFrom(match);
});
},
/** Download every strictly newer repository build, then rescan once. */
updateAllExtensions(): Promise<number> {
return withExtensionMutation(async () => {
const repos = deps.repos();
if (repos.length === 0) return 0;
const catalogue = await fetchRepoCatalogue(repos);
const installedVersions = extensions.map((extension) => ({
pkg: extension.fallbackName,
versionCode: extension.versionCode,
}));
const updates = findExtensionUpdates(installedVersions, catalogue.extensions);
let installedCount = 0;
try {
for (const extension of updates) {
await downloadExtension(extension);
installedCount += 1;
}
} finally {
if (installedCount > 0 && sidecar) await scanExtensions(sidecar);
}
return installedCount;
});
},
/** Remove an installed extension, then rescan. */
async removeExtension(pkg: string): Promise<void> {
await removeExtensionFile(deps.extensionsDir(), pkg);
await preferenceStore.clear(pkg).catch(() => undefined);
if (sidecar) await scanExtensions(sidecar);
removeExtension(pkg: string): Promise<void> {
return withExtensionMutation(async () => {
await removeExtensionFile(deps.extensionsDir(), pkg);
await preferenceStore.clear(pkg).catch(() => undefined);
if (sidecar) await scanExtensions(sidecar);
});
},
/**
@@ -586,8 +641,10 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
},
/** Re-read the extensions directory without restarting the bridge. */
async rescanExtensions(): Promise<void> {
if (sidecar) await scanExtensions(sidecar);
rescanExtensions(): Promise<void> {
return withExtensionMutation(async () => {
if (sidecar) await scanExtensions(sidecar);
});
},
selectSource(sourceId: string, sessionId = 'default'): void {