mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -07:00
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:
@@ -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)));
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user