mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-04 23:54:28 -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user