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
+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;
}