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
+2
View File
@@ -182,6 +182,8 @@ See the [full requirements list](https://docs.subminer.moe/installation#_1-insta
```bash
paru -S subminer-bin
# optional: the anime browser bridge, shared with Mangatan and updated by pacman
paru -S mangatan-extension-server
```
</details>
+3 -2
View File
@@ -18,8 +18,7 @@ area: anime
- The strip proxy no longer forwards `Range` headers to the bridge: ffmpeg opens every HLS segment with `Range: bytes=0-`, the bridge answers some of those with 206, and a partial response bypassed the disguise strip, so whether an episode played depended on the bridge's cache state.
- A bridge that dies out from under the app (killed, crashed, or stopped mid-operation) no longer leaves the browser failing every request until an app restart: the exit is detected, surfaced in the status bar, and the bridge restarts on the next request.
- "Playing" is only reported once mpv actually configures a video output; when a stream fails to decode, the browser shows mpv's error instead of claiming playback started while no window ever appeared.
- The Linux x64 bridge bundle is verified and pinned, so the anime browser starts on Linux instead of refusing with "No pinned checksum for linux-x64-bundle.zip".
- The bridge bundle is fetched from the pinned release tag rather than whatever release is newest, so an upstream publish no longer breaks every install with a checksum mismatch.
- The bridge bundle is downloaded from the newest upstream release that ships a bundle for the platform (skipping releases older than the oldest server SubMiner is known to work with), the same way Mangatan fetches it, so all four published bundles (macOS arm64 and x64, Linux x64, Windows x64) work without a maintainer hashing each one first.
- The Extensions tab's available list has a language chip row: pick one or more languages to narrow a repository index that otherwise lists every language it knows, or "All" to clear the filter. Rows name the language ("Japanese" instead of `ja`) and the Available heading counts what the filter leaves.
- A stream's subtitle tracks are downloaded to a temp directory and loaded into mpv as files rather than streamed from the source URL, so they can serve as the alass reference in Subsync the way Jellyfin subtitles do. The format is detected from the file's own content, a track that fails to download falls back to its URL so the episode still plays, and the directory is removed when the next episode starts or the app exits.
- An episode launched from the browser carries its series, season and episode number through the app instead of a single joined string: stats group by series (every stream previously landed in one entry named `m3u8`), rewatching reuses the existing entry, the Jimaku and TsukiHime modals prefill Title/Season/Episode from the source's own listing, AniList updates use those fields directly, and the mpv title reads `Series S03E04 - Episode Name`.
@@ -30,3 +29,5 @@ area: anime
- Marking an episode that was never played creates its stats row, carrying the same series, season and episode fields playback would have recorded. Both stats library views join the lifetime tables, so a manual mark does not show up there as watch time nobody spent, and clearing a mark creates nothing.
- Episodes can be queued instead of replacing what is playing. Every episode row has **Play** and **Queue** buttons (clicking the row still plays now), the right-click menu offers the same two, and a queued episode shows its place in line ("next up", "#2 in queue") with a queue count and **Clear queue** in the episode header. The queue spans anime, resolves and appends each episode to mpv's real playlist as soon as it is queued while subtitle tracks cache in the background, so next/previous navigation works immediately and the next episode starts without a resolution pause when the current one ends. Queueing with nothing playing just plays.
- Added an in-player Anime Browser modal on `Ctrl+Alt+A`. The shortcut toggles it without losing its page or scroll position. It stays within the player bounds and shares the active episode, playback queue, source configuration, and watch history with the standalone browser, while each surface keeps independent search and navigation state. The modal validates its embedded page before changing overlay state, so a load setup failure leaves it closed rather than revealing a broken modal.
- The bridge is reused from a package-manager install when one exists: on Arch the AUR `mangatan-extension-server` package (shared with Mangatan) is picked up from `/usr/share/mangatan/extension_server`, so nothing is downloaded and pacman keeps it current. `anime.bridgeDir` points SubMiner at a bundle anywhere else. `subminer-bin` lists the package as an optional dependency.
- SubMiner records which bridge release it installed and, once the bridge is running, checks GitHub for a newer one; when there is, the banner offers an **Update to vX** button. The new release is downloaded beside the running bridge, then the bridge restarts on it. The Extensions tab shows the bridge version, where it lives, and who updates it.
+2 -1
View File
@@ -626,7 +626,8 @@
"anime": {
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
"repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
"preferredQuality": "" // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
"preferredQuality": "", // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
"bridgeDir": "" // 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.
}, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
// ==========================================
+44 -10
View File
@@ -197,10 +197,11 @@ there is nowhere to write them and the status bar says so.
## Settings
| Key | Purpose |
| ------------------------ | -------------------------------------------------------------------- |
| ------------------------ | ----------------------------------------------------------------------- |
| `anime.repos` | Repository index URLs. Empty by default. |
| `anime.extensionsDir` | Where APKs are read from. Empty uses `<userData>/anime-extensions`. |
| `anime.preferredQuality` | Preferred stream label, matched as a substring (for example `1080`). |
| `anime.bridgeDir` | A bridge bundle to run instead of the downloaded one. Empty by default. |
## Source settings
@@ -220,13 +221,48 @@ settings once after upgrading.
## The bridge
The first launch downloads a platform bundle (~130 MB) containing the server and
a matching Java runtime, so no system JDK is required. SubMiner pins one
upstream release tag and the SHA-256 of each asset it has verified; the download
is checked against that hash before anything runs, then unpacked into
`<userData>/anime-bridge` and reused after that. Progress appears in the banner
SubMiner looks for the server in three places, in order, and runs the first
one it finds:
1. `anime.bridgeDir`, if set. It must hold a Java runtime and the
`MExtensionServer-*.jar`, laid out as upstream ships them. A directory that
holds neither is an error rather than a silent fallback.
2. A package-manager install. On Arch that is the AUR
[`mangatan-extension-server`](https://aur.archlinux.org/packages/mangatan-extension-server)
package, which Mangatan also uses, at `/usr/share/mangatan/extension_server`.
Installing it means no download, and pacman keeps the bridge current. The
`subminer-bin` package lists it as an optional dependency.
3. SubMiner's own copy in `<userData>/anime-bridge`. The first launch downloads
the newest upstream release that ships a bundle for your platform (~130 MB,
containing the server and a matching Java runtime, so no system JDK is
required), the same way Mangatan does. Releases older than the oldest server
SubMiner is known to work with are skipped. Progress appears in the banner
at the top of the window.
The **Bridge** note at the bottom of the Extensions tab says which one is in
use, its version, and who updates it.
### Updating the bridge
Only the copy SubMiner downloaded is ever updated by SubMiner. A package-manager
install or an `anime.bridgeDir` belongs to whoever put it there.
SubMiner records the release it installed and, once the bridge is running,
asks GitHub for the newest one. When upstream has published a newer release,
the banner reads "Extension bridge v… is installed; v… is available" with an
**Update to v…** button. Clicking it downloads the new release beside the
running bridge, so a failed download changes nothing, then stops the bridge,
swaps the directories, and starts it again. The restart takes a few seconds
and kills the stream of an episode that is playing, the same as when the
bridge exits for any other reason; the queue and browser state survive. The
check is one unauthenticated GitHub API call per bridge start; if it fails
(offline, rate limited) it is logged and no update is offered until the next
start.
Upstream publishes no checksums, so the download is trusted the way Mangatan
and the AUR package trust it: TLS to GitHub and the maintainer's account. That
is the same trust running the server implies in the first place.
The bridge stays running while the window is open. Resolved video URLs point at
its own loopback proxy so the extension's cookies and headers apply, which means
those URLs stop working once it exits — the window keeps it alive for the whole
@@ -241,10 +277,8 @@ Two known limits:
- There is no Android WebView, so extensions that need one (typically for
Cloudflare challenges) will fail with an error from the source.
- Bundles are published for macOS (arm64, x64), Linux (x64), and Windows (x64),
but only the ones we have hashed ourselves will run: currently macOS arm64 and
Linux x64. The rest stop with "No pinned checksum for …" until a maintainer
verifies them. Other platforms are unsupported outright.
- Bundles are published for macOS (arm64, x64), Linux (x64), and Windows (x64).
Other platforms are unsupported outright.
## Playback
+2 -1
View File
@@ -1171,10 +1171,11 @@ Sources for the [anime browser](/anime-browser). SubMiner ships no extension rep
```
| Option | Type | Default | Description |
| ------------------------ | ---------- | ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ------------------------ | ---------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `anime.extensionsDir` | `string` | `""` | Directory holding Aniyomi extension `.apk` files. Empty uses `<userData>/anime-extensions`. |
| `anime.repos` | `string[]` | `[]` | Extension repository index URLs. Any `https` URL ending in `.json` works; `index.min.json` is only the common name. |
| `anime.preferredQuality` | `string` | `""` | Preferred stream quality label, matched as a substring (for example `1080`). Empty keeps the source's own order. A Japanese-audio entry always outranks a higher-quality dub. |
| `anime.bridgeDir` | `string` | `""` | Directory holding an M-Extension-Server bundle (Java runtime plus server jar) to run instead of the downloaded copy. Empty checks the package-manager install first, then `<userData>/anime-bridge`. See [the bridge](anime-browser.md#the-bridge). |
Repositories added from the browser's Extensions tab are written back to `anime.repos`, so the list can also be kept in a dotfile. Changes apply the next time the anime browser opens.
+4
View File
@@ -215,6 +215,10 @@ Install [`subminer-bin`](https://aur.archlinux.org/packages/subminer-bin) from t
paru -S subminer-bin
```
For the [anime browser](anime-browser.md#the-bridge), optionally add
`mangatan-extension-server`. SubMiner picks the package up instead of
downloading its own copy of the bridge, and pacman keeps it updated.
Or manually:
```bash
+2 -1
View File
@@ -626,7 +626,8 @@
"anime": {
"extensionsDir": "", // Directory holding Aniyomi extension .apk files. Empty uses <userData>/anime-extensions.
"repos": [], // Extension repository index URLs (any https .json index, e.g. https://.../index.min.json). Empty by default; SubMiner ships no repositories.
"preferredQuality": "" // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
"preferredQuality": "", // Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.
"bridgeDir": "" // 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.
}, // Anime browser sources. SubMiner ships no extension repositories and bundles no sources;
// ==========================================
+1
View File
@@ -21,6 +21,7 @@ pkgbase = subminer-bin
optdepends = python-guessit: improved AniSkip title and episode inference
optdepends = alass-git: preferred subtitle synchronization engine
optdepends = python-ffsubsync: fallback subtitle synchronization engine
optdepends = mangatan-extension-server: anime browser bridge shared with Mangatan; skips the bundle download and is updated by pacman
provides = subminer=0.6.2
conflicts = subminer
noextract = SubMiner-0.6.2.AppImage
+1
View File
@@ -27,6 +27,7 @@ optdepends=(
'python-guessit: improved AniSkip title and episode inference'
'alass-git: preferred subtitle synchronization engine'
'python-ffsubsync: fallback subtitle synchronization engine'
'mangatan-extension-server: anime browser bridge shared with Mangatan; skips the bundle download and is updated by pacman'
)
provides=("subminer=${pkgver}")
conflicts=('subminer')
+66 -77
View File
@@ -4,14 +4,18 @@ import { mkdtemp, mkdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
BUNDLE_MARKER_FILE,
bundleReleaseUrl,
bundleVersionFromJar,
compareBundleVersions,
findBundleBinaries,
PINNED_BUNDLE_SHA256,
PINNED_BUNDLE_TAG,
MIN_BUNDLE_VERSION,
parseBundleVersion,
readBundleMarker,
resolveBundleAssetName,
selectBundleAsset,
sha256,
verifyPinnedBundle,
systemBundleDirs,
writeBundleMarker,
} from './sidecar-bundle';
test('resolveBundleAssetName maps supported platform/arch pairs', () => {
@@ -27,71 +31,71 @@ test('resolveBundleAssetName returns null for unpublished combinations', () => {
assert.equal(resolveBundleAssetName('freebsd', 'x64'), null);
});
const PINNED_RELEASE = {
tag_name: PINNED_BUNDLE_TAG,
assets: [
{
name: 'macOS-arm64-bundle.zip',
browser_download_url: 'https://example.test/macOS-arm64-bundle.zip',
size: 133_058_560,
},
],
function release(tag: string, assetNames: string[], extra: Record<string, unknown> = {}) {
return {
tag_name: tag,
assets: assetNames.map((name) => ({
name,
browser_download_url: `https://example.test/${tag}/${name}`,
size: 1,
})),
...extra,
};
}
test('selectBundleAsset reads the by-tag endpoint payload', () => {
const asset = selectBundleAsset(PINNED_RELEASE, 'macOS-arm64-bundle.zip');
assert.equal(asset?.tagName, PINNED_BUNDLE_TAG);
assert.equal(asset?.downloadUrl, 'https://example.test/macOS-arm64-bundle.zip');
assert.equal(asset?.sizeBytes, 133_058_560);
});
test('selectBundleAsset skips releases without a matching asset', () => {
test('selectBundleAsset takes the newest release that ships the asset', () => {
const releases = [
// The iOS runtime release carries no desktop bundle.
{ tag_name: 'ios-runtime-v7', assets: [{ name: 'MExtensionServer-ios.jar' }] },
PINNED_RELEASE,
release('ios-runtime-v7', ['MExtensionServer-ios.jar']),
release('v1.0.6.1', ['linux-x64-bundle.zip', 'macOS-arm64-bundle.zip']),
// Older than the one below it: list order must not decide.
release('v1.0.6.2', ['linux-x64-bundle.zip']),
];
const asset = selectBundleAsset(releases, 'macOS-arm64-bundle.zip');
assert.equal(asset?.tagName, PINNED_BUNDLE_TAG);
const asset = selectBundleAsset(releases, 'linux-x64-bundle.zip');
assert.equal(asset?.tagName, 'v1.0.6.2');
assert.equal(asset?.downloadUrl, 'https://example.test/v1.0.6.2/linux-x64-bundle.zip');
// The newest release lacks the macOS asset, so the one before it wins there.
assert.equal(selectBundleAsset(releases, 'macOS-arm64-bundle.zip')?.tagName, 'v1.0.6.1');
});
test('selectBundleAsset ignores releases newer than the pin', () => {
test('selectBundleAsset skips releases below the minimum, drafts, and prereleases', () => {
const releases = [
{
tag_name: 'v9.9.9.9',
assets: [
{
name: 'macOS-arm64-bundle.zip',
browser_download_url: 'https://example.test/unpinned.zip',
size: 1,
},
],
},
PINNED_RELEASE,
release('v1.0.5.9', ['linux-x64-bundle.zip']),
release('v2.0.0.0', ['linux-x64-bundle.zip'], { draft: true }),
release('v2.0.0.1', ['linux-x64-bundle.zip'], { prerelease: true }),
];
assert.equal(selectBundleAsset(releases, 'linux-x64-bundle.zip'), null);
const asset = selectBundleAsset(releases, 'macOS-arm64-bundle.zip');
assert.equal(asset?.tagName, PINNED_BUNDLE_TAG);
assert.equal(asset?.downloadUrl, 'https://example.test/macOS-arm64-bundle.zip');
releases.push(release(MIN_BUNDLE_VERSION, ['linux-x64-bundle.zip']));
assert.equal(selectBundleAsset(releases, 'linux-x64-bundle.zip')?.tagName, MIN_BUNDLE_VERSION);
});
test('selectBundleAsset returns null when nothing matches', () => {
assert.equal(
selectBundleAsset([{ tag_name: PINNED_BUNDLE_TAG, assets: [] }], 'linux-x64-bundle.zip'),
null,
);
assert.equal(selectBundleAsset([release('v1.0.6.0', [])], 'linux-x64-bundle.zip'), null);
assert.equal(selectBundleAsset([], 'linux-x64-bundle.zip'), null);
assert.equal(selectBundleAsset({ message: 'rate limited' }, 'linux-x64-bundle.zip'), null);
});
test('bundleReleaseUrl targets the pinned tag', () => {
test('bundleReleaseUrl lists upstream releases', () => {
assert.equal(
bundleReleaseUrl(),
`https://api.github.com/repos/1Selxo/M-Extension-Server/releases/tags/${PINNED_BUNDLE_TAG}`,
'https://api.github.com/repos/1Selxo/M-Extension-Server/releases?per_page=20',
);
});
test('parseBundleVersion and compareBundleVersions order tags numerically', () => {
assert.deepEqual(parseBundleVersion('v1.0.6.2'), [1, 0, 6, 2]);
assert.deepEqual(parseBundleVersion('1.0.10'), [1, 0, 10]);
assert.deepEqual(parseBundleVersion('v1.0.6.0-r1'), [1, 0, 6, 0]);
assert.equal(parseBundleVersion('ios-runtime-v7'), null);
assert.equal(compareBundleVersions('v1.0.6.2', 'v1.0.6.1'), 1);
assert.equal(compareBundleVersions('v1.0.10', 'v1.0.9'), 1);
assert.equal(compareBundleVersions('v1.0.6', 'v1.0.6.0'), 0);
assert.equal(compareBundleVersions('v1.0.5.9', 'v1.0.6.0'), -1);
});
test('findBundleBinaries locates the nested jre and jar', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'subminer-bundle-'));
await mkdir(path.join(root, 'jre', 'jre', 'bin'), { recursive: true });
@@ -121,39 +125,24 @@ test('findBundleBinaries returns null when the bundle is incomplete', async () =
assert.equal(await findBundleBinaries(root), null);
});
test('sha256 produces lowercase hex digests matching known vectors', () => {
const encode = (value: string) => new TextEncoder().encode(value);
assert.equal(
sha256(encode('')),
'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855',
);
assert.equal(
sha256(encode('subminer')),
'f3b7fdb2037add4cd8f122c090a727243b46b1b9d8a6c379f71573e2df120885',
);
test('bundleVersionFromJar reads the tag out of the jar name, dropping rebuild suffixes', () => {
assert.equal(bundleVersionFromJar('/x/MExtensionServer-v1.0.6.0-r1.jar'), 'v1.0.6.0');
assert.equal(bundleVersionFromJar('/x/MExtensionServer-v1.0.6.2.jar'), 'v1.0.6.2');
assert.equal(bundleVersionFromJar('/x/MExtensionServer-1.0.6.0.jar'), 'v1.0.6.0');
assert.equal(bundleVersionFromJar('/x/MExtensionServer.jar'), null);
});
test('verifyPinnedBundle accepts a matching hash and rejects a mismatch', () => {
const asset = 'macOS-arm64-bundle.zip';
const wrong = verifyPinnedBundle(asset, new TextEncoder().encode('not the bundle'));
assert.equal(wrong.ok, false);
assert.match((wrong as { reason: string }).reason, /Checksum mismatch/);
test('bundle marker round-trips and tolerates a missing or malformed file', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'subminer-bundle-'));
assert.equal(await readBundleMarker(root), null);
await writeBundleMarker(root, 'v1.0.6.0');
assert.equal(await readBundleMarker(root), 'v1.0.6.0');
await writeFile(path.join(root, BUNDLE_MARKER_FILE), '{not json');
assert.equal(await readBundleMarker(root), null);
});
test('verifyPinnedBundle refuses an asset that has no pin', () => {
const result = verifyPinnedBundle('windows-x64-bundle.zip', new Uint8Array([1, 2, 3]));
assert.equal(result.ok, false);
assert.match((result as { reason: string }).reason, /No pinned checksum/);
});
test('the pinned tag and hashes are the verified release', () => {
assert.equal(PINNED_BUNDLE_TAG, 'v1.0.6.0');
assert.equal(
PINNED_BUNDLE_SHA256['macOS-arm64-bundle.zip'],
'5f4fb03abfe88bc46ddf5f4d8221156ee2d66b9cbad7c4bc3ade4baf3a4266e6',
);
assert.equal(
PINNED_BUNDLE_SHA256['linux-x64-bundle.zip'],
'c2b869d3905b06a308517fec0b44f70ff76f7212230c60710bba39a7025c3a69',
);
test('systemBundleDirs names the AUR package location on Linux only', () => {
assert.deepEqual(systemBundleDirs('linux'), ['/usr/share/mangatan/extension_server']);
assert.deepEqual(systemBundleDirs('darwin'), []);
assert.deepEqual(systemBundleDirs('win32'), []);
});
+86 -56
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 {
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;
}
}
+46 -13
View File
@@ -57,6 +57,7 @@ const banner = el<HTMLDivElement>('bridge-banner');
const bannerMessage = el<HTMLSpanElement>('bridge-message');
const bannerMeter = el<HTMLSpanElement>('bridge-meter');
const bannerMeterFill = el<HTMLElement>('bridge-meter-fill');
const bannerUpdate = el<HTMLButtonElement>('bridge-update');
const statusMessage = el<HTMLSpanElement>('status-message');
const browseTab = el<HTMLButtonElement>('tab-browse');
const extensionsTab = el<HTMLButtonElement>('tab-extensions');
@@ -121,31 +122,33 @@ const BRIDGE_LABELS: Record<AnimeBrowserBridgeState['stage'], string> = {
idle: 'Starting the extension bridge',
locating: 'Looking up the extension bridge release',
downloading: 'Downloading the extension bridge',
verifying: 'Verifying the download',
extracting: 'Unpacking the extension bridge',
starting: 'Starting the extension bridge',
ready: 'Bridge ready',
failed: 'Bridge failed to start',
};
const BUSY_STAGES = new Set([
'idle',
'locating',
'downloading',
'verifying',
'extracting',
'starting',
]);
const BUSY_STAGES = new Set(['idle', 'locating', 'downloading', 'extracting', 'starting']);
function renderBridgeState(state: AnimeBrowserBridgeState): void {
const busy = BUSY_STAGES.has(state.stage);
banner.dataset.stage = state.stage;
banner.dataset.busy = String(busy);
// An update is only offered from a running bridge; mid-start it would race
// the start it interrupts.
const update = state.stage === 'ready' ? (state.install?.updateAvailable ?? null) : null;
// Once ready with nothing to report, the banner has nothing to say.
const hide = state.stage === 'ready' && state.message === null;
const hide = state.stage === 'ready' && state.message === null && update === null;
banner.classList.toggle('hidden', hide);
bannerMessage.textContent = state.message ?? BRIDGE_LABELS[state.stage];
bannerMessage.textContent =
state.message ??
(update === null
? BRIDGE_LABELS[state.stage]
: `Extension bridge ${state.install?.version ?? 'of unknown version'} is installed; ${update} is available.`);
bannerUpdate.classList.toggle('hidden', update === null);
bannerUpdate.textContent = update === null ? '' : `Update to ${update}`;
bannerUpdate.disabled = busy;
const showMeter = state.progress !== null;
bannerMeter.classList.toggle('hidden', !showMeter);
@@ -438,6 +441,31 @@ sourceSelect.addEventListener('change', () => {
loadMoreButton.addEventListener('click', () => void loadNextPage());
bannerUpdate.addEventListener('click', () => {
void (async () => {
bannerUpdate.disabled = true;
try {
const state = await api.updateBridge();
renderBridgeState(state);
if (state.stage === 'ready' && state.install?.updateAvailable === null) {
setStatus(
`Extension bridge updated to ${state.install.version ?? 'the pinned release'}.`,
'ok',
);
} else if (state.message !== null) {
setStatus(state.message, 'error');
}
// The bridge restarted, so the source list is fresh from disk.
const snapshot = await api.getSnapshot();
renderSources(snapshot.sources, snapshot.selectedSourceId);
if (currentView === 'extensions') await extensions.refresh();
} catch (error) {
setStatus(describe(error), 'error');
bannerUpdate.disabled = false;
}
})();
});
api.onBridgeState(renderBridgeState);
// The queue changes without this window asking: it advances by itself when an
@@ -450,7 +478,7 @@ api.onPlaybackState((state) => {
});
void (async () => {
renderBridgeState({ stage: 'idle', progress: null, message: null });
renderBridgeState({ stage: 'idle', progress: null, message: null, install: null });
// A queue survives the window being closed and reopened, so start from what
// the main process already holds rather than from empty.
void api.getQueue().then(
@@ -485,7 +513,12 @@ void (async () => {
} catch (error) {
// Without this the window keeps the "starting" banner up forever, with the
// search box disabled and nothing saying why.
renderBridgeState({ stage: 'failed', progress: null, message: describe(error) });
renderBridgeState({
stage: 'failed',
progress: null,
message: describe(error),
install: null,
});
setStatus(describe(error), 'error');
}
})();
+3 -1
View File
@@ -1,6 +1,6 @@
import { describe, el } from './dom';
import { buildIconIndex, iconMonogram, isSafeIconUrl, repoFaviconUrl } from './extension-icons';
import { describeInstalled } from './format';
import { describeBridgeInstall, describeInstalled } from './format';
import {
collectLanguages,
filterByLanguage,
@@ -121,6 +121,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
const { api, setStatus, onSourcesChanged } = options;
const extensionsDirLabel = el<HTMLSpanElement>('extensions-dir');
const bridgeInfo = el<HTMLParagraphElement>('bridge-info');
const installedList = el<HTMLDivElement>('installed-list');
const installedCount = el<HTMLSpanElement>('installed-count');
const availableList = el<HTMLDivElement>('extensions-list');
@@ -328,6 +329,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
async function refresh(): Promise<void> {
const snapshot = await api.getSnapshot();
extensionsDirLabel.textContent = snapshot.extensionsDir;
bridgeInfo.textContent = describeBridgeInstall(snapshot.bridge.install);
renderRepos(snapshot.repos);
repoFailures = [];
+32 -1
View File
@@ -1,6 +1,11 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { describeInstalled, sourceOptionLabel, summarizeSearch } from './format';
import {
describeInstalled,
sourceOptionLabel,
summarizeSearch,
describeBridgeInstall,
} from './format';
import type { AnimeBrowserSearchResult } from '../types/anime-browser';
const result = (
@@ -58,3 +63,29 @@ test('describeInstalled falls back to the package alone when nothing loaded', ()
'broken',
);
});
test('describeBridgeInstall says who updates the bridge', () => {
assert.match(describeBridgeInstall(null), /not started/);
assert.match(
describeBridgeInstall({
origin: 'system',
version: 'v1.0.6.2',
dir: '/usr/share/mangatan/extension_server',
updateAvailable: null,
}),
/v1\.0\.6\.2 from \/usr\/share\/mangatan\/extension_server.*package manager/,
);
assert.match(
describeBridgeInstall({
origin: 'managed',
version: 'v1.0.5.0',
dir: '/home/u/.config/SubMiner/anime-bridge',
updateAvailable: 'v1.0.6.0',
}),
/v1\.0\.6\.0 is available/,
);
assert.match(
describeBridgeInstall({ origin: 'managed', version: null, dir: '/d', updateAvailable: null }),
/unknown version.*up to date/,
);
});
+14
View File
@@ -1,4 +1,5 @@
import type {
AnimeBrowserBridgeInstall,
AnimeBrowserSearchResult,
AnimeBrowserSource,
InstalledExtensionView,
@@ -33,3 +34,16 @@ export function describeInstalled(view: InstalledExtensionView): string {
if (view.langs.length > 0) parts.push(view.langs.join(', '));
return parts.join(' · ');
}
/** One line for the Extensions tab: which bridge is running and who updates it. */
export function describeBridgeInstall(install: AnimeBrowserBridgeInstall | null): string {
if (install === null) return 'The extension bridge has not started yet.';
const version = install.version ?? 'unknown version';
if (install.origin === 'system') {
return `M-Extension-Server ${version} from ${install.dir}, installed outside SubMiner (for example by your package manager), which is where updates come from.`;
}
if (install.updateAvailable !== null) {
return `M-Extension-Server ${version} in ${install.dir}, downloaded by SubMiner. ${install.updateAvailable} is available from the banner above.`;
}
return `M-Extension-Server ${version} in ${install.dir}, downloaded by SubMiner and up to date.`;
}
+9
View File
@@ -73,6 +73,12 @@
<span class="bridge-dot" id="bridge-dot"></span>
<span class="bridge-message" id="bridge-message"></span>
<span class="bridge-meter hidden" id="bridge-meter"><i id="bridge-meter-fill"></i></span>
<button
class="ghost-button bridge-update hidden"
id="bridge-update"
type="button"
title="Downloads the verified release and restarts the bridge. A playing episode's stream stops."
></button>
</div>
<section class="settings hidden" id="settings" role="tabpanel" aria-labelledby="tab-settings">
@@ -122,6 +128,9 @@
</h3>
<div class="lang-filter" id="lang-filter" role="group" aria-label="Filter by language"></div>
<div class="ext-list" id="extensions-list" aria-label="Available extensions"></div>
<h3 class="ext-group-title">Bridge</h3>
<p class="repo-hint" id="bridge-info"></p>
</section>
<main class="layout" id="layout" role="tabpanel" aria-labelledby="tab-browse">
+7
View File
@@ -304,6 +304,13 @@ body {
transition: width 0.2s ease;
}
.bridge-update {
margin-left: auto;
padding: 4px 12px;
border-radius: 8px;
font-size: 12px;
}
/* ---------- layout ---------- */
.layout {
@@ -102,6 +102,7 @@ export const INTEGRATIONS_DEFAULT_CONFIG: Pick<
extensionsDir: '',
repos: [],
preferredQuality: '',
bridgeDir: '',
},
jimaku: {
apiBaseUrl: 'https://jimaku.cc',
@@ -587,6 +587,13 @@ export function buildIntegrationConfigOptionRegistry(
description:
'Preferred stream quality label, matched as a substring (for example: 1080). Empty uses the source order.',
},
{
path: 'anime.bridgeDir',
kind: 'string',
defaultValue: defaultConfig.anime.bridgeDir,
description:
'Directory holding an M-Extension-Server bundle (java runtime plus server jar) to run instead of the copy SubMiner downloads. Empty checks the package-manager install (Arch: mangatan-extension-server), then <userData>/anime-bridge.',
},
{
path: 'jellyfin.enabled',
kind: 'boolean',
+7
View File
@@ -357,6 +357,13 @@ export function applyIntegrationConfig(context: ResolveContext): void {
);
}
const bridgeDir = asString(src.anime.bridgeDir);
if (bridgeDir !== undefined) {
resolved.anime.bridgeDir = normalizeExternalProfilePath(bridgeDir);
} else if (src.anime.bridgeDir !== undefined) {
warn('anime.bridgeDir', src.anime.bridgeDir, resolved.anime.bridgeDir, 'Expected string.');
}
const preferredQuality = asString(src.anime.preferredQuality);
if (preferredQuality !== undefined) {
resolved.anime.preferredQuality = preferredQuality.trim();
+12 -1
View File
@@ -543,7 +543,11 @@ import {
} from './main/runtime/setup-window-factory';
import { createAnimeBrowserApplicationRuntime } from './main/runtime/anime-browser-application-runtime';
import { openAnimeBrowserModal as openAnimeBrowserModalRuntime } from './main/runtime/anime-browser-open';
import { ensureBridgeBinaries } from './main/runtime/anime-bridge-installer';
import {
ensureBridgeBinaries,
findBridgeUpdate,
stageBridgeUpdate,
} from './main/runtime/anime-bridge-installer';
import { createConfigSettingsRuntime } from './main/runtime/config-settings-runtime';
import { createOpenConfigSettingsWindowHandler } from './main/runtime/config-settings-window';
import { createSyncUiRuntime } from './main/runtime/sync-ui-runtime';
@@ -3332,6 +3336,13 @@ const animeBrowserApplicationRuntime = createAnimeBrowserApplicationRuntime({
preferencesFile: path.join(USER_DATA_PATH, 'anime-source-preferences.json'),
ensureBinaries: (onProgress) =>
ensureBridgeBinaries({
installDir: path.join(USER_DATA_PATH, 'anime-bridge'),
configuredDir: configService.getConfig().anime?.bridgeDir,
onProgress,
}),
checkBridgeUpdate: (install) => findBridgeUpdate(install),
stageBridgeUpdate: (onProgress) =>
stageBridgeUpdate({
installDir: path.join(USER_DATA_PATH, 'anime-bridge'),
onProgress,
}),
@@ -0,0 +1,234 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { access, mkdir, mkdtemp, readdir, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import {
BUNDLE_MARKER_FILE,
readBundleMarker,
writeBundleMarker,
} from '../../anime-bridge/sidecar-bundle';
import {
ensureBridgeBinaries,
findBridgeUpdate,
stageBridgeUpdate,
type EnsureBridgeOptions,
} from './anime-bridge-installer';
/** Lay down the upstream bundle shape: a nested jre plus a versioned server jar. */
async function writeBundle(dir: string, jarName: string): Promise<void> {
await mkdir(path.join(dir, 'jre', 'bin'), { recursive: true });
await writeFile(path.join(dir, 'jre', 'bin', 'java'), '');
await writeFile(path.join(dir, jarName), '');
}
async function exists(file: string): Promise<boolean> {
try {
await access(file);
return true;
} catch {
return false;
}
}
const LATEST = 'v1.0.6.2';
const downloadUrl = (tag: string) => `https://example.test/${tag}/linux-x64-bundle.zip`;
/**
* Upstream's release list answered from memory: a newer release, an older one,
* an iOS runtime release with no desktop bundle, and one below the minimum.
*/
function fakeUpstream() {
const calls: string[] = [];
const bytes = new TextEncoder().encode('zip bytes');
const asset = (tag: string) => ({
name: 'linux-x64-bundle.zip',
browser_download_url: downloadUrl(tag),
size: bytes.length,
});
const fetchImpl: typeof fetch = async (input) => {
const url = String(input);
calls.push(url);
if (url.includes('/releases?')) {
return new Response(
JSON.stringify([
{ tag_name: 'ios-runtime-v7', assets: [{ name: 'MExtensionServer-ios.jar' }] },
{ tag_name: 'v1.0.6.1', assets: [asset('v1.0.6.1')] },
{ tag_name: LATEST, assets: [asset(LATEST)] },
{ tag_name: 'v1.0.5.0', assets: [asset('v1.0.5.0')] },
]),
);
}
if (url === downloadUrl(LATEST)) return new Response(bytes);
return new Response('not found', { status: 404 });
};
const options = {
platform: 'linux',
arch: 'x64',
fetchImpl,
extractImpl: async (_zipPath: string, targetDir: string) =>
writeBundle(targetDir, `MExtensionServer-${LATEST}.jar`),
} satisfies Partial<EnsureBridgeOptions>;
return { calls, options };
}
async function tempRoot(): Promise<string> {
return mkdtemp(path.join(tmpdir(), 'subminer-bridge-install-'));
}
test('anime.bridgeDir wins over every other location and is never downloaded', async () => {
const root = await tempRoot();
const configured = path.join(root, 'configured');
await writeBundle(configured, 'MExtensionServer-v1.0.6.2.jar');
const system = path.join(root, 'system');
await writeBundle(system, 'MExtensionServer-v1.0.6.1.jar');
const { calls, options } = fakeUpstream();
const install = await ensureBridgeBinaries({
...options,
installDir: path.join(root, 'managed'),
configuredDir: configured,
systemDirs: [system],
});
assert.equal(install.dir, configured);
assert.equal(install.origin, 'system');
assert.equal(install.version, 'v1.0.6.2');
assert.equal(install.updateAvailable, null);
assert.equal(install.jarPath, path.join(configured, 'MExtensionServer-v1.0.6.2.jar'));
assert.deepEqual(calls, []);
});
test('an anime.bridgeDir without a bundle is an error, not a fallback', async () => {
const root = await tempRoot();
const { options } = fakeUpstream();
await assert.rejects(
ensureBridgeBinaries({
...options,
installDir: path.join(root, 'managed'),
configuredDir: path.join(root, 'empty'),
systemDirs: [],
}),
/anime\.bridgeDir .*holds no java runtime/,
);
});
test('a package-manager install is used ahead of the managed copy', async () => {
const root = await tempRoot();
const system = path.join(root, 'usr', 'share', 'mangatan', 'extension_server');
await writeBundle(system, 'MExtensionServer-v1.0.6.2.jar');
const managed = path.join(root, 'managed');
await writeBundle(managed, 'MExtensionServer-v1.0.6.0.jar');
const { options } = fakeUpstream();
const install = await ensureBridgeBinaries({
...options,
installDir: managed,
systemDirs: [path.join(root, 'missing'), system],
});
assert.equal(install.dir, system);
assert.equal(install.origin, 'system');
assert.equal(install.version, 'v1.0.6.2');
});
test('a managed install reports the marker tag over the jar name and is not re-downloaded', async () => {
const root = await tempRoot();
const managed = path.join(root, 'managed');
// The jar name says one thing, the marker another: the marker is what we installed.
await writeBundle(managed, 'MExtensionServer-v1.0.6.0.jar');
await writeBundleMarker(managed, 'v1.0.5.0');
const { calls, options } = fakeUpstream();
const install = await ensureBridgeBinaries({ ...options, installDir: managed, systemDirs: [] });
assert.equal(install.origin, 'managed');
assert.equal(install.version, 'v1.0.5.0');
assert.deepEqual(calls, []);
});
test('a managed install from before the marker reads its version off the jar', async () => {
const root = await tempRoot();
const managed = path.join(root, 'managed');
await writeBundle(managed, 'MExtensionServer-v1.0.6.0-r1.jar');
const { options } = fakeUpstream();
const install = await ensureBridgeBinaries({ ...options, installDir: managed, systemDirs: [] });
assert.equal(install.version, 'v1.0.6.0');
});
test('with nothing installed the newest release is downloaded and marked', async () => {
const root = await tempRoot();
const managed = path.join(root, 'managed');
const { calls, options } = fakeUpstream();
const stages: string[] = [];
const install = await ensureBridgeBinaries({
...options,
installDir: managed,
systemDirs: [],
onProgress: (progress) => stages.push(progress.stage),
});
assert.equal(install.origin, 'managed');
assert.equal(install.dir, managed);
assert.equal(install.version, LATEST);
assert.equal(calls.length, 2);
assert.equal(calls[1], downloadUrl(LATEST));
assert.equal(await readBundleMarker(managed), LATEST);
assert.deepEqual([...new Set(stages)], ['locating', 'downloading', 'extracting']);
// The archive is not left behind next to the unpacked bundle.
assert.ok(!(await readdir(managed)).some((entry) => entry.endsWith('.zip')));
});
test('findBridgeUpdate offers the newest release only to a managed install that is behind it', async () => {
const { calls, options } = fakeUpstream();
assert.equal(await findBridgeUpdate({ origin: 'managed', version: 'v1.0.6.0' }, options), LATEST);
assert.equal(await findBridgeUpdate({ origin: 'managed', version: LATEST }, options), null);
assert.equal(await findBridgeUpdate({ origin: 'managed', version: 'v1.0.7.0' }, options), null);
// An unreadable version is offered the newest release as the way back to a known state.
assert.equal(await findBridgeUpdate({ origin: 'managed', version: null }, options), LATEST);
assert.equal(calls.length, 4);
// A system install is pacman's, so upstream is not even asked.
assert.equal(await findBridgeUpdate({ origin: 'system', version: 'v1.0.0.0' }, options), null);
assert.equal(calls.length, 4);
});
test('findBridgeUpdate propagates a failed release listing', async () => {
const fetchImpl: typeof fetch = async () => new Response('rate limited', { status: 403 });
await assert.rejects(
findBridgeUpdate(
{ origin: 'managed', version: 'v1.0.6.0' },
{ platform: 'linux', arch: 'x64', fetchImpl },
),
/Could not list anime bridge releases \(403\)/,
);
});
test('stageBridgeUpdate downloads beside the install and commit swaps it in', async () => {
const root = await tempRoot();
const managed = path.join(root, 'managed');
await writeBundle(managed, 'MExtensionServer-v1.0.5.0.jar');
await writeBundleMarker(managed, 'v1.0.5.0');
const { options } = fakeUpstream();
const staged = await stageBridgeUpdate({ ...options, installDir: managed, systemDirs: [] });
assert.equal(staged.version, LATEST);
// The running install is untouched until commit.
assert.ok(await exists(path.join(managed, 'MExtensionServer-v1.0.5.0.jar')));
assert.equal(await readBundleMarker(managed), 'v1.0.5.0');
assert.ok(await exists(path.join(`${managed}.next`, `MExtensionServer-${LATEST}.jar`)));
const install = await staged.commit();
assert.equal(install.dir, managed);
assert.equal(install.version, LATEST);
assert.equal(install.jarPath, path.join(managed, `MExtensionServer-${LATEST}.jar`));
assert.ok(!(await exists(path.join(managed, 'MExtensionServer-v1.0.5.0.jar'))));
assert.ok(!(await exists(`${managed}.next`)));
assert.ok(await exists(path.join(managed, BUNDLE_MARKER_FILE)));
});
+145 -32
View File
@@ -1,22 +1,34 @@
import { spawn } from 'node:child_process';
import { chmod, mkdir, rm, writeFile } from 'node:fs/promises';
import { chmod, mkdir, rename, rm, writeFile } from 'node:fs/promises';
import path from 'node:path';
import {
bundleReleaseUrl,
bundleVersionFromJar,
compareBundleVersions,
findBundleBinaries,
PINNED_BUNDLE_TAG,
readBundleMarker,
resolveBundleAssetName,
selectBundleAsset,
verifyPinnedBundle,
systemBundleDirs,
writeBundleMarker,
type BundleAsset,
type BundleBinaries,
} from '../../anime-bridge/sidecar-bundle';
import type { AnimeBrowserBridgeInstall } from '../../types/anime-browser';
/**
* Downloads and unpacks the M-Extension-Server bundle that runs Aniyomi
* extension APKs. The bundle ships its own JRE, so no system Java is needed.
* Locates the M-Extension-Server bundle that runs Aniyomi extension APKs, or
* downloads and unpacks it. The bundle ships its own JRE, so no system Java is
* needed.
*
* Resolution order: an explicit `anime.bridgeDir`, then a package-manager
* install (the AUR `mangatan-extension-server` package on Arch), then the copy
* SubMiner manages under userData. Only the managed copy is ever downloaded or
* updated; the others belong to whoever put them there. Downloads take the
* newest upstream release that ships a bundle for this platform.
*/
export type InstallStage = 'locating' | 'downloading' | 'verifying' | 'extracting';
export type InstallStage = 'locating' | 'downloading' | 'extracting';
/** Neither call has a default deadline, so a hung network would stall install. */
const RELEASES_TIMEOUT_MS = 30_000;
@@ -40,12 +52,24 @@ export interface InstallProgress {
progress: number | null;
}
export interface EnsureBridgeOptions {
/** Directory the bundle is unpacked into, e.g. `<userData>/anime-bridge`. */
installDir: string;
/** The binaries to launch plus where they came from, for the browser to show. */
export type BridgeInstall = BundleBinaries & AnimeBrowserBridgeInstall;
export interface BridgeReleaseOptions {
platform?: string;
arch?: string;
fetchImpl?: typeof fetch;
}
export interface EnsureBridgeOptions extends BridgeReleaseOptions {
/** Directory the managed bundle is unpacked into, e.g. `<userData>/anime-bridge`. */
installDir: string;
/** `anime.bridgeDir`: a bundle the user pointed at. Must hold a usable bundle. */
configuredDir?: string;
/** Package-manager locations to check before downloading. Defaults per platform. */
systemDirs?: string[];
/** Replaces the unzip/tar extraction. Tests only. */
extractImpl?: (zipPath: string, targetDir: string) => Promise<void>;
onProgress?: (progress: InstallProgress) => void;
}
@@ -123,14 +147,8 @@ async function downloadWithProgress(
return merged;
}
/**
* Return the bundle's java + jar paths, downloading the release first if the
* install directory does not already hold a usable copy.
*/
export async function ensureBridgeBinaries(options: EnsureBridgeOptions): Promise<BundleBinaries> {
const existing = await findBundleBinaries(options.installDir);
if (existing) return existing;
/** The newest upstream release that ships this platform's bundle. */
async function locateLatestBundle(options: BridgeReleaseOptions): Promise<BundleAsset> {
const platform = options.platform ?? process.platform;
const arch = options.arch ?? process.arch;
const fetchImpl = options.fetchImpl ?? fetch;
@@ -143,20 +161,59 @@ export async function ensureBridgeBinaries(options: EnsureBridgeOptions): Promis
);
}
options.onProgress?.({ stage: 'locating', progress: null });
const releasesResponse = await fetchImpl(bundleReleaseUrl(), {
headers: { Accept: 'application/vnd.github+json' },
signal: AbortSignal.timeout(RELEASES_TIMEOUT_MS),
});
if (!releasesResponse.ok) {
throw new Error(
`Could not read anime bridge release ${PINNED_BUNDLE_TAG} (${releasesResponse.status}).`,
);
throw new Error(`Could not list anime bridge releases (${releasesResponse.status}).`);
}
const asset = selectBundleAsset(await releasesResponse.json(), assetName);
if (asset === null) {
throw new Error(`Anime bridge release ${PINNED_BUNDLE_TAG} has no ${assetName}.`);
throw new Error(`No anime bridge release ships ${assetName} at a supported version.`);
}
return asset;
}
/**
* The newest release a managed install could move to, or null when it is
* current or is not SubMiner's to update. An install whose version cannot be
* read is offered the newest release: re-downloading is the way back to a
* known state. Network errors propagate; the caller decides how loudly.
*/
export async function findBridgeUpdate(
install: Pick<AnimeBrowserBridgeInstall, 'origin' | 'version'>,
options: BridgeReleaseOptions = {},
): Promise<string | null> {
if (install.origin !== 'managed') return null;
const latest = await locateLatestBundle(options);
if (install.version === null) return latest.tagName;
return compareBundleVersions(latest.tagName, install.version) > 0 ? latest.tagName : null;
}
async function describeInstall(
binaries: BundleBinaries,
dir: string,
origin: AnimeBrowserBridgeInstall['origin'],
): Promise<BridgeInstall> {
const version =
(origin === 'managed' ? await readBundleMarker(dir) : null) ??
bundleVersionFromJar(binaries.jarPath);
// The update check needs the network and runs once the bridge is up.
return { ...binaries, dir, origin, version, updateAvailable: null };
}
/**
* Download the newest release into `targetDir` and unpack it. The directory
* is created if needed and is not cleared first.
*/
async function downloadLatestBundle(
targetDir: string,
options: EnsureBridgeOptions,
): Promise<{ binaries: BundleBinaries; tagName: string }> {
options.onProgress?.({ stage: 'locating', progress: null });
const asset = await locateLatestBundle(options);
const fetchImpl = options.fetchImpl ?? fetch;
options.onProgress?.({ stage: 'downloading', progress: 0 });
const downloadResponse = await fetchImpl(asset.downloadUrl, {
@@ -169,27 +226,83 @@ export async function ensureBridgeBinaries(options: EnsureBridgeOptions): Promis
options.onProgress?.({ stage: 'downloading', progress: fraction }),
);
options.onProgress?.({ stage: 'verifying', progress: null });
const verification = verifyPinnedBundle(assetName, bytes);
if (!verification.ok) throw new Error(verification.reason);
options.onProgress?.({ stage: 'extracting', progress: null });
await mkdir(options.installDir, { recursive: true });
const zipPath = path.join(options.installDir, assetName);
await mkdir(targetDir, { recursive: true });
const zipPath = path.join(targetDir, asset.assetName);
await writeFile(zipPath, bytes);
try {
await extractZip(zipPath, options.installDir);
await (options.extractImpl ?? extractZip)(zipPath, targetDir);
} finally {
await rm(zipPath, { force: true });
}
const binaries = await findBundleBinaries(options.installDir);
const binaries = await findBundleBinaries(targetDir);
if (!binaries) {
throw new Error('The anime bridge bundle unpacked without a java runtime or server jar.');
}
// Some extractors drop the executable bit; restore it rather than failing at spawn.
if (platform !== 'win32') {
if ((options.platform ?? process.platform) !== 'win32') {
await chmod(binaries.javaPath, 0o755).catch(() => undefined);
}
return binaries;
await writeBundleMarker(targetDir, asset.tagName);
return { binaries, tagName: asset.tagName };
}
/**
* Return the bridge's java + jar paths, downloading the newest release into
* the managed directory only when no other install is usable.
*/
export async function ensureBridgeBinaries(options: EnsureBridgeOptions): Promise<BridgeInstall> {
const configuredDir = options.configuredDir?.trim();
if (configuredDir) {
const configured = await findBundleBinaries(configuredDir);
if (!configured) {
throw new Error(
`anime.bridgeDir (${configuredDir}) holds no java runtime or MExtensionServer jar.`,
);
}
return describeInstall(configured, configuredDir, 'system');
}
for (const dir of options.systemDirs ?? systemBundleDirs(options.platform ?? process.platform)) {
const system = await findBundleBinaries(dir);
if (system) return describeInstall(system, dir, 'system');
}
const existing = await findBundleBinaries(options.installDir);
if (existing) return describeInstall(existing, options.installDir, 'managed');
const downloaded = await downloadLatestBundle(options.installDir, options);
return describeInstall(downloaded.binaries, options.installDir, 'managed');
}
export interface StagedBridgeUpdate {
version: string;
/**
* Replace the managed install with the staged one. Call only once the old
* bridge has stopped: on Windows its open files cannot be removed, and on
* every platform a JVM still running out of the old tree would outlive it.
*/
commit: () => Promise<BridgeInstall>;
}
/**
* Download the newest release next to the managed install without touching
* it, so a failed download leaves the running bridge as it was.
*/
export async function stageBridgeUpdate(options: EnsureBridgeOptions): Promise<StagedBridgeUpdate> {
const stagingDir = `${options.installDir}.next`;
await rm(stagingDir, { recursive: true, force: true });
const downloaded = await downloadLatestBundle(stagingDir, options);
return {
version: downloaded.tagName,
commit: async () => {
await rm(options.installDir, { recursive: true, force: true });
await rename(stagingDir, options.installDir);
const binaries = await findBundleBinaries(options.installDir);
if (!binaries)
throw new Error('The updated anime bridge is missing its java runtime or jar.');
return describeInstall(binaries, options.installDir, 'managed');
},
};
}
@@ -48,6 +48,7 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
registerSession(deps, event, sessionId);
return runtime.ensureBridge();
});
handle(channels.animeBrowserUpdateBridge, () => runtime.updateBridge());
handle(channels.animeBrowserSelectSource, (event, sessionId, sourceId) =>
runtime.selectSource(String(sourceId), registerSession(deps, event, sessionId)),
);
@@ -0,0 +1,210 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtemp } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type { AnimeBridgeClient } from '../../anime-bridge/bridge-client';
import type { AnimeBrowserBridgeState } from '../../types/anime-browser';
import type { BridgeInstall, StagedBridgeUpdate } from './anime-bridge-installer';
import { createAnimeBrowserRuntime, type AnimeBrowserRuntimeDeps } from './anime-browser-runtime';
// The installer never fills `updateAvailable`; the runtime asks upstream later.
const OLD: BridgeInstall = {
javaPath: '/managed/jre/bin/java',
jarPath: '/managed/MExtensionServer-v1.0.5.0.jar',
dir: '/managed',
origin: 'managed',
version: 'v1.0.5.0',
updateAvailable: null,
};
const NEW: BridgeInstall = { ...OLD, version: 'v1.0.6.0' };
/** Upstream's newest release, as the update check would report it. */
const LATEST = 'v1.0.6.0';
const tick = () => new Promise((resolve) => setTimeout(resolve, 10));
async function setup(overrides: Partial<AnimeBrowserRuntimeDeps> = {}) {
const dir = await mkdtemp(path.join(tmpdir(), 'subminer-anime-bridge-update-'));
const states: AnimeBrowserBridgeState[] = [];
const stopped: number[] = [];
let started = 0;
let current = OLD;
const runtime = createAnimeBrowserRuntime({
extensionsDir: () => dir,
repos: () => [],
setRepos: () => undefined,
preferencesFile: path.join(dir, 'preferences.json'),
ensureBinaries: async () => current,
checkBridgeUpdate: async (install) => (install.version === LATEST ? null : LATEST),
stageBridgeUpdate: async (onProgress) => {
onProgress({ stage: 'downloading', progress: 0.5 });
return {
version: NEW.version!,
commit: async () => {
current = NEW;
return NEW;
},
};
},
sendMpvCommand: () => undefined,
ensureMpvConnected: async () => true,
onBridgeState: (state) => states.push(state),
log: () => undefined,
startSidecar: async () => {
const id = ++started;
return {
client: { listAnimeSources: async () => [] } as unknown as AnimeBridgeClient,
baseUrl: `http://127.0.0.1:${id}`,
port: id,
stop: async () => {
stopped.push(id);
},
onExit: () => undefined,
};
},
startStreamStripProxy: async () => ({
origin: 'http://127.0.0.1:9',
port: 9,
close: async () => undefined,
}),
...overrides,
});
return { runtime, states, stopped, started: () => started };
}
test('the bridge state says where the running bridge came from, then learns about an update', async () => {
const { runtime, states } = await setup();
const state = await runtime.ensureBridge();
assert.equal(state.stage, 'ready');
assert.equal(state.install?.origin, 'managed');
assert.equal(state.install?.version, 'v1.0.5.0');
assert.equal(state.install?.dir, '/managed');
// The upstream check is kicked off after ready, off the start path, and
// re-broadcasts the ready state once it answers.
await tick();
const latest = runtime.getSnapshot().bridge;
assert.equal(latest.stage, 'ready');
assert.equal(latest.install?.updateAvailable, LATEST);
assert.equal(states.at(-1)?.install?.updateAvailable, LATEST);
});
test('a failed update check is logged and leaves the bridge ready', async () => {
const logged: string[] = [];
const { runtime } = await setup({
checkBridgeUpdate: async () => {
throw new Error('rate limited');
},
log: (message) => logged.push(message),
});
await runtime.ensureBridge();
await tick();
const state = runtime.getSnapshot().bridge;
assert.equal(state.stage, 'ready');
assert.equal(state.install?.updateAvailable, null);
assert.ok(logged.some((line) => /update check failed: rate limited/.test(line)));
});
test('a system install is never asked about updates', async () => {
let asked = 0;
const { runtime } = await setup({
ensureBinaries: async () => ({ ...OLD, origin: 'system' }),
checkBridgeUpdate: async () => {
asked += 1;
return LATEST;
},
});
await runtime.ensureBridge();
await tick();
assert.equal(asked, 0);
assert.equal(runtime.getSnapshot().bridge.install?.updateAvailable, null);
});
test('updateBridge stages, stops the old bridge, and restarts on the new install', async () => {
const { runtime, states, stopped, started } = await setup();
await runtime.ensureBridge();
await tick();
assert.equal(runtime.getSnapshot().bridge.install?.updateAvailable, LATEST);
const state = await runtime.updateBridge();
assert.deepEqual(stopped, [1]);
assert.equal(started(), 2);
assert.equal(state.stage, 'ready');
assert.equal(state.install?.version, 'v1.0.6.0');
// Progress from the staging download reached the UI, still against the old install.
const downloading = states.find((candidate) => candidate.stage === 'downloading');
assert.equal(downloading?.progress, 0.5);
assert.equal(downloading?.install?.version, 'v1.0.5.0');
// The post-restart check finds nothing newer, so the button goes away.
await tick();
assert.equal(runtime.getSnapshot().bridge.install?.updateAvailable, null);
});
test('a failed download leaves the running bridge as it was', async () => {
const { runtime, stopped, started } = await setup({
stageBridgeUpdate: async () => {
throw new Error('offline');
},
});
await runtime.ensureBridge();
const state = await runtime.updateBridge();
assert.deepEqual(stopped, []);
assert.equal(started(), 1);
assert.equal(state.stage, 'ready');
assert.match(state.message ?? '', /Bridge update failed: offline/);
});
test('updateBridge refuses to touch a system install', async () => {
const system: BridgeInstall = {
...OLD,
origin: 'system',
dir: '/usr/share/mangatan/extension_server',
};
const { runtime } = await setup({ ensureBinaries: async () => system });
await runtime.ensureBridge();
await assert.rejects(runtime.updateBridge(), /managed outside SubMiner/);
});
test('requests that arrive mid-update wait for the new bridge instead of starting the old one', async () => {
let releaseCommit: () => void = () => undefined;
const commitGate = new Promise<void>((resolve) => {
releaseCommit = resolve;
});
let current = OLD;
const staged: StagedBridgeUpdate = {
version: 'v1.0.6.0',
commit: async () => {
await commitGate;
current = NEW;
return NEW;
},
};
const { runtime, started } = await setup({
ensureBinaries: async () => current,
stageBridgeUpdate: async () => staged,
});
await runtime.ensureBridge();
const update = runtime.updateBridge();
// Let the update get past stopping the old bridge and into the gated commit.
await new Promise((resolve) => setTimeout(resolve, 10));
let ensured: AnimeBrowserBridgeState | null = null;
const waiting = runtime.ensureBridge().then((state) => {
ensured = state;
});
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(ensured, null);
assert.equal(started(), 1);
releaseCommit();
await update;
await waiting;
assert.equal(started(), 2);
assert.equal(ensured!.install?.version, 'v1.0.6.0');
});
+16 -3
View File
@@ -8,15 +8,15 @@ import type {
export type StreamWatchMark = StreamPlaybackMetadataInput;
import type { PlaybackEndFileEvent } from '../../anime-bridge/playback-outcome';
import type { SubtitleCacheIo } from '../../anime-bridge/subtitle-cache';
import type { BundleBinaries } from '../../anime-bridge/sidecar-bundle';
import { startSidecar } from '../../anime-bridge/sidecar-process';
import { startStreamStripProxy } from '../../anime-bridge/stream-strip-proxy';
import type {
AnimeBrowserBridgeInstall,
AnimeBrowserBridgeState,
AnimeBrowserQueueState,
AnimeBrowserSearchUpdate,
} from '../../types/anime-browser';
import type { InstallProgress } from './anime-bridge-installer';
import type { BridgeInstall, InstallProgress, StagedBridgeUpdate } from './anime-bridge-installer';
export interface AnimeBrowserRuntimeDeps {
/** Where user-supplied Aniyomi extension APKs live. Read lazily so config edits apply. */
@@ -27,7 +27,20 @@ export interface AnimeBrowserRuntimeDeps {
setRepos: (repos: string[]) => void;
/** JSON file holding each source's saved preference values. */
preferencesFile: string;
ensureBinaries: (onProgress: (progress: InstallProgress) => void) => Promise<BundleBinaries>;
/** Locates (or first downloads) the bridge and says where it came from. */
ensureBinaries: (onProgress: (progress: InstallProgress) => void) => Promise<BridgeInstall>;
/**
* The newest upstream release a managed install could move to, or null.
* Asked once the bridge is running; a rejection is logged, not shown.
*/
checkBridgeUpdate: (install: AnimeBrowserBridgeInstall) => Promise<string | null>;
/**
* Downloads the newest release beside the managed install; the returned
* `commit` swaps it in once the old bridge has stopped.
*/
stageBridgeUpdate: (
onProgress: (progress: InstallProgress) => void,
) => Promise<StagedBridgeUpdate>;
/** Sends mpv an IPC command; same transport the Jellyfin path uses. */
sendMpvCommand: (command: Array<string | number>) => void;
/** Brings mpv up if it is not already connected. Resolves false on failure. */
@@ -31,6 +31,10 @@ async function setupRuntime(
setRepos: () => undefined,
preferencesFile,
ensureBinaries: async () => ({}) as never,
checkBridgeUpdate: async () => null,
stageBridgeUpdate: async () => {
throw new Error('not under test');
},
sendMpvCommand: () => undefined,
ensureMpvConnected: async () => true,
onBridgeState: () => undefined,
+118 -13
View File
@@ -32,6 +32,7 @@ import { applyPreferenceValue, parsePreferences } from '../../anime-bridge/prefe
import type { SourcePreferenceView } from '../../anime-bridge/preferences';
import { ALL_SOURCES_ID } from '../../types/anime-browser';
import type {
AnimeBrowserBridgeInstall,
AnimeBrowserBridgeState,
AnimeBrowserDetails,
AnimeBrowserEntry,
@@ -54,13 +55,23 @@ import { createAnimeBrowserQueue } from './anime-browser-queue';
import type { AnimeBrowserRuntimeDeps } from './anime-browser-runtime-deps';
export type { AnimeBrowserRuntimeDeps } from './anime-browser-runtime-deps';
const IDLE_STATE: AnimeBrowserBridgeState = { stage: 'idle', progress: null, message: null };
const IDLE_STATE: AnimeBrowserBridgeState = {
stage: 'idle',
progress: null,
message: null,
install: null,
};
/** Stage, progress and message; `install` is carried across every state change. */
type BridgeStateChange = Omit<AnimeBrowserBridgeState, 'install'>;
export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
let bridgeState: AnimeBrowserBridgeState = IDLE_STATE;
let install: AnimeBrowserBridgeInstall | null = null;
let sidecar: SidecarHandle | null = null;
let stripProxy: StreamStripProxyHandle | null = null;
let starting: Promise<SidecarHandle> | null = null;
let updating: Promise<AnimeBrowserBridgeState> | null = null;
let extensions: InstalledExtension[] = [];
let sources: ExtensionSource[] = [];
let loadFailures: ExtensionLoadFailure[] = [];
@@ -78,9 +89,9 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
return created;
}
function setState(state: AnimeBrowserBridgeState): void {
bridgeState = state;
deps.onBridgeState(state);
function setState(change: BridgeStateChange): void {
bridgeState = { ...change, install };
deps.onBridgeState(bridgeState);
}
async function sourceFor(sourceId: string) {
@@ -119,13 +130,23 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
}
async function startBridge(): Promise<SidecarHandle> {
const binaries = await deps.ensureBinaries((progress) =>
const resolved = await deps.ensureBinaries((progress) =>
setState({ stage: progress.stage, progress: progress.progress, message: null }),
);
install = {
origin: resolved.origin,
version: resolved.version,
dir: resolved.dir,
updateAvailable: resolved.updateAvailable,
};
deps.log(
`[anime-browser] bridge ${resolved.version ?? 'unknown version'} (${resolved.origin}) ` +
`from ${resolved.dir}`,
);
setState({ stage: 'starting', progress: null, message: null });
const handle = await (deps.startSidecar ?? startSidecar)({
binaries,
binaries: resolved,
onLog: (line) => deps.log(`[anime-bridge] ${line}`),
});
sidecar = handle;
@@ -167,9 +188,33 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
}
await scanExtensions(handle);
void checkForBridgeUpdate(handle);
return handle;
}
/**
* Ask upstream whether a managed install is behind, after the bridge is up
* so a slow or failed GitHub call never delays a search. The answer lands
* in `install.updateAvailable` and is re-broadcast on the current state.
*/
async function checkForBridgeUpdate(handle: SidecarHandle): Promise<void> {
if (install === null || install.origin !== 'managed') return;
try {
const latest = await deps.checkBridgeUpdate(install);
// The bridge may have been restarted or updated while we waited.
if (sidecar !== handle || install === null || latest === install.updateAvailable) return;
install = { ...install, updateAvailable: latest };
if (latest !== null) deps.log(`[anime-browser] bridge update available: ${latest}`);
setState({
stage: bridgeState.stage,
progress: bridgeState.progress,
message: bridgeState.message,
});
} catch (error) {
deps.log(`[anime-browser] bridge update check failed: ${describeError(error)}`);
}
}
/**
* Re-read the extensions directory and ask the bridge what each APK provides.
* Called on start and after any install or removal.
@@ -203,7 +248,25 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
});
}
/** Stop the bridge and its proxy on purpose, without disturbing playback state. */
async function stopBridge(): Promise<void> {
const handle = sidecar;
const proxy = stripProxy;
sidecar = null;
stripProxy = null;
starting = null;
await proxy?.close();
await handle?.stop();
}
async function ensureBridge(): Promise<AnimeBrowserBridgeState> {
// A request that lands while an update is swapping directories would start
// the old bridge out of a tree that is about to be deleted.
if (updating) await updating;
return startIfNeeded();
}
async function startIfNeeded(): Promise<AnimeBrowserBridgeState> {
if (sidecar) return bridgeState;
// Collapse concurrent callers onto one start; the UI calls this eagerly.
if (!starting) {
@@ -221,6 +284,52 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
return bridgeState;
}
/**
* Move a managed install to the newest release: download beside it while
* the old bridge keeps serving, then stop, swap, and restart. A failed
* download leaves the old bridge running; a failed swap is reported as a
* failed start, which the next request retries.
*/
function updateBridge(): Promise<AnimeBrowserBridgeState> {
if (updating) return updating;
if (install && install.origin !== 'managed') {
return Promise.reject(
new Error(`The bridge in ${install.dir} is managed outside SubMiner; update it there.`),
);
}
updating = (async () => {
let staged;
try {
staged = await deps.stageBridgeUpdate((progress) =>
setState({ stage: progress.stage, progress: progress.progress, message: null }),
);
} catch (error) {
setState({
stage: sidecar ? 'ready' : 'failed',
progress: null,
message: `Bridge update failed: ${describeError(error)}`,
});
return bridgeState;
}
await stopBridge();
try {
await staged.commit();
} catch (error) {
setState({
stage: 'failed',
progress: null,
message: `Bridge update failed: ${describeError(error)}`,
});
return bridgeState;
}
// Re-resolves the install from disk and re-checks upstream once it is up.
return startIfNeeded();
})().finally(() => {
updating = null;
});
return updating;
}
async function installExtensionFrom(extension: RepoExtension): Promise<void> {
await installExtension({ extensionsDir: deps.extensionsDir(), extension });
if (sidecar) await scanExtensions(sidecar);
@@ -404,6 +513,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
},
ensureBridge,
updateBridge,
/**
* Extensions available from the configured repositories, annotated with
@@ -670,16 +780,11 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
},
async dispose(): Promise<void> {
const handle = sidecar;
const proxy = stripProxy;
sidecar = null;
stripProxy = null;
starting = null;
const stopping = stopBridge();
setState(IDLE_STATE);
await queue.dispose();
await playback.dispose();
await proxy?.close();
await handle?.stop();
await stopping;
},
};
}
@@ -20,6 +20,10 @@ async function setupRuntime(overrides: Partial<AnimeBrowserRuntimeDeps> = {}) {
setRepos: () => undefined,
preferencesFile: path.join(dir, 'preferences.json'),
ensureBinaries: async () => ({}) as never,
checkBridgeUpdate: async () => null,
stageBridgeUpdate: async () => {
throw new Error('not under test');
},
sendMpvCommand: () => undefined,
ensureMpvConnected: async () => true,
onBridgeState: () => undefined,
+2
View File
@@ -40,6 +40,8 @@ export function createAnimeBrowserAPI(ipcRenderer: AnimeBrowserIpcRenderer): Ani
ipcRenderer.invoke(request.animeBrowserGetSnapshot, sessionId),
ensureBridge: (): Promise<AnimeBrowserBridgeState> =>
ipcRenderer.invoke(request.animeBrowserEnsureBridge, sessionId),
updateBridge: (): Promise<AnimeBrowserBridgeState> =>
ipcRenderer.invoke(request.animeBrowserUpdateBridge),
selectSource: (sourceId: string): Promise<void> =>
ipcRenderer.invoke(request.animeBrowserSelectSource, sessionId, sourceId),
search: (query: string, page?: number): Promise<AnimeBrowserSearchResult> =>
+1
View File
@@ -128,6 +128,7 @@ export const IPC_CHANNELS = {
getChangelogSnapshot: 'changelog:get-snapshot',
animeBrowserGetSnapshot: 'anime-browser:get-snapshot',
animeBrowserEnsureBridge: 'anime-browser:ensure-bridge',
animeBrowserUpdateBridge: 'anime-browser:update-bridge',
animeBrowserSelectSource: 'anime-browser:select-source',
animeBrowserSearch: 'anime-browser:search',
animeBrowserGetPopular: 'anime-browser:get-popular',
+28 -1
View File
@@ -128,17 +128,38 @@ export type AnimeBrowserBridgeStage =
| 'idle'
| 'locating'
| 'downloading'
| 'verifying'
| 'extracting'
| 'starting'
| 'ready'
| 'failed';
/** Where the running bridge came from, and whether SubMiner can move it forward. */
export interface AnimeBrowserBridgeInstall {
/**
* `managed`: downloaded by SubMiner into its own directory, so it can be
* updated from the browser. `system`: a package-manager install (the AUR
* `mangatan-extension-server` package) or an `anime.bridgeDir` the user
* pointed at; SubMiner uses it as found and never writes to it.
*/
origin: 'managed' | 'system';
/** Release tag, e.g. `v1.0.6.2`, or null when it cannot be read. */
version: string | null;
dir: string;
/**
* The newest upstream release with a bundle for this platform, when it is
* newer than a managed install; null when current, not managed, or not yet
* checked. Filled in once the bridge is running, since it needs the network.
*/
updateAvailable: string | null;
}
export interface AnimeBrowserBridgeState {
stage: AnimeBrowserBridgeStage;
/** 0-1 while downloading, otherwise null. */
progress: number | null;
message: string | null;
/** Null until the bridge binaries have been located. */
install: AnimeBrowserBridgeInstall | null;
}
/** An extension APK that failed to load, surfaced instead of silently vanishing. */
@@ -255,6 +276,12 @@ export interface AnimeBrowserQueueState {
export interface AnimeBrowserAPI {
getSnapshot: () => Promise<AnimeBrowserSnapshot>;
ensureBridge: () => Promise<AnimeBrowserBridgeState>;
/**
* Download the newest release over a managed install and restart the bridge
* on it. Rejected for a system install. A playing episode's stream dies with
* the old bridge.
*/
updateBridge: () => Promise<AnimeBrowserBridgeState>;
selectSource: (sourceId: string) => Promise<void>;
search: (query: string, page?: number) => Promise<AnimeBrowserSearchResult>;
getPopular: (page?: number) => Promise<AnimeBrowserSearchResult>;
+6
View File
@@ -58,6 +58,12 @@ export interface AnimeConfig {
repos?: string[];
/** Preferred stream label, matched as a substring, e.g. "1080". */
preferredQuality?: string;
/**
* Directory holding an M-Extension-Server bundle (java runtime plus server
* jar) to run instead of the copy SubMiner downloads. Empty checks the
* package-manager location, then the managed copy.
*/
bridgeDir?: string;
}
export interface JimakuConfig {