feat(anime): support shared bridge installs and updates

- Prefer configured or package-managed bridge bundles before downloading
- Select compatible upstream releases and offer managed-bundle updates
- Document bridge configuration and package dependencies
This commit is contained in:
2026-09-01 23:14:16 -07:00
parent 1d1575f414
commit 2bcb6c7e98
33 changed files with 1136 additions and 229 deletions
@@ -0,0 +1,234 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { access, mkdir, mkdtemp, readdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
BUNDLE_MARKER_FILE,
readBundleMarker,
writeBundleMarker,
} from '../../anime-bridge/sidecar-bundle';
import {
ensureBridgeBinaries,
findBridgeUpdate,
stageBridgeUpdate,
type EnsureBridgeOptions,
} from './anime-bridge-installer';
/** Lay down the upstream bundle shape: a nested jre plus a versioned server jar. */
async function writeBundle(dir: string, jarName: string): Promise<void> {
await mkdir(path.join(dir, 'jre', 'bin'), { recursive: true });
await writeFile(path.join(dir, 'jre', 'bin', 'java'), '');
await writeFile(path.join(dir, jarName), '');
}
async function exists(file: string): Promise<boolean> {
try {
await access(file);
return true;
} catch {
return false;
}
}
const LATEST = 'v1.0.6.2';
const downloadUrl = (tag: string) => `https://example.test/${tag}/linux-x64-bundle.zip`;
/**
* Upstream's release list answered from memory: a newer release, an older one,
* an iOS runtime release with no desktop bundle, and one below the minimum.
*/
function fakeUpstream() {
const calls: string[] = [];
const bytes = new TextEncoder().encode('zip bytes');
const asset = (tag: string) => ({
name: 'linux-x64-bundle.zip',
browser_download_url: downloadUrl(tag),
size: bytes.length,
});
const fetchImpl: typeof fetch = async (input) => {
const url = String(input);
calls.push(url);
if (url.includes('/releases?')) {
return new Response(
JSON.stringify([
{ tag_name: 'ios-runtime-v7', assets: [{ name: 'MExtensionServer-ios.jar' }] },
{ tag_name: 'v1.0.6.1', assets: [asset('v1.0.6.1')] },
{ tag_name: LATEST, assets: [asset(LATEST)] },
{ tag_name: 'v1.0.5.0', assets: [asset('v1.0.5.0')] },
]),
);
}
if (url === downloadUrl(LATEST)) return new Response(bytes);
return new Response('not found', { status: 404 });
};
const options = {
platform: 'linux',
arch: 'x64',
fetchImpl,
extractImpl: async (_zipPath: string, targetDir: string) =>
writeBundle(targetDir, `MExtensionServer-${LATEST}.jar`),
} satisfies Partial<EnsureBridgeOptions>;
return { calls, options };
}
async function tempRoot(): Promise<string> {
return mkdtemp(path.join(tmpdir(), 'subminer-bridge-install-'));
}
test('anime.bridgeDir wins over every other location and is never downloaded', async () => {
const root = await tempRoot();
const configured = path.join(root, 'configured');
await writeBundle(configured, 'MExtensionServer-v1.0.6.2.jar');
const system = path.join(root, 'system');
await writeBundle(system, 'MExtensionServer-v1.0.6.1.jar');
const { calls, options } = fakeUpstream();
const install = await ensureBridgeBinaries({
...options,
installDir: path.join(root, 'managed'),
configuredDir: configured,
systemDirs: [system],
});
assert.equal(install.dir, configured);
assert.equal(install.origin, 'system');
assert.equal(install.version, 'v1.0.6.2');
assert.equal(install.updateAvailable, null);
assert.equal(install.jarPath, path.join(configured, 'MExtensionServer-v1.0.6.2.jar'));
assert.deepEqual(calls, []);
});
test('an anime.bridgeDir without a bundle is an error, not a fallback', async () => {
const root = await tempRoot();
const { options } = fakeUpstream();
await assert.rejects(
ensureBridgeBinaries({
...options,
installDir: path.join(root, 'managed'),
configuredDir: path.join(root, 'empty'),
systemDirs: [],
}),
/anime\.bridgeDir .*holds no java runtime/,
);
});
test('a package-manager install is used ahead of the managed copy', async () => {
const root = await tempRoot();
const system = path.join(root, 'usr', 'share', 'mangatan', 'extension_server');
await writeBundle(system, 'MExtensionServer-v1.0.6.2.jar');
const managed = path.join(root, 'managed');
await writeBundle(managed, 'MExtensionServer-v1.0.6.0.jar');
const { options } = fakeUpstream();
const install = await ensureBridgeBinaries({
...options,
installDir: managed,
systemDirs: [path.join(root, 'missing'), system],
});
assert.equal(install.dir, system);
assert.equal(install.origin, 'system');
assert.equal(install.version, 'v1.0.6.2');
});
test('a managed install reports the marker tag over the jar name and is not re-downloaded', async () => {
const root = await tempRoot();
const managed = path.join(root, 'managed');
// The jar name says one thing, the marker another: the marker is what we installed.
await writeBundle(managed, 'MExtensionServer-v1.0.6.0.jar');
await writeBundleMarker(managed, 'v1.0.5.0');
const { calls, options } = fakeUpstream();
const install = await ensureBridgeBinaries({ ...options, installDir: managed, systemDirs: [] });
assert.equal(install.origin, 'managed');
assert.equal(install.version, 'v1.0.5.0');
assert.deepEqual(calls, []);
});
test('a managed install from before the marker reads its version off the jar', async () => {
const root = await tempRoot();
const managed = path.join(root, 'managed');
await writeBundle(managed, 'MExtensionServer-v1.0.6.0-r1.jar');
const { options } = fakeUpstream();
const install = await ensureBridgeBinaries({ ...options, installDir: managed, systemDirs: [] });
assert.equal(install.version, 'v1.0.6.0');
});
test('with nothing installed the newest release is downloaded and marked', async () => {
const root = await tempRoot();
const managed = path.join(root, 'managed');
const { calls, options } = fakeUpstream();
const stages: string[] = [];
const install = await ensureBridgeBinaries({
...options,
installDir: managed,
systemDirs: [],
onProgress: (progress) => stages.push(progress.stage),
});
assert.equal(install.origin, 'managed');
assert.equal(install.dir, managed);
assert.equal(install.version, LATEST);
assert.equal(calls.length, 2);
assert.equal(calls[1], downloadUrl(LATEST));
assert.equal(await readBundleMarker(managed), LATEST);
assert.deepEqual([...new Set(stages)], ['locating', 'downloading', 'extracting']);
// The archive is not left behind next to the unpacked bundle.
assert.ok(!(await readdir(managed)).some((entry) => entry.endsWith('.zip')));
});
test('findBridgeUpdate offers the newest release only to a managed install that is behind it', async () => {
const { calls, options } = fakeUpstream();
assert.equal(await findBridgeUpdate({ origin: 'managed', version: 'v1.0.6.0' }, options), LATEST);
assert.equal(await findBridgeUpdate({ origin: 'managed', version: LATEST }, options), null);
assert.equal(await findBridgeUpdate({ origin: 'managed', version: 'v1.0.7.0' }, options), null);
// An unreadable version is offered the newest release as the way back to a known state.
assert.equal(await findBridgeUpdate({ origin: 'managed', version: null }, options), LATEST);
assert.equal(calls.length, 4);
// A system install is pacman's, so upstream is not even asked.
assert.equal(await findBridgeUpdate({ origin: 'system', version: 'v1.0.0.0' }, options), null);
assert.equal(calls.length, 4);
});
test('findBridgeUpdate propagates a failed release listing', async () => {
const fetchImpl: typeof fetch = async () => new Response('rate limited', { status: 403 });
await assert.rejects(
findBridgeUpdate(
{ origin: 'managed', version: 'v1.0.6.0' },
{ platform: 'linux', arch: 'x64', fetchImpl },
),
/Could not list anime bridge releases \(403\)/,
);
});
test('stageBridgeUpdate downloads beside the install and commit swaps it in', async () => {
const root = await tempRoot();
const managed = path.join(root, 'managed');
await writeBundle(managed, 'MExtensionServer-v1.0.5.0.jar');
await writeBundleMarker(managed, 'v1.0.5.0');
const { options } = fakeUpstream();
const staged = await stageBridgeUpdate({ ...options, installDir: managed, systemDirs: [] });
assert.equal(staged.version, LATEST);
// The running install is untouched until commit.
assert.ok(await exists(path.join(managed, 'MExtensionServer-v1.0.5.0.jar')));
assert.equal(await readBundleMarker(managed), 'v1.0.5.0');
assert.ok(await exists(path.join(`${managed}.next`, `MExtensionServer-${LATEST}.jar`)));
const install = await staged.commit();
assert.equal(install.dir, managed);
assert.equal(install.version, LATEST);
assert.equal(install.jarPath, path.join(managed, `MExtensionServer-${LATEST}.jar`));
assert.ok(!(await exists(path.join(managed, 'MExtensionServer-v1.0.5.0.jar'))));
assert.ok(!(await exists(`${managed}.next`)));
assert.ok(await exists(path.join(managed, BUNDLE_MARKER_FILE)));
});
+145 -32
View File
@@ -1,22 +1,34 @@
import { spawn } from 'node:child_process';
import { chmod, mkdir, rm, writeFile } from 'node:fs/promises';
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import {
bundleReleaseUrl,
bundleVersionFromJar,
compareBundleVersions,
findBundleBinaries,
PINNED_BUNDLE_TAG,
readBundleMarker,
resolveBundleAssetName,
selectBundleAsset,
verifyPinnedBundle,
systemBundleDirs,
writeBundleMarker,
type BundleAsset,
type BundleBinaries,
} from '../../anime-bridge/sidecar-bundle';
import type { AnimeBrowserBridgeInstall } from '../../types/anime-browser';
/**
* Downloads and unpacks the M-Extension-Server bundle that runs Aniyomi
* extension APKs. The bundle ships its own JRE, so no system Java is needed.
* Locates the M-Extension-Server bundle that runs Aniyomi extension APKs, or
* downloads and unpacks it. The bundle ships its own JRE, so no system Java is
* needed.
*
* Resolution order: an explicit `anime.bridgeDir`, then a package-manager
* install (the AUR `mangatan-extension-server` package on Arch), then the copy
* SubMiner manages under userData. Only the managed copy is ever downloaded or
* updated; the others belong to whoever put them there. Downloads take the
* newest upstream release that ships a bundle for this platform.
*/
export type InstallStage = 'locating' | 'downloading' | 'verifying' | 'extracting';
export type InstallStage = 'locating' | 'downloading' | 'extracting';
/** Neither call has a default deadline, so a hung network would stall install. */
const RELEASES_TIMEOUT_MS = 30_000;
@@ -40,12 +52,24 @@ export interface InstallProgress {
progress: number | null;
}
export interface EnsureBridgeOptions {
/** Directory the bundle is unpacked into, e.g. `<userData>/anime-bridge`. */
installDir: string;
/** The binaries to launch plus where they came from, for the browser to show. */
export type BridgeInstall = BundleBinaries & AnimeBrowserBridgeInstall;
export interface BridgeReleaseOptions {
platform?: string;
arch?: string;
fetchImpl?: typeof fetch;
}
export interface EnsureBridgeOptions extends BridgeReleaseOptions {
/** Directory the managed bundle is unpacked into, e.g. `<userData>/anime-bridge`. */
installDir: string;
/** `anime.bridgeDir`: a bundle the user pointed at. Must hold a usable bundle. */
configuredDir?: string;
/** Package-manager locations to check before downloading. Defaults per platform. */
systemDirs?: string[];
/** Replaces the unzip/tar extraction. Tests only. */
extractImpl?: (zipPath: string, targetDir: string) => Promise<void>;
onProgress?: (progress: InstallProgress) => void;
}
@@ -123,14 +147,8 @@ async function downloadWithProgress(
return merged;
}
/**
* Return the bundle's java + jar paths, downloading the release first if the
* install directory does not already hold a usable copy.
*/
export async function ensureBridgeBinaries(options: EnsureBridgeOptions): Promise<BundleBinaries> {
const existing = await findBundleBinaries(options.installDir);
if (existing) return existing;
/** The newest upstream release that ships this platform's bundle. */
async function locateLatestBundle(options: BridgeReleaseOptions): Promise<BundleAsset> {
const platform = options.platform ?? process.platform;
const arch = options.arch ?? process.arch;
const fetchImpl = options.fetchImpl ?? fetch;
@@ -143,20 +161,59 @@ export async function ensureBridgeBinaries(options: EnsureBridgeOptions): Promis
);
}
options.onProgress?.({ stage: 'locating', progress: null });
const releasesResponse = await fetchImpl(bundleReleaseUrl(), {
headers: { Accept: 'application/vnd.github+json' },
signal: AbortSignal.timeout(RELEASES_TIMEOUT_MS),
});
if (!releasesResponse.ok) {
throw new Error(
`Could not read anime bridge release ${PINNED_BUNDLE_TAG} (${releasesResponse.status}).`,
);
throw new Error(`Could not list anime bridge releases (${releasesResponse.status}).`);
}
const asset = selectBundleAsset(await releasesResponse.json(), assetName);
if (asset === null) {
throw new Error(`Anime bridge release ${PINNED_BUNDLE_TAG} has no ${assetName}.`);
throw new Error(`No anime bridge release ships ${assetName} at a supported version.`);
}
return asset;
}
/**
* The newest release a managed install could move to, or null when it is
* current or is not SubMiner's to update. An install whose version cannot be
* read is offered the newest release: re-downloading is the way back to a
* known state. Network errors propagate; the caller decides how loudly.
*/
export async function findBridgeUpdate(
install: Pick<AnimeBrowserBridgeInstall, 'origin' | 'version'>,
options: BridgeReleaseOptions = {},
): Promise<string | null> {
if (install.origin !== 'managed') return null;
const latest = await locateLatestBundle(options);
if (install.version === null) return latest.tagName;
return compareBundleVersions(latest.tagName, install.version) > 0 ? latest.tagName : null;
}
async function describeInstall(
binaries: BundleBinaries,
dir: string,
origin: AnimeBrowserBridgeInstall['origin'],
): Promise<BridgeInstall> {
const version =
(origin === 'managed' ? await readBundleMarker(dir) : null) ??
bundleVersionFromJar(binaries.jarPath);
// The update check needs the network and runs once the bridge is up.
return { ...binaries, dir, origin, version, updateAvailable: null };
}
/**
* Download the newest release into `targetDir` and unpack it. The directory
* is created if needed and is not cleared first.
*/
async function downloadLatestBundle(
targetDir: string,
options: EnsureBridgeOptions,
): Promise<{ binaries: BundleBinaries; tagName: string }> {
options.onProgress?.({ stage: 'locating', progress: null });
const asset = await locateLatestBundle(options);
const fetchImpl = options.fetchImpl ?? fetch;
options.onProgress?.({ stage: 'downloading', progress: 0 });
const downloadResponse = await fetchImpl(asset.downloadUrl, {
@@ -169,27 +226,83 @@ export async function ensureBridgeBinaries(options: EnsureBridgeOptions): Promis
options.onProgress?.({ stage: 'downloading', progress: fraction }),
);
options.onProgress?.({ stage: 'verifying', progress: null });
const verification = verifyPinnedBundle(assetName, bytes);
if (!verification.ok) throw new Error(verification.reason);
options.onProgress?.({ stage: 'extracting', progress: null });
await mkdir(options.installDir, { recursive: true });
const zipPath = path.join(options.installDir, assetName);
await mkdir(targetDir, { recursive: true });
const zipPath = path.join(targetDir, asset.assetName);
await writeFile(zipPath, bytes);
try {
await extractZip(zipPath, options.installDir);
await (options.extractImpl ?? extractZip)(zipPath, targetDir);
} finally {
await rm(zipPath, { force: true });
}
const binaries = await findBundleBinaries(options.installDir);
const binaries = await findBundleBinaries(targetDir);
if (!binaries) {
throw new Error('The anime bridge bundle unpacked without a java runtime or server jar.');
}
// Some extractors drop the executable bit; restore it rather than failing at spawn.
if (platform !== 'win32') {
if ((options.platform ?? process.platform) !== 'win32') {
await chmod(binaries.javaPath, 0o755).catch(() => undefined);
}
return binaries;
await writeBundleMarker(targetDir, asset.tagName);
return { binaries, tagName: asset.tagName };
}
/**
* Return the bridge's java + jar paths, downloading the newest release into
* the managed directory only when no other install is usable.
*/
export async function ensureBridgeBinaries(options: EnsureBridgeOptions): Promise<BridgeInstall> {
const configuredDir = options.configuredDir?.trim();
if (configuredDir) {
const configured = await findBundleBinaries(configuredDir);
if (!configured) {
throw new Error(
`anime.bridgeDir (${configuredDir}) holds no java runtime or MExtensionServer jar.`,
);
}
return describeInstall(configured, configuredDir, 'system');
}
for (const dir of options.systemDirs ?? systemBundleDirs(options.platform ?? process.platform)) {
const system = await findBundleBinaries(dir);
if (system) return describeInstall(system, dir, 'system');
}
const existing = await findBundleBinaries(options.installDir);
if (existing) return describeInstall(existing, options.installDir, 'managed');
const downloaded = await downloadLatestBundle(options.installDir, options);
return describeInstall(downloaded.binaries, options.installDir, 'managed');
}
export interface StagedBridgeUpdate {
version: string;
/**
* Replace the managed install with the staged one. Call only once the old
* bridge has stopped: on Windows its open files cannot be removed, and on
* every platform a JVM still running out of the old tree would outlive it.
*/
commit: () => Promise<BridgeInstall>;
}
/**
* Download the newest release next to the managed install without touching
* it, so a failed download leaves the running bridge as it was.
*/
export async function stageBridgeUpdate(options: EnsureBridgeOptions): Promise<StagedBridgeUpdate> {
const stagingDir = `${options.installDir}.next`;
await rm(stagingDir, { recursive: true, force: true });
const downloaded = await downloadLatestBundle(stagingDir, options);
return {
version: downloaded.tagName,
commit: async () => {
await rm(options.installDir, { recursive: true, force: true });
await rename(stagingDir, options.installDir);
const binaries = await findBundleBinaries(options.installDir);
if (!binaries)
throw new Error('The updated anime bridge is missing its java runtime or jar.');
return describeInstall(binaries, options.installDir, 'managed');
},
};
}
@@ -48,6 +48,7 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
registerSession(deps, event, sessionId);
return runtime.ensureBridge();
});
handle(channels.animeBrowserUpdateBridge, () => runtime.updateBridge());
handle(channels.animeBrowserSelectSource, (event, sessionId, sourceId) =>
runtime.selectSource(String(sourceId), registerSession(deps, event, sessionId)),
);
@@ -0,0 +1,210 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type { AnimeBridgeClient } from '../../anime-bridge/bridge-client';
import type { AnimeBrowserBridgeState } from '../../types/anime-browser';
import type { BridgeInstall, StagedBridgeUpdate } from './anime-bridge-installer';
import { createAnimeBrowserRuntime, type AnimeBrowserRuntimeDeps } from './anime-browser-runtime';
// The installer never fills `updateAvailable`; the runtime asks upstream later.
const OLD: BridgeInstall = {
javaPath: '/managed/jre/bin/java',
jarPath: '/managed/MExtensionServer-v1.0.5.0.jar',
dir: '/managed',
origin: 'managed',
version: 'v1.0.5.0',
updateAvailable: null,
};
const NEW: BridgeInstall = { ...OLD, version: 'v1.0.6.0' };
/** Upstream's newest release, as the update check would report it. */
const LATEST = 'v1.0.6.0';
const tick = () => new Promise((resolve) => setTimeout(resolve, 10));
async function setup(overrides: Partial<AnimeBrowserRuntimeDeps> = {}) {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-anime-bridge-update-'));
const states: AnimeBrowserBridgeState[] = [];
const stopped: number[] = [];
let started = 0;
let current = OLD;
const runtime = createAnimeBrowserRuntime({
extensionsDir: () => dir,
repos: () => [],
setRepos: () => undefined,
preferencesFile: path.join(dir, 'preferences.json'),
ensureBinaries: async () => current,
checkBridgeUpdate: async (install) => (install.version === LATEST ? null : LATEST),
stageBridgeUpdate: async (onProgress) => {
onProgress({ stage: 'downloading', progress: 0.5 });
return {
version: NEW.version!,
commit: async () => {
current = NEW;
return NEW;
},
};
},
sendMpvCommand: () => undefined,
ensureMpvConnected: async () => true,
onBridgeState: (state) => states.push(state),
log: () => undefined,
startSidecar: async () => {
const id = ++started;
return {
client: { listAnimeSources: async () => [] } as unknown as AnimeBridgeClient,
baseUrl: `http://127.0.0.1:${id}`,
port: id,
stop: async () => {
stopped.push(id);
},
onExit: () => undefined,
};
},
startStreamStripProxy: async () => ({
origin: 'http://127.0.0.1:9',
port: 9,
close: async () => undefined,
}),
...overrides,
});
return { runtime, states, stopped, started: () => started };
}
test('the bridge state says where the running bridge came from, then learns about an update', async () => {
const { runtime, states } = await setup();
const state = await runtime.ensureBridge();
assert.equal(state.stage, 'ready');
assert.equal(state.install?.origin, 'managed');
assert.equal(state.install?.version, 'v1.0.5.0');
assert.equal(state.install?.dir, '/managed');
// The upstream check is kicked off after ready, off the start path, and
// re-broadcasts the ready state once it answers.
await tick();
const latest = runtime.getSnapshot().bridge;
assert.equal(latest.stage, 'ready');
assert.equal(latest.install?.updateAvailable, LATEST);
assert.equal(states.at(-1)?.install?.updateAvailable, LATEST);
});
test('a failed update check is logged and leaves the bridge ready', async () => {
const logged: string[] = [];
const { runtime } = await setup({
checkBridgeUpdate: async () => {
throw new Error('rate limited');
},
log: (message) => logged.push(message),
});
await runtime.ensureBridge();
await tick();
const state = runtime.getSnapshot().bridge;
assert.equal(state.stage, 'ready');
assert.equal(state.install?.updateAvailable, null);
assert.ok(logged.some((line) => /update check failed: rate limited/.test(line)));
});
test('a system install is never asked about updates', async () => {
let asked = 0;
const { runtime } = await setup({
ensureBinaries: async () => ({ ...OLD, origin: 'system' }),
checkBridgeUpdate: async () => {
asked += 1;
return LATEST;
},
});
await runtime.ensureBridge();
await tick();
assert.equal(asked, 0);
assert.equal(runtime.getSnapshot().bridge.install?.updateAvailable, null);
});
test('updateBridge stages, stops the old bridge, and restarts on the new install', async () => {
const { runtime, states, stopped, started } = await setup();
await runtime.ensureBridge();
await tick();
assert.equal(runtime.getSnapshot().bridge.install?.updateAvailable, LATEST);
const state = await runtime.updateBridge();
assert.deepEqual(stopped, [1]);
assert.equal(started(), 2);
assert.equal(state.stage, 'ready');
assert.equal(state.install?.version, 'v1.0.6.0');
// Progress from the staging download reached the UI, still against the old install.
const downloading = states.find((candidate) => candidate.stage === 'downloading');
assert.equal(downloading?.progress, 0.5);
assert.equal(downloading?.install?.version, 'v1.0.5.0');
// The post-restart check finds nothing newer, so the button goes away.
await tick();
assert.equal(runtime.getSnapshot().bridge.install?.updateAvailable, null);
});
test('a failed download leaves the running bridge as it was', async () => {
const { runtime, stopped, started } = await setup({
stageBridgeUpdate: async () => {
throw new Error('offline');
},
});
await runtime.ensureBridge();
const state = await runtime.updateBridge();
assert.deepEqual(stopped, []);
assert.equal(started(), 1);
assert.equal(state.stage, 'ready');
assert.match(state.message ?? '', /Bridge update failed: offline/);
});
test('updateBridge refuses to touch a system install', async () => {
const system: BridgeInstall = {
...OLD,
origin: 'system',
dir: '/usr/share/mangatan/extension_server',
};
const { runtime } = await setup({ ensureBinaries: async () => system });
await runtime.ensureBridge();
await assert.rejects(runtime.updateBridge(), /managed outside SubMiner/);
});
test('requests that arrive mid-update wait for the new bridge instead of starting the old one', async () => {
let releaseCommit: () => void = () => undefined;
const commitGate = new Promise<void>((resolve) => {
releaseCommit = resolve;
});
let current = OLD;
const staged: StagedBridgeUpdate = {
version: 'v1.0.6.0',
commit: async () => {
await commitGate;
current = NEW;
return NEW;
},
};
const { runtime, started } = await setup({
ensureBinaries: async () => current,
stageBridgeUpdate: async () => staged,
});
await runtime.ensureBridge();
const update = runtime.updateBridge();
// Let the update get past stopping the old bridge and into the gated commit.
await new Promise((resolve) => setTimeout(resolve, 10));
let ensured: AnimeBrowserBridgeState | null = null;
const waiting = runtime.ensureBridge().then((state) => {
ensured = state;
});
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(ensured, null);
assert.equal(started(), 1);
releaseCommit();
await update;
await waiting;
assert.equal(started(), 2);
assert.equal(ensured!.install?.version, 'v1.0.6.0');
});
+16 -3
View File
@@ -8,15 +8,15 @@ import type {
export type StreamWatchMark = StreamPlaybackMetadataInput;
import type { PlaybackEndFileEvent } from '../../anime-bridge/playback-outcome';
import type { SubtitleCacheIo } from '../../anime-bridge/subtitle-cache';
import type { BundleBinaries } from '../../anime-bridge/sidecar-bundle';
import { startSidecar } from '../../anime-bridge/sidecar-process';
import { startStreamStripProxy } from '../../anime-bridge/stream-strip-proxy';
import type {
AnimeBrowserBridgeInstall,
AnimeBrowserBridgeState,
AnimeBrowserQueueState,
AnimeBrowserSearchUpdate,
} from '../../types/anime-browser';
import type { InstallProgress } from './anime-bridge-installer';
import type { BridgeInstall, InstallProgress, StagedBridgeUpdate } from './anime-bridge-installer';
export interface AnimeBrowserRuntimeDeps {
/** Where user-supplied Aniyomi extension APKs live. Read lazily so config edits apply. */
@@ -27,7 +27,20 @@ export interface AnimeBrowserRuntimeDeps {
setRepos: (repos: string[]) => void;
/** JSON file holding each source's saved preference values. */
preferencesFile: string;
ensureBinaries: (onProgress: (progress: InstallProgress) => void) => Promise<BundleBinaries>;
/** Locates (or first downloads) the bridge and says where it came from. */
ensureBinaries: (onProgress: (progress: InstallProgress) => void) => Promise<BridgeInstall>;
/**
* The newest upstream release a managed install could move to, or null.
* Asked once the bridge is running; a rejection is logged, not shown.
*/
checkBridgeUpdate: (install: AnimeBrowserBridgeInstall) => Promise<string | null>;
/**
* Downloads the newest release beside the managed install; the returned
* `commit` swaps it in once the old bridge has stopped.
*/
stageBridgeUpdate: (
onProgress: (progress: InstallProgress) => void,
) => Promise<StagedBridgeUpdate>;
/** Sends mpv an IPC command; same transport the Jellyfin path uses. */
sendMpvCommand: (command: Array<string | number>) => void;
/** Brings mpv up if it is not already connected. Resolves false on failure. */
@@ -31,6 +31,10 @@ async function setupRuntime(
setRepos: () => undefined,
preferencesFile,
ensureBinaries: async () => ({}) as never,
checkBridgeUpdate: async () => null,
stageBridgeUpdate: async () => {
throw new Error('not under test');
},
sendMpvCommand: () => undefined,
ensureMpvConnected: async () => true,
onBridgeState: () => undefined,
+118 -13
View File
@@ -32,6 +32,7 @@ import { applyPreferenceValue, parsePreferences } from '../../anime-bridge/prefe
import type { SourcePreferenceView } from '../../anime-bridge/preferences';
import { ALL_SOURCES_ID } from '../../types/anime-browser';
import type {
AnimeBrowserBridgeInstall,
AnimeBrowserBridgeState,
AnimeBrowserDetails,
AnimeBrowserEntry,
@@ -54,13 +55,23 @@ import { createAnimeBrowserQueue } from './anime-browser-queue';
import type { AnimeBrowserRuntimeDeps } from './anime-browser-runtime-deps';
export type { AnimeBrowserRuntimeDeps } from './anime-browser-runtime-deps';
const IDLE_STATE: AnimeBrowserBridgeState = { stage: 'idle', progress: null, message: null };
const IDLE_STATE: AnimeBrowserBridgeState = {
stage: 'idle',
progress: null,
message: null,
install: null,
};
/** Stage, progress and message; `install` is carried across every state change. */
type BridgeStateChange = Omit<AnimeBrowserBridgeState, 'install'>;
export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
let bridgeState: AnimeBrowserBridgeState = IDLE_STATE;
let install: AnimeBrowserBridgeInstall | null = null;
let sidecar: SidecarHandle | null = null;
let stripProxy: StreamStripProxyHandle | null = null;
let starting: Promise<SidecarHandle> | null = null;
let updating: Promise<AnimeBrowserBridgeState> | null = null;
let extensions: InstalledExtension[] = [];
let sources: ExtensionSource[] = [];
let loadFailures: ExtensionLoadFailure[] = [];
@@ -78,9 +89,9 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
return created;
}
function setState(state: AnimeBrowserBridgeState): void {
bridgeState = state;
deps.onBridgeState(state);
function setState(change: BridgeStateChange): void {
bridgeState = { ...change, install };
deps.onBridgeState(bridgeState);
}
async function sourceFor(sourceId: string) {
@@ -119,13 +130,23 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
}
async function startBridge(): Promise<SidecarHandle> {
const binaries = await deps.ensureBinaries((progress) =>
const resolved = await deps.ensureBinaries((progress) =>
setState({ stage: progress.stage, progress: progress.progress, message: null }),
);
install = {
origin: resolved.origin,
version: resolved.version,
dir: resolved.dir,
updateAvailable: resolved.updateAvailable,
};
deps.log(
`[anime-browser] bridge ${resolved.version ?? 'unknown version'} (${resolved.origin}) ` +
`from ${resolved.dir}`,
);
setState({ stage: 'starting', progress: null, message: null });
const handle = await (deps.startSidecar ?? startSidecar)({
binaries,
binaries: resolved,
onLog: (line) => deps.log(`[anime-bridge] ${line}`),
});
sidecar = handle;
@@ -167,9 +188,33 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
}
await scanExtensions(handle);
void checkForBridgeUpdate(handle);
return handle;
}
/**
* Ask upstream whether a managed install is behind, after the bridge is up
* so a slow or failed GitHub call never delays a search. The answer lands
* in `install.updateAvailable` and is re-broadcast on the current state.
*/
async function checkForBridgeUpdate(handle: SidecarHandle): Promise<void> {
if (install === null || install.origin !== 'managed') return;
try {
const latest = await deps.checkBridgeUpdate(install);
// The bridge may have been restarted or updated while we waited.
if (sidecar !== handle || install === null || latest === install.updateAvailable) return;
install = { ...install, updateAvailable: latest };
if (latest !== null) deps.log(`[anime-browser] bridge update available: ${latest}`);
setState({
stage: bridgeState.stage,
progress: bridgeState.progress,
message: bridgeState.message,
});
} catch (error) {
deps.log(`[anime-browser] bridge update check failed: ${describeError(error)}`);
}
}
/**
* Re-read the extensions directory and ask the bridge what each APK provides.
* Called on start and after any install or removal.
@@ -203,7 +248,25 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
});
}
/** Stop the bridge and its proxy on purpose, without disturbing playback state. */
async function stopBridge(): Promise<void> {
const handle = sidecar;
const proxy = stripProxy;
sidecar = null;
stripProxy = null;
starting = null;
await proxy?.close();
await handle?.stop();
}
async function ensureBridge(): Promise<AnimeBrowserBridgeState> {
// A request that lands while an update is swapping directories would start
// the old bridge out of a tree that is about to be deleted.
if (updating) await updating;
return startIfNeeded();
}
async function startIfNeeded(): Promise<AnimeBrowserBridgeState> {
if (sidecar) return bridgeState;
// Collapse concurrent callers onto one start; the UI calls this eagerly.
if (!starting) {
@@ -221,6 +284,52 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
return bridgeState;
}
/**
* Move a managed install to the newest release: download beside it while
* the old bridge keeps serving, then stop, swap, and restart. A failed
* download leaves the old bridge running; a failed swap is reported as a
* failed start, which the next request retries.
*/
function updateBridge(): Promise<AnimeBrowserBridgeState> {
if (updating) return updating;
if (install && install.origin !== 'managed') {
return Promise.reject(
new Error(`The bridge in ${install.dir} is managed outside SubMiner; update it there.`),
);
}
updating = (async () => {
let staged;
try {
staged = await deps.stageBridgeUpdate((progress) =>
setState({ stage: progress.stage, progress: progress.progress, message: null }),
);
} catch (error) {
setState({
stage: sidecar ? 'ready' : 'failed',
progress: null,
message: `Bridge update failed: ${describeError(error)}`,
});
return bridgeState;
}
await stopBridge();
try {
await staged.commit();
} catch (error) {
setState({
stage: 'failed',
progress: null,
message: `Bridge update failed: ${describeError(error)}`,
});
return bridgeState;
}
// Re-resolves the install from disk and re-checks upstream once it is up.
return startIfNeeded();
})().finally(() => {
updating = null;
});
return updating;
}
async function installExtensionFrom(extension: RepoExtension): Promise<void> {
await installExtension({ extensionsDir: deps.extensionsDir(), extension });
if (sidecar) await scanExtensions(sidecar);
@@ -404,6 +513,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
},
ensureBridge,
updateBridge,
/**
* Extensions available from the configured repositories, annotated with
@@ -670,16 +780,11 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
},
async dispose(): Promise<void> {
const handle = sidecar;
const proxy = stripProxy;
sidecar = null;
stripProxy = null;
starting = null;
const stopping = stopBridge();
setState(IDLE_STATE);
await queue.dispose();
await playback.dispose();
await proxy?.close();
await handle?.stop();
await stopping;
},
};
}
@@ -20,6 +20,10 @@ async function setupRuntime(overrides: Partial<AnimeBrowserRuntimeDeps> = {}) {
setRepos: () => undefined,
preferencesFile: path.join(dir, 'preferences.json'),
ensureBinaries: async () => ({}) as never,
checkBridgeUpdate: async () => null,
stageBridgeUpdate: async () => {
throw new Error('not under test');
},
sendMpvCommand: () => undefined,
ensureMpvConnected: async () => true,
onBridgeState: () => undefined,