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
+67 -78
View File
@@ -4,14 +4,18 @@ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
BUNDLE_MARKER_FILE,
bundleReleaseUrl,
bundleVersionFromJar,
compareBundleVersions,
findBundleBinaries,
PINNED_BUNDLE_SHA256,
PINNED_BUNDLE_TAG,
MIN_BUNDLE_VERSION,
parseBundleVersion,
readBundleMarker,
resolveBundleAssetName,
selectBundleAsset,
sha256,
verifyPinnedBundle,
systemBundleDirs,
writeBundleMarker,
} from './sidecar-bundle';
test('resolveBundleAssetName maps supported platform/arch pairs', () => {
@@ -27,71 +31,71 @@ test('resolveBundleAssetName returns null for unpublished combinations', () => {
assert.equal(resolveBundleAssetName('freebsd', 'x64'), null);
});
const PINNED_RELEASE = {
tag_name: PINNED_BUNDLE_TAG,
assets: [
{
name: 'macOS-arm64-bundle.zip',
browser_download_url: 'https://example.test/macOS-arm64-bundle.zip',
size: 133_058_560,
},
],
};
function release(tag: string, assetNames: string[], extra: Record<string, unknown> = {}) {
return {
tag_name: tag,
assets: assetNames.map((name) => ({
name,
browser_download_url: `https://example.test/${tag}/${name}`,
size: 1,
})),
...extra,
};
}
test('selectBundleAsset reads the by-tag endpoint payload', () => {
const asset = selectBundleAsset(PINNED_RELEASE, 'macOS-arm64-bundle.zip');
assert.equal(asset?.tagName, PINNED_BUNDLE_TAG);
assert.equal(asset?.downloadUrl, 'https://example.test/macOS-arm64-bundle.zip');
assert.equal(asset?.sizeBytes, 133_058_560);
});
test('selectBundleAsset skips releases without a matching asset', () => {
test('selectBundleAsset takes the newest release that ships the asset', () => {
const releases = [
// The iOS runtime release carries no desktop bundle.
{ tag_name: 'ios-runtime-v7', assets: [{ name: 'MExtensionServer-ios.jar' }] },
PINNED_RELEASE,
release('ios-runtime-v7', ['MExtensionServer-ios.jar']),
release('v1.0.6.1', ['linux-x64-bundle.zip', 'macOS-arm64-bundle.zip']),
// Older than the one below it: list order must not decide.
release('v1.0.6.2', ['linux-x64-bundle.zip']),
];
const asset = selectBundleAsset(releases, 'macOS-arm64-bundle.zip');
assert.equal(asset?.tagName, PINNED_BUNDLE_TAG);
const asset = selectBundleAsset(releases, 'linux-x64-bundle.zip');
assert.equal(asset?.tagName, 'v1.0.6.2');
assert.equal(asset?.downloadUrl, 'https://example.test/v1.0.6.2/linux-x64-bundle.zip');
// The newest release lacks the macOS asset, so the one before it wins there.
assert.equal(selectBundleAsset(releases, 'macOS-arm64-bundle.zip')?.tagName, 'v1.0.6.1');
});
test('selectBundleAsset ignores releases newer than the pin', () => {
test('selectBundleAsset skips releases below the minimum, drafts, and prereleases', () => {
const releases = [
{
tag_name: 'v9.9.9.9',
assets: [
{
name: 'macOS-arm64-bundle.zip',
browser_download_url: 'https://example.test/unpinned.zip',
size: 1,
},
],
},
PINNED_RELEASE,
release('v1.0.5.9', ['linux-x64-bundle.zip']),
release('v2.0.0.0', ['linux-x64-bundle.zip'], { draft: true }),
release('v2.0.0.1', ['linux-x64-bundle.zip'], { prerelease: true }),
];
assert.equal(selectBundleAsset(releases, 'linux-x64-bundle.zip'), null);
const asset = selectBundleAsset(releases, 'macOS-arm64-bundle.zip');
assert.equal(asset?.tagName, PINNED_BUNDLE_TAG);
assert.equal(asset?.downloadUrl, 'https://example.test/macOS-arm64-bundle.zip');
releases.push(release(MIN_BUNDLE_VERSION, ['linux-x64-bundle.zip']));
assert.equal(selectBundleAsset(releases, 'linux-x64-bundle.zip')?.tagName, MIN_BUNDLE_VERSION);
});
test('selectBundleAsset returns null when nothing matches', () => {
assert.equal(
selectBundleAsset([{ tag_name: PINNED_BUNDLE_TAG, assets: [] }], 'linux-x64-bundle.zip'),
null,
);
assert.equal(selectBundleAsset([release('v1.0.6.0', [])], 'linux-x64-bundle.zip'), null);
assert.equal(selectBundleAsset([], 'linux-x64-bundle.zip'), null);
assert.equal(selectBundleAsset({ message: 'rate limited' }, 'linux-x64-bundle.zip'), null);
});
test('bundleReleaseUrl targets the pinned tag', () => {
test('bundleReleaseUrl lists upstream releases', () => {
assert.equal(
bundleReleaseUrl(),
`https://api.github.com/repos/1Selxo/M-Extension-Server/releases/tags/${PINNED_BUNDLE_TAG}`,
'https://api.github.com/repos/1Selxo/M-Extension-Server/releases?per_page=20',
);
});
test('parseBundleVersion and compareBundleVersions order tags numerically', () => {
assert.deepEqual(parseBundleVersion('v1.0.6.2'), [1, 0, 6, 2]);
assert.deepEqual(parseBundleVersion('1.0.10'), [1, 0, 10]);
assert.deepEqual(parseBundleVersion('v1.0.6.0-r1'), [1, 0, 6, 0]);
assert.equal(parseBundleVersion('ios-runtime-v7'), null);
assert.equal(compareBundleVersions('v1.0.6.2', 'v1.0.6.1'), 1);
assert.equal(compareBundleVersions('v1.0.10', 'v1.0.9'), 1);
assert.equal(compareBundleVersions('v1.0.6', 'v1.0.6.0'), 0);
assert.equal(compareBundleVersions('v1.0.5.9', 'v1.0.6.0'), -1);
});
test('findBundleBinaries locates the nested jre and jar', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'subminer-bundle-'));
await mkdir(path.join(root, 'jre', 'jre', 'bin'), { recursive: true });
@@ -121,39 +125,24 @@ test('findBundleBinaries returns null when the bundle is incomplete', async () =
assert.equal(await findBundleBinaries(root), null);
});
test('sha256 produces lowercase hex digests matching known vectors', () => {
const encode = (value: string) => new TextEncoder().encode(value);
assert.equal(
sha256(encode('')),
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
);
assert.equal(
sha256(encode('subminer')),
'f3b7fdb2037add4cd8f122c090a727243b46b1b9d8a6c379f71573e2df120885',
);
test('bundleVersionFromJar reads the tag out of the jar name, dropping rebuild suffixes', () => {
assert.equal(bundleVersionFromJar('/x/MExtensionServer-v1.0.6.0-r1.jar'), 'v1.0.6.0');
assert.equal(bundleVersionFromJar('/x/MExtensionServer-v1.0.6.2.jar'), 'v1.0.6.2');
assert.equal(bundleVersionFromJar('/x/MExtensionServer-1.0.6.0.jar'), 'v1.0.6.0');
assert.equal(bundleVersionFromJar('/x/MExtensionServer.jar'), null);
});
test('verifyPinnedBundle accepts a matching hash and rejects a mismatch', () => {
const asset = 'macOS-arm64-bundle.zip';
const wrong = verifyPinnedBundle(asset, new TextEncoder().encode('not the bundle'));
assert.equal(wrong.ok, false);
assert.match((wrong as { reason: string }).reason, /Checksum mismatch/);
test('bundle marker round-trips and tolerates a missing or malformed file', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'subminer-bundle-'));
assert.equal(await readBundleMarker(root), null);
await writeBundleMarker(root, 'v1.0.6.0');
assert.equal(await readBundleMarker(root), 'v1.0.6.0');
await writeFile(path.join(root, BUNDLE_MARKER_FILE), '{not json');
assert.equal(await readBundleMarker(root), null);
});
test('verifyPinnedBundle refuses an asset that has no pin', () => {
const result = verifyPinnedBundle('windows-x64-bundle.zip', new Uint8Array([1, 2, 3]));
assert.equal(result.ok, false);
assert.match((result as { reason: string }).reason, /No pinned checksum/);
});
test('the pinned tag and hashes are the verified release', () => {
assert.equal(PINNED_BUNDLE_TAG, 'v1.0.6.0');
assert.equal(
PINNED_BUNDLE_SHA256['macOS-arm64-bundle.zip'],
'5f4fb03abfe88bc46ddf5f4d8221156ee2d66b9cbad7c4bc3ade4baf3a4266e6',
);
assert.equal(
PINNED_BUNDLE_SHA256['linux-x64-bundle.zip'],
'c2b869d3905b06a308517fec0b44f70ff76f7212230c60710bba39a7025c3a69',
);
test('systemBundleDirs names the AUR package location on Linux only', () => {
assert.deepEqual(systemBundleDirs('linux'), ['/usr/share/mangatan/extension_server']);
assert.deepEqual(systemBundleDirs('darwin'), []);
assert.deepEqual(systemBundleDirs('win32'), []);
});
+92 -62
View File
@@ -1,60 +1,50 @@
import { createHash } from 'node:crypto';
import { readdir, stat } from 'node:fs/promises';
import { readdir, readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
/**
* Locates the M-Extension-Server release bundle for the host platform. Each
* bundle ships a matching JRE alongside the server JAR, so no system JDK is
* required.
*
* Releases are taken from upstream's GitHub releases, newest first, the same
* way Mangatan does it. Upstream publishes no checksums, so the download is
* trusted on the strength of TLS to GitHub and the maintainer's account, which
* is the same trust running their code implies in the first place.
*/
const BUNDLE_REPO_API = 'https://api.github.com/repos/1Selxo/M-Extension-Server';
/**
* Fetch the pinned release by tag rather than listing releases: upstream ships
* several a week, so a paged list would scroll the pinned tag off page one.
*/
export function bundleReleaseUrl(tagName: string = PINNED_BUNDLE_TAG): string {
return `${BUNDLE_REPO_API}/releases/tags/${encodeURIComponent(tagName)}`;
/** Enough of the list to skip the iOS-runtime releases that carry no desktop bundle. */
const RELEASE_PAGE_SIZE = 20;
export function bundleReleaseUrl(): string {
return `${BUNDLE_REPO_API}/releases?per_page=${RELEASE_PAGE_SIZE}`;
}
/**
* The bridge release this integration was verified against.
*
* Upstream publishes no checksums for the desktop bundles, so we pin a tag and
* a hash we computed ourselves rather than tracking "latest". Bumping this
* means downloading the new asset, verifying it starts and reports the
* capabilities in `AnimeBridgeClient.isReady`, then updating both fields.
* The oldest server this client is known to work with. Releases below it are
* skipped even when they are the only ones on offer; anything newer is taken,
* and the readiness probe in `AnimeBridgeClient.isReady` catches a server that
* has stopped speaking our protocol.
*/
export const PINNED_BUNDLE_TAG = 'v1.0.6.0';
export const MIN_BUNDLE_VERSION = 'v1.0.6.0';
/** SHA-256 of each pinned asset, keyed by release asset name. */
export const PINNED_BUNDLE_SHA256: Readonly<Record<string, string>> = {
'macOS-arm64-bundle.zip': '5f4fb03abfe88bc46ddf5f4d8221156ee2d66b9cbad7c4bc3ade4baf3a4266e6',
'linux-x64-bundle.zip': 'c2b869d3905b06a308517fec0b44f70ff76f7212230c60710bba39a7025c3a69',
};
/** `v1.0.6.2` → `[1, 0, 6, 2]`; null for tags that are not dotted numbers. */
export function parseBundleVersion(tag: string): number[] | null {
const match = /^v?(\d+(?:\.\d+)*)(?:-|$)/.exec(tag.trim());
return match ? match[1]!.split('.').map(Number) : null;
}
/**
* Check a downloaded asset against its pin. Assets we have not verified
* ourselves are rejected rather than trusted, so an unpinned platform fails
* loudly instead of silently running an unchecked binary.
*/
export function verifyPinnedBundle(
assetName: string,
bytes: Uint8Array,
): { ok: true } | { ok: false; reason: string } {
const expected = PINNED_BUNDLE_SHA256[assetName];
if (expected === undefined) {
return { ok: false, reason: `No pinned checksum for ${assetName}; refusing to run it.` };
/** Numeric, segment-wise comparison; a missing segment reads as zero. */
export function compareBundleVersions(a: string, b: string): number {
const left = parseBundleVersion(a) ?? [];
const right = parseBundleVersion(b) ?? [];
const length = Math.max(left.length, right.length);
for (let index = 0; index < length; index += 1) {
const difference = (left[index] ?? 0) - (right[index] ?? 0);
if (difference !== 0) return Math.sign(difference);
}
const actual = sha256(bytes);
if (actual !== expected) {
return {
ok: false,
reason: `Checksum mismatch for ${assetName}: expected ${expected}, got ${actual}.`,
};
}
return { ok: true };
return 0;
}
/** Release asset name for a platform/arch pair, or null when unsupported. */
@@ -96,13 +86,11 @@ async function walk(dir: string, depth: number, onFile: (file: string) => void):
export async function findBundleBinaries(rootDir: string): Promise<BundleBinaries | null> {
const javaCandidates: string[] = [];
const jarCandidates: string[] = [];
await walk(rootDir, 6, (file) => {
const base = path.basename(file);
if (base === 'java' || base === 'java.exe') javaCandidates.push(file);
else if (/^MExtensionServer.*\.jar$/.test(base)) jarCandidates.push(file);
});
// Prefer the shallowest match so a nested duplicate never shadows the real one.
const byDepth = (a: string, b: string) => a.split(path.sep).length - b.split(path.sep).length;
const javaPath = javaCandidates.sort(byDepth)[0];
@@ -111,11 +99,6 @@ export async function findBundleBinaries(rootDir: string): Promise<BundleBinarie
return { javaPath, jarPath };
}
/** Verify a downloaded archive against a pinned SHA-256, as Mangatan does. */
export function sha256(bytes: Uint8Array): string {
return createHash('sha256').update(bytes).digest('hex');
}
export async function isExecutableFile(file: string): Promise<boolean> {
try {
const info = await stat(file);
@@ -134,36 +117,83 @@ export interface BundleAsset {
interface GithubRelease {
tag_name?: string;
draft?: boolean;
prerelease?: boolean;
assets?: Array<{ name?: string; browser_download_url?: string; size?: number }>;
}
/**
* Pick the asset for this platform from the pinned release. Selecting "newest"
* instead would download a release whose checksum we never computed, so every
* upstream publish would break the install with a mismatch.
* Pick the newest release that ships this platform's bundle and meets the
* minimum version. Sorted by parsed version rather than list order, so a
* re-published older release cannot shadow the current one.
*/
export function selectBundleAsset(
releases: unknown,
assetName: string,
tagName: string = PINNED_BUNDLE_TAG,
minimumVersion: string = MIN_BUNDLE_VERSION,
): BundleAsset | null {
// Accepts either a single release (the by-tag endpoint) or a list.
// Accepts either a single release or the list endpoint's array.
const candidates = Array.isArray(releases)
? releases
: releases && typeof releases === 'object'
? [releases]
: [];
const usable: BundleAsset[] = [];
for (const release of candidates as GithubRelease[]) {
if (release.tag_name !== tagName) continue;
if (!release.tag_name || release.draft || release.prerelease) continue;
if (parseBundleVersion(release.tag_name) === null) continue;
if (compareBundleVersions(release.tag_name, minimumVersion) < 0) continue;
const asset = release.assets?.find((candidate) => candidate.name === assetName);
if (asset?.browser_download_url && release.tag_name) {
return {
tagName: release.tag_name,
assetName,
downloadUrl: asset.browser_download_url,
sizeBytes: asset.size ?? 0,
};
}
if (!asset?.browser_download_url) continue;
usable.push({
tagName: release.tag_name,
assetName,
downloadUrl: asset.browser_download_url,
sizeBytes: asset.size ?? 0,
});
}
usable.sort((a, b) => compareBundleVersions(b.tagName, a.tagName));
return usable[0] ?? null;
}
/**
* Where a package manager may have put the same bundle. Only Arch has a
* package today (AUR `mangatan-extension-server`, installed for Mangatan); it
* unpacks the upstream layout verbatim, so `findBundleBinaries` reads it as-is.
*/
export function systemBundleDirs(platform: string): string[] {
if (platform === 'linux') return ['/usr/share/mangatan/extension_server'];
return [];
}
/**
* The release version a server jar carries in its name, normalised to the tag
* form (`MExtensionServer-v1.0.6.0-r1.jar` → `v1.0.6.0`). Upstream appends a
* `-rN` rebuild suffix to some jars that the release tag does not carry.
*/
export function bundleVersionFromJar(jarPath: string): string | null {
const match = /^MExtensionServer-v?(\d+(?:\.\d+)*)/.exec(path.basename(jarPath));
return match ? `v${match[1]}` : null;
}
/**
* Records which release SubMiner unpacked into a managed install directory.
* Older installs predate the marker; they fall back to the jar name.
*/
export const BUNDLE_MARKER_FILE = 'bundle.json';
export async function writeBundleMarker(installDir: string, tag: string): Promise<void> {
await writeFile(path.join(installDir, BUNDLE_MARKER_FILE), JSON.stringify({ tag }, null, 2));
}
export async function readBundleMarker(installDir: string): Promise<string | null> {
try {
const parsed: unknown = JSON.parse(
await readFile(path.join(installDir, BUNDLE_MARKER_FILE), 'utf8'),
);
const tag = (parsed as { tag?: unknown } | null)?.tag;
return typeof tag === 'string' && tag.length > 0 ? tag : null;
} catch {
return null;
}
return null;
}
+46 -13
View File
@@ -57,6 +57,7 @@ const banner = el<HTMLDivElement>('bridge-banner');
const bannerMessage = el<HTMLSpanElement>('bridge-message');
const bannerMeter = el<HTMLSpanElement>('bridge-meter');
const bannerMeterFill = el<HTMLElement>('bridge-meter-fill');
const bannerUpdate = el<HTMLButtonElement>('bridge-update');
const statusMessage = el<HTMLSpanElement>('status-message');
const browseTab = el<HTMLButtonElement>('tab-browse');
const extensionsTab = el<HTMLButtonElement>('tab-extensions');
@@ -121,31 +122,33 @@ const BRIDGE_LABELS: Record<AnimeBrowserBridgeState['stage'], string> = {
idle: 'Starting the extension bridge',
locating: 'Looking up the extension bridge release',
downloading: 'Downloading the extension bridge',
verifying: 'Verifying the download',
extracting: 'Unpacking the extension bridge',
starting: 'Starting the extension bridge',
ready: 'Bridge ready',
failed: 'Bridge failed to start',
};
const BUSY_STAGES = new Set([
'idle',
'locating',
'downloading',
'verifying',
'extracting',
'starting',
]);
const BUSY_STAGES = new Set(['idle', 'locating', 'downloading', 'extracting', 'starting']);
function renderBridgeState(state: AnimeBrowserBridgeState): void {
const busy = BUSY_STAGES.has(state.stage);
banner.dataset.stage = state.stage;
banner.dataset.busy = String(busy);
// An update is only offered from a running bridge; mid-start it would race
// the start it interrupts.
const update = state.stage === 'ready' ? (state.install?.updateAvailable ?? null) : null;
// Once ready with nothing to report, the banner has nothing to say.
const hide = state.stage === 'ready' && state.message === null;
const hide = state.stage === 'ready' && state.message === null && update === null;
banner.classList.toggle('hidden', hide);
bannerMessage.textContent = state.message ?? BRIDGE_LABELS[state.stage];
bannerMessage.textContent =
state.message ??
(update === null
? BRIDGE_LABELS[state.stage]
: `Extension bridge ${state.install?.version ?? 'of unknown version'} is installed; ${update} is available.`);
bannerUpdate.classList.toggle('hidden', update === null);
bannerUpdate.textContent = update === null ? '' : `Update to ${update}`;
bannerUpdate.disabled = busy;
const showMeter = state.progress !== null;
bannerMeter.classList.toggle('hidden', !showMeter);
@@ -438,6 +441,31 @@ sourceSelect.addEventListener('change', () => {
loadMoreButton.addEventListener('click', () => void loadNextPage());
bannerUpdate.addEventListener('click', () => {
void (async () => {
bannerUpdate.disabled = true;
try {
const state = await api.updateBridge();
renderBridgeState(state);
if (state.stage === 'ready' && state.install?.updateAvailable === null) {
setStatus(
`Extension bridge updated to ${state.install.version ?? 'the pinned release'}.`,
'ok',
);
} else if (state.message !== null) {
setStatus(state.message, 'error');
}
// The bridge restarted, so the source list is fresh from disk.
const snapshot = await api.getSnapshot();
renderSources(snapshot.sources, snapshot.selectedSourceId);
if (currentView === 'extensions') await extensions.refresh();
} catch (error) {
setStatus(describe(error), 'error');
bannerUpdate.disabled = false;
}
})();
});
api.onBridgeState(renderBridgeState);
// The queue changes without this window asking: it advances by itself when an
@@ -450,7 +478,7 @@ api.onPlaybackState((state) => {
});
void (async () => {
renderBridgeState({ stage: 'idle', progress: null, message: null });
renderBridgeState({ stage: 'idle', progress: null, message: null, install: null });
// A queue survives the window being closed and reopened, so start from what
// the main process already holds rather than from empty.
void api.getQueue().then(
@@ -485,7 +513,12 @@ void (async () => {
} 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) });
renderBridgeState({
stage: 'failed',
progress: null,
message: describe(error),
install: null,
});
setStatus(describe(error), 'error');
}
})();
+3 -1
View File
@@ -1,6 +1,6 @@
import { describe, el } from './dom';
import { buildIconIndex, iconMonogram, isSafeIconUrl, repoFaviconUrl } from './extension-icons';
import { describeInstalled } from './format';
import { describeBridgeInstall, describeInstalled } from './format';
import {
collectLanguages,
filterByLanguage,
@@ -121,6 +121,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
const { api, setStatus, onSourcesChanged } = options;
const extensionsDirLabel = el<HTMLSpanElement>('extensions-dir');
const bridgeInfo = el<HTMLParagraphElement>('bridge-info');
const installedList = el<HTMLDivElement>('installed-list');
const installedCount = el<HTMLSpanElement>('installed-count');
const availableList = el<HTMLDivElement>('extensions-list');
@@ -328,6 +329,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
async function refresh(): Promise<void> {
const snapshot = await api.getSnapshot();
extensionsDirLabel.textContent = snapshot.extensionsDir;
bridgeInfo.textContent = describeBridgeInstall(snapshot.bridge.install);
renderRepos(snapshot.repos);
repoFailures = [];
+32 -1
View File
@@ -1,6 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { describeInstalled, sourceOptionLabel, summarizeSearch } from './format';
import {
describeInstalled,
sourceOptionLabel,
summarizeSearch,
describeBridgeInstall,
} from './format';
import type { AnimeBrowserSearchResult } from '../types/anime-browser';
const result = (
@@ -58,3 +63,29 @@ test('describeInstalled falls back to the package alone when nothing loaded', ()
'broken',
);
});
test('describeBridgeInstall says who updates the bridge', () => {
assert.match(describeBridgeInstall(null), /not started/);
assert.match(
describeBridgeInstall({
origin: 'system',
version: 'v1.0.6.2',
dir: '/usr/share/mangatan/extension_server',
updateAvailable: null,
}),
/v1\.0\.6\.2 from \/usr\/share\/mangatan\/extension_server.*package manager/,
);
assert.match(
describeBridgeInstall({
origin: 'managed',
version: 'v1.0.5.0',
dir: '/home/u/.config/SubMiner/anime-bridge',
updateAvailable: 'v1.0.6.0',
}),
/v1\.0\.6\.0 is available/,
);
assert.match(
describeBridgeInstall({ origin: 'managed', version: null, dir: '/d', updateAvailable: null }),
/unknown version.*up to date/,
);
});
+14
View File
@@ -1,4 +1,5 @@
import type {
AnimeBrowserBridgeInstall,
AnimeBrowserSearchResult,
AnimeBrowserSource,
InstalledExtensionView,
@@ -33,3 +34,16 @@ export function describeInstalled(view: InstalledExtensionView): string {
if (view.langs.length > 0) parts.push(view.langs.join(', '));
return parts.join(' · ');
}
/** One line for the Extensions tab: which bridge is running and who updates it. */
export function describeBridgeInstall(install: AnimeBrowserBridgeInstall | null): string {
if (install === null) return 'The extension bridge has not started yet.';
const version = install.version ?? 'unknown version';
if (install.origin === 'system') {
return `M-Extension-Server ${version} from ${install.dir}, installed outside SubMiner (for example by your package manager), which is where updates come from.`;
}
if (install.updateAvailable !== null) {
return `M-Extension-Server ${version} in ${install.dir}, downloaded by SubMiner. ${install.updateAvailable} is available from the banner above.`;
}
return `M-Extension-Server ${version} in ${install.dir}, downloaded by SubMiner and up to date.`;
}
+9
View File
@@ -73,6 +73,12 @@
<span class="bridge-dot" id="bridge-dot"></span>
<span class="bridge-message" id="bridge-message"></span>
<span class="bridge-meter hidden" id="bridge-meter"><i id="bridge-meter-fill"></i></span>
<button
class="ghost-button bridge-update hidden"
id="bridge-update"
type="button"
title="Downloads the verified release and restarts the bridge. A playing episode's stream stops."
></button>
</div>
<section class="settings hidden" id="settings" role="tabpanel" aria-labelledby="tab-settings">
@@ -122,6 +128,9 @@
</h3>
<div class="lang-filter" id="lang-filter" role="group" aria-label="Filter by language"></div>
<div class="ext-list" id="extensions-list" aria-label="Available extensions"></div>
<h3 class="ext-group-title">Bridge</h3>
<p class="repo-hint" id="bridge-info"></p>
</section>
<main class="layout" id="layout" role="tabpanel" aria-labelledby="tab-browse">
+7
View File
@@ -304,6 +304,13 @@ body {
transition: width 0.2s ease;
}
.bridge-update {
margin-left: auto;
padding: 4px 12px;
border-radius: 8px;
font-size: 12px;
}
/* ---------- layout ---------- */
.layout {
@@ -102,6 +102,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
extensionsDir: '',
repos: [],
preferredQuality: '',
bridgeDir: '',
},
jimaku: {
apiBaseUrl: 'https://jimaku.cc',
@@ -587,6 +587,13 @@ export function buildIntegrationConfigOptionRegistry(
description:
'Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.',
},
{
path: 'anime.bridgeDir',
kind: 'string',
defaultValue: defaultConfig.anime.bridgeDir,
description:
'Directory holding an M-Extension-Server bundle (java runtime plus server jar) to run instead of the copy SubMiner downloads. Empty checks the package-manager install (Arch: mangatan-extension-server), then <userData>/anime-bridge.',
},
{
path: 'jellyfin.enabled',
kind: 'boolean',
+7
View File
@@ -357,6 +357,13 @@ export function applyIntegrationConfig(context: ResolveContext): void {
);
}
const bridgeDir = asString(src.anime.bridgeDir);
if (bridgeDir !== undefined) {
resolved.anime.bridgeDir = normalizeExternalProfilePath(bridgeDir);
} else if (src.anime.bridgeDir !== undefined) {
warn('anime.bridgeDir', src.anime.bridgeDir, resolved.anime.bridgeDir, 'Expected string.');
}
const preferredQuality = asString(src.anime.preferredQuality);
if (preferredQuality !== undefined) {
resolved.anime.preferredQuality = preferredQuality.trim();
+12 -1
View File
@@ -543,7 +543,11 @@ import {
} from './main/runtime/setup-window-factory';
import { createAnimeBrowserApplicationRuntime } from './main/runtime/anime-browser-application-runtime';
import { openAnimeBrowserModal as openAnimeBrowserModalRuntime } from './main/runtime/anime-browser-open';
import { ensureBridgeBinaries } from './main/runtime/anime-bridge-installer';
import {
ensureBridgeBinaries,
findBridgeUpdate,
stageBridgeUpdate,
} from './main/runtime/anime-bridge-installer';
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
import { createOpenConfigSettingsWindowHandler } from './main/runtime/config-settings-window';
import { createSyncUiRuntime } from './main/runtime/sync-ui-runtime';
@@ -3332,6 +3336,13 @@ const animeBrowserApplicationRuntime = createAnimeBrowserApplicationRuntime({
preferencesFile: path.join(USER_DATA_PATH, 'anime-source-preferences.json'),
ensureBinaries: (onProgress) =>
ensureBridgeBinaries({
installDir: path.join(USER_DATA_PATH, 'anime-bridge'),
configuredDir: configService.getConfig().anime?.bridgeDir,
onProgress,
}),
checkBridgeUpdate: (install) => findBridgeUpdate(install),
stageBridgeUpdate: (onProgress) =>
stageBridgeUpdate({
installDir: path.join(USER_DATA_PATH, 'anime-bridge'),
onProgress,
}),
@@ -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,
+2
View File
@@ -40,6 +40,8 @@ export function createAnimeBrowserAPI(ipcRenderer: AnimeBrowserIpcRenderer): Ani
ipcRenderer.invoke(request.animeBrowserGetSnapshot, sessionId),
ensureBridge: (): Promise<AnimeBrowserBridgeState> =>
ipcRenderer.invoke(request.animeBrowserEnsureBridge, sessionId),
updateBridge: (): Promise<AnimeBrowserBridgeState> =>
ipcRenderer.invoke(request.animeBrowserUpdateBridge),
selectSource: (sourceId: string): Promise<void> =>
ipcRenderer.invoke(request.animeBrowserSelectSource, sessionId, sourceId),
search: (query: string, page?: number): Promise<AnimeBrowserSearchResult> =>
+1
View File
@@ -128,6 +128,7 @@ export const IPC_CHANNELS = {
getChangelogSnapshot: 'changelog:get-snapshot',
animeBrowserGetSnapshot: 'anime-browser:get-snapshot',
animeBrowserEnsureBridge: 'anime-browser:ensure-bridge',
animeBrowserUpdateBridge: 'anime-browser:update-bridge',
animeBrowserSelectSource: 'anime-browser:select-source',
animeBrowserSearch: 'anime-browser:search',
animeBrowserGetPopular: 'anime-browser:get-popular',
+28 -1
View File
@@ -128,17 +128,38 @@ export type AnimeBrowserBridgeStage =
| 'idle'
| 'locating'
| 'downloading'
| 'verifying'
| 'extracting'
| 'starting'
| 'ready'
| 'failed';
/** Where the running bridge came from, and whether SubMiner can move it forward. */
export interface AnimeBrowserBridgeInstall {
/**
* `managed`: downloaded by SubMiner into its own directory, so it can be
* updated from the browser. `system`: a package-manager install (the AUR
* `mangatan-extension-server` package) or an `anime.bridgeDir` the user
* pointed at; SubMiner uses it as found and never writes to it.
*/
origin: 'managed' | 'system';
/** Release tag, e.g. `v1.0.6.2`, or null when it cannot be read. */
version: string | null;
dir: string;
/**
* The newest upstream release with a bundle for this platform, when it is
* newer than a managed install; null when current, not managed, or not yet
* checked. Filled in once the bridge is running, since it needs the network.
*/
updateAvailable: string | null;
}
export interface AnimeBrowserBridgeState {
stage: AnimeBrowserBridgeStage;
/** 0-1 while downloading, otherwise null. */
progress: number | null;
message: string | null;
/** Null until the bridge binaries have been located. */
install: AnimeBrowserBridgeInstall | null;
}
/** An extension APK that failed to load, surfaced instead of silently vanishing. */
@@ -255,6 +276,12 @@ export interface AnimeBrowserQueueState {
export interface AnimeBrowserAPI {
getSnapshot: () => Promise<AnimeBrowserSnapshot>;
ensureBridge: () => Promise<AnimeBrowserBridgeState>;
/**
* Download the newest release over a managed install and restart the bridge
* on it. Rejected for a system install. A playing episode's stream dies with
* the old bridge.
*/
updateBridge: () => Promise<AnimeBrowserBridgeState>;
selectSource: (sourceId: string) => Promise<void>;
search: (query: string, page?: number) => Promise<AnimeBrowserSearchResult>;
getPopular: (page?: number) => Promise<AnimeBrowserSearchResult>;
+6
View File
@@ -58,6 +58,12 @@ export interface AnimeConfig {
repos?: string[];
/** Preferred stream label, matched as a substring, e.g. "1080". */
preferredQuality?: string;
/**
* Directory holding an M-Extension-Server bundle (java runtime plus server
* jar) to run instead of the copy SubMiner downloads. Empty checks the
* package-manager location, then the managed copy.
*/
bridgeDir?: string;
}
export interface JimakuConfig {