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:
@@ -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'), []);
|
||||
});
|
||||
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
})();
|
||||
|
||||
@@ -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 = [];
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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.`;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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)));
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user