mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-02 23:54:29 -07:00
feat(anime): add version-aware extension updates
- Compare installed APK version codes before offering updates - Add update status labels and an Update all action
This commit is contained in:
@@ -9,7 +9,7 @@ area: anime
|
||||
- Browse, Extensions, and Source settings are tabs, so each one gets the full window instead of sharing it with the search results.
|
||||
- Repository URLs only need to be an https URL to a `.json` index; the file name is not restricted to `index.min.json`.
|
||||
- The source picker offers "All sources", which searches every installed source at once. Results stream in as each source answers, with per-source progress in the status bar. Results are tagged with their source, failures do not blank the grid, and **Load more** appends later pages without duplicating streamed entries.
|
||||
- The Extensions tab opens with an Installed section listing every extension on disk with Remove — including ones added by hand or whose repository has since been removed — and Update where a configured repository still carries it.
|
||||
- The Extensions tab opens with an Installed section listing every extension on disk with Remove, including ones added by hand or whose repository has since been removed. It compares APK and repository version codes, enables Update only for newer builds, marks current extensions as Up to date, and offers Update all when multiple updates are waiting.
|
||||
- Added `anime.repos`, `anime.extensionsDir`, and `anime.preferredQuality` config keys. SubMiner ships no extension repositories and performs no discovery.
|
||||
- Anime playback targets Japanese audio: dub-labelled entries are skipped when the source offers an alternative, `alang` prefers Japanese, and the source's own audio and subtitle tracks are loaded into mpv (Japanese selected) instead of being discarded, so all of them can be switched from mpv's track menu.
|
||||
- The primary subtitle slot stays reserved for Japanese: a source that only has, say, English subtitles gets them added with a normalized language tag (`English` → `en`) but not selected, so the regular `secondarySub` auto-load can route them to the secondary slot instead.
|
||||
|
||||
@@ -94,8 +94,12 @@ extension you dropped in by hand — or one whose repository you have since
|
||||
removed — is still listed and still removable. An installed extension borrows
|
||||
its icon from the catalogue, so one no repository carries shows its monogram.
|
||||
|
||||
**Update** appears next to an extension a configured repository still carries;
|
||||
it downloads the current version over the existing APK.
|
||||
SubMiner compares the APK's Android version code with the newest build in the
|
||||
configured repositories. **Update** is clickable only when the repository has a
|
||||
newer build. A current extension says **Up to date**. If an unusual APK has no
|
||||
readable version code, its disabled button says **Version unknown**. **Update
|
||||
all** installs every newer build in one pass and disables itself when there is
|
||||
nothing to update.
|
||||
|
||||
### From a file
|
||||
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
## Highlights
|
||||
### Fixed
|
||||
|
||||
- **Anki Card Update Progress**: The update spinner now stays visible until audio and image updates actually finish, so you won't mistake an in-progress update for a failure.
|
||||
- **Word-Card Field Enrichment**: Word-card enrichment now reliably writes sentence text and audio into whichever AnkiConnect fields you've configured, while the dedicated Lapis/Kiku sentence-card and audio-card actions still use their expected field names.
|
||||
- **Overlapping Subtitles**:
|
||||
- Lines that start while another is still on screen now show together instead of staying hidden until you switch tracks or seek.
|
||||
- Subtitles shown at the same time now stack by their authored screen position, with signs and song lyrics above dialogue.
|
||||
- Half-size ASS furigana no longer shows up as if it were its own subtitle line.
|
||||
- **YouTube Auto-Generated Captions**:
|
||||
- Captions now follow their intended timing instead of drifting off sync.
|
||||
- Long speech is paged across two rows instead of piling into a wall of text.
|
||||
- Timed sound cues like `[音楽]` no longer linger over later dialogue.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(anki): keep overlay progress visible through card updates by @ksyasuda in #218
|
||||
- fix(youtube): keep auto captions on screen for their full span by @ksyasuda in #219
|
||||
- fix(subtitles): keep overlapping lines that join an already active cue by @ksyasuda in #221
|
||||
- fix(anki): respect configured fields for word-card enrichment by @ksyasuda in #223
|
||||
|
||||
## Installation
|
||||
|
||||
See the README and docs/installation guide for full setup steps.
|
||||
|
||||
## Assets
|
||||
|
||||
- Linux: `SubMiner.AppImage`
|
||||
- macOS: `SubMiner-*.dmg` and `SubMiner-*.zip`
|
||||
- Windows: `SubMiner-*.exe` and `SubMiner-*-win.zip`
|
||||
- Optional extras: `subminer-assets.tar.gz` and the `subminer` launcher
|
||||
|
||||
Note: the `subminer` wrapper script uses Bun (`#!/usr/bin/env bun`), so `bun` must be installed and on `PATH`.
|
||||
@@ -0,0 +1,112 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { deflateRawSync } from 'node:zlib';
|
||||
import { parseAndroidManifestVersionCode, readApkVersionCode } from './apk-version';
|
||||
|
||||
const NO_STRING = 0xffffffff;
|
||||
|
||||
function writeChunkHeader(buffer: Buffer, type: number, headerSize: number, size: number): void {
|
||||
buffer.writeUInt16LE(type, 0);
|
||||
buffer.writeUInt16LE(headerSize, 2);
|
||||
buffer.writeUInt32LE(size, 4);
|
||||
}
|
||||
|
||||
function makeStringPool(strings: string[]): Buffer {
|
||||
const encoded = strings.map((value) => {
|
||||
const bytes = Buffer.from(value, 'utf8');
|
||||
return Buffer.concat([Buffer.from([value.length, bytes.length]), bytes, Buffer.from([0])]);
|
||||
});
|
||||
const offsets = encoded.map((_value, index) =>
|
||||
encoded.slice(0, index).reduce((total, value) => total + value.length, 0),
|
||||
);
|
||||
const dataLength = encoded.reduce((total, value) => total + value.length, 0);
|
||||
const paddedDataLength = Math.ceil(dataLength / 4) * 4;
|
||||
const headerSize = 28;
|
||||
const stringsStart = headerSize + strings.length * 4;
|
||||
const chunk = Buffer.alloc(stringsStart + paddedDataLength);
|
||||
writeChunkHeader(chunk, 0x0001, headerSize, chunk.length);
|
||||
chunk.writeUInt32LE(strings.length, 8);
|
||||
chunk.writeUInt32LE(0x00000100, 16);
|
||||
chunk.writeUInt32LE(stringsStart, 20);
|
||||
offsets.forEach((offset, index) => chunk.writeUInt32LE(offset, headerSize + index * 4));
|
||||
Buffer.concat(encoded).copy(chunk, stringsStart);
|
||||
return chunk;
|
||||
}
|
||||
|
||||
function makeBinaryManifest(versionCode: number): Buffer {
|
||||
const stringPool = makeStringPool(['manifest', 'versionCode']);
|
||||
const startElement = Buffer.alloc(56);
|
||||
writeChunkHeader(startElement, 0x0102, 16, startElement.length);
|
||||
startElement.writeUInt32LE(NO_STRING, 12);
|
||||
startElement.writeUInt32LE(NO_STRING, 16);
|
||||
startElement.writeUInt32LE(0, 20);
|
||||
startElement.writeUInt16LE(20, 24);
|
||||
startElement.writeUInt16LE(20, 26);
|
||||
startElement.writeUInt16LE(1, 28);
|
||||
const attributeOffset = 36;
|
||||
startElement.writeUInt32LE(NO_STRING, attributeOffset);
|
||||
startElement.writeUInt32LE(1, attributeOffset + 4);
|
||||
startElement.writeUInt32LE(NO_STRING, attributeOffset + 8);
|
||||
startElement.writeUInt16LE(8, attributeOffset + 12);
|
||||
startElement[attributeOffset + 15] = 0x10;
|
||||
startElement.writeUInt32LE(versionCode, attributeOffset + 16);
|
||||
|
||||
const document = Buffer.alloc(8);
|
||||
writeChunkHeader(document, 0x0003, 8, document.length + stringPool.length + startElement.length);
|
||||
return Buffer.concat([document, stringPool, startElement]);
|
||||
}
|
||||
|
||||
function makeDeflatedZip(name: string, data: Buffer): Buffer {
|
||||
const fileName = Buffer.from(name, 'utf8');
|
||||
const compressed = deflateRawSync(data);
|
||||
const local = Buffer.alloc(30 + fileName.length);
|
||||
local.writeUInt32LE(0x04034b50, 0);
|
||||
local.writeUInt16LE(20, 4);
|
||||
local.writeUInt16LE(8, 8);
|
||||
local.writeUInt32LE(compressed.length, 18);
|
||||
local.writeUInt32LE(data.length, 22);
|
||||
local.writeUInt16LE(fileName.length, 26);
|
||||
fileName.copy(local, 30);
|
||||
|
||||
const central = Buffer.alloc(46 + fileName.length);
|
||||
central.writeUInt32LE(0x02014b50, 0);
|
||||
central.writeUInt16LE(20, 4);
|
||||
central.writeUInt16LE(20, 6);
|
||||
central.writeUInt16LE(8, 10);
|
||||
central.writeUInt32LE(compressed.length, 20);
|
||||
central.writeUInt32LE(data.length, 24);
|
||||
central.writeUInt16LE(fileName.length, 28);
|
||||
fileName.copy(central, 46);
|
||||
|
||||
const end = Buffer.alloc(22);
|
||||
end.writeUInt32LE(0x06054b50, 0);
|
||||
end.writeUInt16LE(1, 8);
|
||||
end.writeUInt16LE(1, 10);
|
||||
end.writeUInt32LE(central.length, 12);
|
||||
end.writeUInt32LE(local.length + compressed.length, 16);
|
||||
return Buffer.concat([local, compressed, central, end]);
|
||||
}
|
||||
|
||||
test('parseAndroidManifestVersionCode reads the typed manifest attribute', () => {
|
||||
assert.equal(parseAndroidManifestVersionCode(makeBinaryManifest(42)), 42);
|
||||
assert.equal(parseAndroidManifestVersionCode(Buffer.from('plain xml')), null);
|
||||
});
|
||||
|
||||
test('readApkVersionCode reads a deflated AndroidManifest.xml from an APK', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'subminer-apk-version-'));
|
||||
const apkPath = path.join(directory, 'extension.apk');
|
||||
await writeFile(apkPath, makeDeflatedZip('AndroidManifest.xml', makeBinaryManifest(730)));
|
||||
|
||||
assert.equal(await readApkVersionCode(apkPath), 730);
|
||||
});
|
||||
|
||||
test('readApkVersionCode returns null for a malformed APK', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'subminer-apk-version-'));
|
||||
const apkPath = path.join(directory, 'broken.apk');
|
||||
await writeFile(apkPath, 'not a zip');
|
||||
|
||||
assert.equal(await readApkVersionCode(apkPath), null);
|
||||
});
|
||||
@@ -0,0 +1,256 @@
|
||||
import { open, type FileHandle } from 'node:fs/promises';
|
||||
import { inflateRawSync } from 'node:zlib';
|
||||
|
||||
const END_OF_CENTRAL_DIRECTORY_SIGNATURE = 0x06054b50;
|
||||
const CENTRAL_FILE_HEADER_SIGNATURE = 0x02014b50;
|
||||
const LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
|
||||
const ANDROID_XML_TYPE = 0x0003;
|
||||
const STRING_POOL_TYPE = 0x0001;
|
||||
const START_ELEMENT_TYPE = 0x0102;
|
||||
const UTF8_STRING_POOL_FLAG = 0x00000100;
|
||||
const NO_STRING = 0xffffffff;
|
||||
const TYPE_INT_DEC = 0x10;
|
||||
const TYPE_INT_HEX = 0x11;
|
||||
const MANIFEST_ENTRY = 'AndroidManifest.xml';
|
||||
const MAX_ZIP_TAIL_BYTES = 65_535 + 22;
|
||||
const MAX_CENTRAL_DIRECTORY_BYTES = 16 * 1024 * 1024;
|
||||
const MAX_MANIFEST_BYTES = 1024 * 1024;
|
||||
|
||||
interface ZipEntryLocation {
|
||||
compressionMethod: number;
|
||||
compressedSize: number;
|
||||
uncompressedSize: number;
|
||||
localHeaderOffset: number;
|
||||
}
|
||||
|
||||
async function readExactly(handle: FileHandle, length: number, position: number): Promise<Buffer> {
|
||||
const buffer = Buffer.alloc(length);
|
||||
const { bytesRead } = await handle.read(buffer, 0, length, position);
|
||||
if (bytesRead !== length) throw new Error('Unexpected end of APK.');
|
||||
return buffer;
|
||||
}
|
||||
|
||||
function findEndOfCentralDirectory(tail: Buffer): number | null {
|
||||
for (let offset = tail.length - 22; offset >= 0; offset -= 1) {
|
||||
if (tail.readUInt32LE(offset) !== END_OF_CENTRAL_DIRECTORY_SIGNATURE) continue;
|
||||
const commentLength = tail.readUInt16LE(offset + 20);
|
||||
if (offset + 22 + commentLength === tail.length) return offset;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findZipEntry(
|
||||
central: Buffer,
|
||||
entryCount: number,
|
||||
wantedName: string,
|
||||
): ZipEntryLocation | null {
|
||||
let offset = 0;
|
||||
for (let index = 0; index < entryCount; index += 1) {
|
||||
if (offset + 46 > central.length) return null;
|
||||
if (central.readUInt32LE(offset) !== CENTRAL_FILE_HEADER_SIGNATURE) return null;
|
||||
|
||||
const flags = central.readUInt16LE(offset + 8);
|
||||
const compressionMethod = central.readUInt16LE(offset + 10);
|
||||
const compressedSize = central.readUInt32LE(offset + 20);
|
||||
const uncompressedSize = central.readUInt32LE(offset + 24);
|
||||
const nameLength = central.readUInt16LE(offset + 28);
|
||||
const extraLength = central.readUInt16LE(offset + 30);
|
||||
const commentLength = central.readUInt16LE(offset + 32);
|
||||
const recordLength = 46 + nameLength + extraLength + commentLength;
|
||||
if (offset + recordLength > central.length) return null;
|
||||
|
||||
const name = central.subarray(offset + 46, offset + 46 + nameLength).toString('utf8');
|
||||
if (name === wantedName) {
|
||||
if ((flags & 0x0001) !== 0) return null;
|
||||
if (compressedSize > MAX_MANIFEST_BYTES || uncompressedSize > MAX_MANIFEST_BYTES) return null;
|
||||
return {
|
||||
compressionMethod,
|
||||
compressedSize,
|
||||
uncompressedSize,
|
||||
localHeaderOffset: central.readUInt32LE(offset + 42),
|
||||
};
|
||||
}
|
||||
offset += recordLength;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readZipEntry(apkPath: string, wantedName: string): Promise<Buffer | null> {
|
||||
let handle: FileHandle | null = null;
|
||||
try {
|
||||
handle = await open(apkPath, 'r');
|
||||
const fileSize = (await handle.stat()).size;
|
||||
const tailSize = Math.min(fileSize, MAX_ZIP_TAIL_BYTES);
|
||||
if (tailSize < 22) return null;
|
||||
const tail = await readExactly(handle, tailSize, fileSize - tailSize);
|
||||
const endOffset = findEndOfCentralDirectory(tail);
|
||||
if (endOffset === null) return null;
|
||||
|
||||
const diskNumber = tail.readUInt16LE(endOffset + 4);
|
||||
const centralDisk = tail.readUInt16LE(endOffset + 6);
|
||||
const diskEntryCount = tail.readUInt16LE(endOffset + 8);
|
||||
const entryCount = tail.readUInt16LE(endOffset + 10);
|
||||
const centralSize = tail.readUInt32LE(endOffset + 12);
|
||||
const centralOffset = tail.readUInt32LE(endOffset + 16);
|
||||
if (
|
||||
diskNumber !== 0 ||
|
||||
centralDisk !== 0 ||
|
||||
diskEntryCount !== entryCount ||
|
||||
entryCount === 0 ||
|
||||
centralSize === 0 ||
|
||||
centralSize > MAX_CENTRAL_DIRECTORY_BYTES ||
|
||||
centralOffset + centralSize > fileSize
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const central = await readExactly(handle, centralSize, centralOffset);
|
||||
const entry = findZipEntry(central, entryCount, wantedName);
|
||||
if (!entry || entry.localHeaderOffset + 30 > centralOffset) return null;
|
||||
|
||||
const localHeader = await readExactly(handle, 30, entry.localHeaderOffset);
|
||||
if (localHeader.readUInt32LE(0) !== LOCAL_FILE_HEADER_SIGNATURE) return null;
|
||||
const nameLength = localHeader.readUInt16LE(26);
|
||||
const extraLength = localHeader.readUInt16LE(28);
|
||||
const dataOffset = entry.localHeaderOffset + 30 + nameLength + extraLength;
|
||||
if (dataOffset + entry.compressedSize > centralOffset) return null;
|
||||
const compressed = await readExactly(handle, entry.compressedSize, dataOffset);
|
||||
|
||||
let data: Buffer;
|
||||
if (entry.compressionMethod === 0) {
|
||||
data = compressed;
|
||||
} else if (entry.compressionMethod === 8) {
|
||||
data = inflateRawSync(compressed, { maxOutputLength: MAX_MANIFEST_BYTES });
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return data.length === entry.uncompressedSize ? data : null;
|
||||
} catch {
|
||||
return null;
|
||||
} finally {
|
||||
await handle?.close().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function readUtf8Length(buffer: Buffer, offset: number): { length: number; next: number } | null {
|
||||
if (offset >= buffer.length) return null;
|
||||
const first = buffer[offset]!;
|
||||
if ((first & 0x80) === 0) return { length: first, next: offset + 1 };
|
||||
if (offset + 1 >= buffer.length) return null;
|
||||
return { length: ((first & 0x7f) << 8) | buffer[offset + 1]!, next: offset + 2 };
|
||||
}
|
||||
|
||||
function readUtf16Length(buffer: Buffer, offset: number): { length: number; next: number } | null {
|
||||
if (offset + 2 > buffer.length) return null;
|
||||
const first = buffer.readUInt16LE(offset);
|
||||
if ((first & 0x8000) === 0) return { length: first, next: offset + 2 };
|
||||
if (offset + 4 > buffer.length) return null;
|
||||
return {
|
||||
length: ((first & 0x7fff) << 16) | buffer.readUInt16LE(offset + 2),
|
||||
next: offset + 4,
|
||||
};
|
||||
}
|
||||
|
||||
interface AndroidStringPool {
|
||||
stringAt: (index: number) => string | null;
|
||||
}
|
||||
|
||||
function parseStringPool(
|
||||
buffer: Buffer,
|
||||
chunkOffset: number,
|
||||
chunkSize: number,
|
||||
): AndroidStringPool | null {
|
||||
const headerSize = buffer.readUInt16LE(chunkOffset + 2);
|
||||
if (headerSize < 28 || chunkOffset + chunkSize > buffer.length) return null;
|
||||
const stringCount = buffer.readUInt32LE(chunkOffset + 8);
|
||||
const flags = buffer.readUInt32LE(chunkOffset + 16);
|
||||
const stringsStart = buffer.readUInt32LE(chunkOffset + 20);
|
||||
if (headerSize + stringCount * 4 > chunkSize || stringsStart >= chunkSize) return null;
|
||||
|
||||
return {
|
||||
stringAt(index) {
|
||||
if (index === NO_STRING || index >= stringCount) return null;
|
||||
const relativeOffset = buffer.readUInt32LE(chunkOffset + headerSize + index * 4);
|
||||
let stringOffset = chunkOffset + stringsStart + relativeOffset;
|
||||
const chunkEnd = chunkOffset + chunkSize;
|
||||
if (stringOffset >= chunkEnd) return null;
|
||||
|
||||
if ((flags & UTF8_STRING_POOL_FLAG) !== 0) {
|
||||
const utf16Length = readUtf8Length(buffer, stringOffset);
|
||||
if (!utf16Length) return null;
|
||||
const byteLength = readUtf8Length(buffer, utf16Length.next);
|
||||
if (!byteLength || byteLength.next + byteLength.length > chunkEnd) return null;
|
||||
return buffer.toString('utf8', byteLength.next, byteLength.next + byteLength.length);
|
||||
}
|
||||
|
||||
const length = readUtf16Length(buffer, stringOffset);
|
||||
if (!length) return null;
|
||||
stringOffset = length.next;
|
||||
const byteLength = length.length * 2;
|
||||
if (stringOffset + byteLength > chunkEnd) return null;
|
||||
return buffer.toString('utf16le', stringOffset, stringOffset + byteLength);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Read Android's numeric version code from a binary AndroidManifest.xml. */
|
||||
export function parseAndroidManifestVersionCode(buffer: Buffer): number | null {
|
||||
try {
|
||||
if (buffer.length < 8 || buffer.readUInt16LE(0) !== ANDROID_XML_TYPE) return null;
|
||||
const documentSize = buffer.readUInt32LE(4);
|
||||
if (documentSize > buffer.length) return null;
|
||||
|
||||
let strings: AndroidStringPool | null = null;
|
||||
let offset = buffer.readUInt16LE(2);
|
||||
while (offset + 8 <= documentSize) {
|
||||
const chunkType = buffer.readUInt16LE(offset);
|
||||
const headerSize = buffer.readUInt16LE(offset + 2);
|
||||
const chunkSize = buffer.readUInt32LE(offset + 4);
|
||||
if (headerSize < 8 || chunkSize < headerSize || offset + chunkSize > documentSize)
|
||||
return null;
|
||||
|
||||
if (chunkType === STRING_POOL_TYPE) {
|
||||
strings = parseStringPool(buffer, offset, chunkSize);
|
||||
} else if (chunkType === START_ELEMENT_TYPE && strings && headerSize >= 16) {
|
||||
const elementName = strings.stringAt(buffer.readUInt32LE(offset + 20));
|
||||
if (elementName === 'manifest') {
|
||||
const attributeStart = buffer.readUInt16LE(offset + 24);
|
||||
const attributeSize = buffer.readUInt16LE(offset + 26);
|
||||
const attributeCount = buffer.readUInt16LE(offset + 28);
|
||||
const attributesOffset = offset + 16 + attributeStart;
|
||||
if (
|
||||
attributeSize < 20 ||
|
||||
attributesOffset + attributeSize * attributeCount > offset + chunkSize
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let index = 0; index < attributeCount; index += 1) {
|
||||
const attributeOffset = attributesOffset + index * attributeSize;
|
||||
const name = strings.stringAt(buffer.readUInt32LE(attributeOffset + 4));
|
||||
if (name !== 'versionCode') continue;
|
||||
const valueType = buffer[attributeOffset + 15];
|
||||
if (valueType === TYPE_INT_DEC || valueType === TYPE_INT_HEX) {
|
||||
return buffer.readUInt32LE(attributeOffset + 16);
|
||||
}
|
||||
const rawValue = strings.stringAt(buffer.readUInt32LE(attributeOffset + 8));
|
||||
if (rawValue === null) return null;
|
||||
const parsed = Number(rawValue);
|
||||
return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
offset += chunkSize;
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Read the installed version without extracting the APK or running Android tooling. */
|
||||
export async function readApkVersionCode(apkPath: string): Promise<number | null> {
|
||||
const manifest = await readZipEntry(apkPath, MANIFEST_ENTRY);
|
||||
return manifest ? parseAndroidManifestVersionCode(manifest) : null;
|
||||
}
|
||||
@@ -35,6 +35,7 @@ test('readInstalledExtensions fingerprints apks without holding their bytes', as
|
||||
assert.equal(extensions.length, 1);
|
||||
assert.equal(extensions[0]?.fallbackName, 'my-source');
|
||||
assert.equal(extensions[0]?.sha256, createHash('sha256').update('APK-BYTES').digest('hex'));
|
||||
assert.equal(extensions[0]?.versionCode, null);
|
||||
});
|
||||
|
||||
test('the fingerprint changes when an apk is replaced in place', async () => {
|
||||
@@ -69,7 +70,12 @@ test('readInstalledExtensions returns empty for a missing directory', async () =
|
||||
});
|
||||
|
||||
test('toBridgeSource includes sourceId only when selecting inside a factory apk', () => {
|
||||
const extension: InstalledExtension = { file: '/x/a.apk', fallbackName: 'a', sha256: 'hash-a' };
|
||||
const extension: InstalledExtension = {
|
||||
file: '/x/a.apk',
|
||||
fallbackName: 'a',
|
||||
sha256: 'hash-a',
|
||||
versionCode: 1,
|
||||
};
|
||||
assert.equal(toBridgeSource(extension).sourceId, undefined);
|
||||
assert.equal(toBridgeSource(extension, 'src-1').sourceId, 'src-1');
|
||||
assert.equal(toBridgeSource(extension, 'src-1').fingerprint, 'hash-a');
|
||||
@@ -77,7 +83,7 @@ test('toBridgeSource includes sourceId only when selecting inside a factory apk'
|
||||
|
||||
test('listExtensionSources flattens every source a factory apk provides', async () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a' },
|
||||
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a', versionCode: 1 },
|
||||
];
|
||||
const client = fakeClient(async () => [
|
||||
{ id: 101, name: 'Source One', lang: 'en' },
|
||||
@@ -96,8 +102,8 @@ test('listExtensionSources flattens every source a factory apk provides', async
|
||||
|
||||
test('sources with the same bridge id in different packages have distinct runtime ids', async () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/one.apk', fallbackName: 'pkg.one', sha256: 'hash-one' },
|
||||
{ file: '/x/two.apk', fallbackName: 'pkg.two', sha256: 'hash-two' },
|
||||
{ file: '/x/one.apk', fallbackName: 'pkg.one', sha256: 'hash-one', versionCode: 1 },
|
||||
{ file: '/x/two.apk', fallbackName: 'pkg.two', sha256: 'hash-two', versionCode: 2 },
|
||||
];
|
||||
const client = fakeClient(async () => [{ id: 'shared', name: 'Source', lang: 'en' }]);
|
||||
|
||||
@@ -114,7 +120,7 @@ test('sources with the same bridge id in different packages have distinct runtim
|
||||
|
||||
test('listExtensionSources falls back to the file name and a default language', async () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/my-ext.apk', fallbackName: 'my-ext', sha256: 'hash-a' },
|
||||
{ file: '/x/my-ext.apk', fallbackName: 'my-ext', sha256: 'hash-a', versionCode: 1 },
|
||||
];
|
||||
const client = fakeClient(async () => [{ id: '1', name: ' ' }]);
|
||||
|
||||
@@ -126,14 +132,14 @@ test('listExtensionSources falls back to the file name and a default language',
|
||||
test('listExtensionSources drops descriptors with no usable id', async () => {
|
||||
const client = fakeClient(async () => [{ name: 'No Id' }, { id: '', name: 'Empty' }]);
|
||||
const sources = await listExtensionSources(client, [
|
||||
{ file: '/x/a.apk', fallbackName: 'a', sha256: 'hash-a' },
|
||||
{ file: '/x/a.apk', fallbackName: 'a', sha256: 'hash-a', versionCode: 1 },
|
||||
]);
|
||||
assert.deepEqual(sources, []);
|
||||
});
|
||||
|
||||
test('toInstalledExtensionViews names an extension after the sources it provides', () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a' },
|
||||
{ file: '/x/multi.apk', fallbackName: 'multi', sha256: 'hash-a', versionCode: 7 },
|
||||
];
|
||||
const sources: ExtensionSource[] = [
|
||||
{ id: 'multi:1', bridgeId: '1', name: 'One', lang: 'en', pkg: 'multi', file: '/x/multi.apk' },
|
||||
@@ -141,26 +147,42 @@ test('toInstalledExtensionViews names an extension after the sources it provides
|
||||
];
|
||||
|
||||
assert.deepEqual(toInstalledExtensionViews(extensions, sources, []), [
|
||||
{ pkg: 'multi', name: 'One, Two', langs: ['en', 'ja'], sourceCount: 2, error: null },
|
||||
{
|
||||
pkg: 'multi',
|
||||
name: 'One, Two',
|
||||
langs: ['en', 'ja'],
|
||||
sourceCount: 2,
|
||||
versionCode: 7,
|
||||
error: null,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('toInstalledExtensionViews lists an extension that loaded nothing, with its reason', () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/broken.apk', fallbackName: 'broken', sha256: 'hash-a' },
|
||||
{ file: '/x/broken.apk', fallbackName: 'broken', sha256: 'hash-a', versionCode: null },
|
||||
];
|
||||
|
||||
// A broken APK is still installed, so it must stay listed and removable.
|
||||
assert.deepEqual(
|
||||
toInstalledExtensionViews(extensions, [], [{ pkg: 'broken', error: 'dex2jar failed' }]),
|
||||
[{ pkg: 'broken', name: 'broken', langs: [], sourceCount: 0, error: 'dex2jar failed' }],
|
||||
[
|
||||
{
|
||||
pkg: 'broken',
|
||||
name: 'broken',
|
||||
langs: [],
|
||||
sourceCount: 0,
|
||||
versionCode: null,
|
||||
error: 'dex2jar failed',
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('one broken extension does not hide the working ones', async () => {
|
||||
const extensions: InstalledExtension[] = [
|
||||
{ file: '/x/broken.apk', fallbackName: 'broken', sha256: 'hash-a' },
|
||||
{ file: '/x/good.apk', fallbackName: 'good', sha256: 'hash-b' },
|
||||
{ file: '/x/broken.apk', fallbackName: 'broken', sha256: 'hash-a', versionCode: null },
|
||||
{ file: '/x/good.apk', fallbackName: 'good', sha256: 'hash-b', versionCode: 1 },
|
||||
];
|
||||
const failures: string[] = [];
|
||||
const client = fakeClient(async (source) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { readdir, readFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import path from 'node:path';
|
||||
import { readApkVersionCode } from './apk-version';
|
||||
import type { AnimeBridgeClient } from './bridge-client';
|
||||
import type { BridgeSource } from './bridge-client';
|
||||
import type { ExtensionLoadFailure, InstalledExtensionView } from '../types/anime-browser';
|
||||
@@ -23,6 +24,8 @@ export interface InstalledExtension {
|
||||
* bridge's extension-id cache misses after an in-place upgrade.
|
||||
*/
|
||||
sha256: string;
|
||||
/** Android manifest version code, or null when the APK cannot declare one. */
|
||||
versionCode: number | null;
|
||||
}
|
||||
|
||||
export interface ExtensionSource {
|
||||
@@ -56,10 +59,12 @@ export async function readInstalledExtensions(directory: string): Promise<Instal
|
||||
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
if (!entry.isFile() || !entry.name.toLowerCase().endsWith('.apk')) continue;
|
||||
const file = path.join(directory, entry.name);
|
||||
const [sha256, versionCode] = await Promise.all([hashFile(file), readApkVersionCode(file)]);
|
||||
extensions.push({
|
||||
file,
|
||||
fallbackName: entry.name.replace(/\.apk$/i, ''),
|
||||
sha256: await hashFile(file),
|
||||
sha256,
|
||||
versionCode,
|
||||
});
|
||||
}
|
||||
return extensions;
|
||||
@@ -91,6 +96,7 @@ export function toInstalledExtensionViews(
|
||||
name: names.length > 0 ? names.join(', ') : extension.fallbackName,
|
||||
langs: [...new Set(provided.map((source) => source.lang))],
|
||||
sourceCount: provided.length,
|
||||
versionCode: extension.versionCode,
|
||||
error: loadFailures.find((failure) => failure.pkg === extension.fallbackName)?.error ?? null,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import type { AvailableExtension, InstalledExtensionView } from '../types/anime-browser';
|
||||
import { getExtensionUpdateState, summarizeExtensionUpdates } from './extensions-panel';
|
||||
|
||||
const installed = {
|
||||
pkg: 'pkg.example',
|
||||
name: 'Example',
|
||||
langs: ['en'],
|
||||
sourceCount: 1,
|
||||
versionCode: 12,
|
||||
error: null,
|
||||
} satisfies InstalledExtensionView;
|
||||
|
||||
const offered = {
|
||||
pkg: 'pkg.example',
|
||||
name: 'Example',
|
||||
lang: 'en',
|
||||
version: '1.2.0',
|
||||
versionCode: 12,
|
||||
nsfw: false,
|
||||
repoUrl: 'https://repo.example/index.json',
|
||||
iconUrl: 'https://repo.example/icon.png',
|
||||
sourceNames: ['Example'],
|
||||
installed: true,
|
||||
} satisfies AvailableExtension;
|
||||
|
||||
test('extension update state only enables a strictly newer repository build', () => {
|
||||
assert.equal(getExtensionUpdateState(installed, { ...offered, versionCode: 13 }), 'available');
|
||||
assert.equal(getExtensionUpdateState(installed, offered), 'current');
|
||||
assert.equal(getExtensionUpdateState(installed, { ...offered, versionCode: 11 }), 'current');
|
||||
});
|
||||
|
||||
test('extension update state does not offer unverifiable updates', () => {
|
||||
assert.equal(getExtensionUpdateState({ ...installed, versionCode: null }, offered), 'unknown');
|
||||
assert.equal(getExtensionUpdateState(installed, undefined), 'unavailable');
|
||||
});
|
||||
|
||||
test('bulk update summary distinguishes waiting, current, and unverifiable states', () => {
|
||||
assert.deepEqual(summarizeExtensionUpdates(['current', 'available', 'available']), {
|
||||
kind: 'available',
|
||||
count: 2,
|
||||
});
|
||||
assert.deepEqual(summarizeExtensionUpdates(['current', 'current']), { kind: 'current' });
|
||||
assert.deepEqual(summarizeExtensionUpdates(['current', 'unknown']), { kind: 'none' });
|
||||
assert.deepEqual(summarizeExtensionUpdates([]), { kind: 'none' });
|
||||
});
|
||||
+103
-12
@@ -13,6 +13,7 @@ import type {
|
||||
AvailableExtension,
|
||||
InstalledExtensionView,
|
||||
} from '../types/anime-browser';
|
||||
import { hasExtensionUpdate } from '../shared/extension-updates';
|
||||
|
||||
/**
|
||||
* The Extensions tab: what is installed, which repositories feed it, and what
|
||||
@@ -31,11 +32,17 @@ export interface ExtensionsPanelOptions {
|
||||
onSourcesChanged: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface RowAction {
|
||||
label: string;
|
||||
primary?: boolean;
|
||||
onClick: () => void | Promise<void>;
|
||||
}
|
||||
type RowAction =
|
||||
| {
|
||||
label: string;
|
||||
primary?: boolean;
|
||||
onClick: () => void | Promise<void>;
|
||||
}
|
||||
| {
|
||||
label: string;
|
||||
disabled: true;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
interface RowOptions {
|
||||
name: string;
|
||||
@@ -96,8 +103,14 @@ function extensionRow(options: RowOptions): HTMLDivElement {
|
||||
for (const action of options.actions ?? []) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = action.primary ? 'primary-button' : 'ghost-button';
|
||||
button.className = 'primary' in action && action.primary ? 'primary-button' : 'ghost-button';
|
||||
button.textContent = action.label;
|
||||
if ('disabled' in action) {
|
||||
button.disabled = true;
|
||||
if (action.title) button.title = action.title;
|
||||
row.append(button);
|
||||
continue;
|
||||
}
|
||||
button.addEventListener('click', () => {
|
||||
button.disabled = true;
|
||||
void Promise.resolve(action.onClick()).finally(() => {
|
||||
@@ -110,6 +123,32 @@ function extensionRow(options: RowOptions): HTMLDivElement {
|
||||
return row;
|
||||
}
|
||||
|
||||
export type ExtensionUpdateState = 'available' | 'current' | 'unknown' | 'unavailable';
|
||||
|
||||
export type ExtensionUpdateSummary =
|
||||
| { kind: 'available'; count: number }
|
||||
| { kind: 'current' }
|
||||
| { kind: 'none' };
|
||||
|
||||
export function getExtensionUpdateState(
|
||||
installed: InstalledExtensionView,
|
||||
offered: AvailableExtension | undefined,
|
||||
): ExtensionUpdateState {
|
||||
if (!offered) return 'unavailable';
|
||||
if (installed.versionCode === null) return 'unknown';
|
||||
return hasExtensionUpdate(installed.versionCode, offered.versionCode) ? 'available' : 'current';
|
||||
}
|
||||
|
||||
export function summarizeExtensionUpdates(
|
||||
states: readonly ExtensionUpdateState[],
|
||||
): ExtensionUpdateSummary {
|
||||
const count = states.filter((state) => state === 'available').length;
|
||||
if (count > 0) return { kind: 'available', count };
|
||||
return states.length > 0 && states.every((state) => state === 'current')
|
||||
? { kind: 'current' }
|
||||
: { kind: 'none' };
|
||||
}
|
||||
|
||||
function emptyNote(text: string): HTMLParagraphElement {
|
||||
const empty = document.createElement('p');
|
||||
empty.className = 'ext-empty';
|
||||
@@ -124,6 +163,7 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
const bridgeInfo = el<HTMLParagraphElement>('bridge-info');
|
||||
const installedList = el<HTMLDivElement>('installed-list');
|
||||
const installedCount = el<HTMLSpanElement>('installed-count');
|
||||
const updateAllButton = el<HTMLButtonElement>('update-all');
|
||||
const availableList = el<HTMLDivElement>('extensions-list');
|
||||
const availableCount = el<HTMLSpanElement>('available-count');
|
||||
const langFilter = el<HTMLDivElement>('lang-filter');
|
||||
@@ -138,6 +178,8 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
let iconsByPkg = new Map<string, string>();
|
||||
let repoFailures: Array<{ name: string; error: string }> = [];
|
||||
let hasRepos = false;
|
||||
let updatingAll = false;
|
||||
let pendingUpdateCount = 0;
|
||||
/** Selected language codes; empty means "All". */
|
||||
let selectedLangs = new Set<string>();
|
||||
|
||||
@@ -149,10 +191,23 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
|
||||
function renderInstalled(
|
||||
installed: InstalledExtensionView[],
|
||||
offeredPkgs: Set<string>,
|
||||
offeredByPkg: Map<string, AvailableExtension>,
|
||||
extensionsDir: string,
|
||||
): void {
|
||||
installedCount.textContent = installed.length === 0 ? '' : String(installed.length);
|
||||
const updateStates = installed.map((view) =>
|
||||
getExtensionUpdateState(view, offeredByPkg.get(view.pkg)),
|
||||
);
|
||||
const updateSummary = summarizeExtensionUpdates(updateStates);
|
||||
const updateCount = updateSummary.kind === 'available' ? updateSummary.count : 0;
|
||||
pendingUpdateCount = updateCount;
|
||||
updateAllButton.textContent =
|
||||
updateSummary.kind === 'available'
|
||||
? `Update all (${updateSummary.count})`
|
||||
: updateSummary.kind === 'current'
|
||||
? 'All up to date'
|
||||
: 'No updates';
|
||||
updateAllButton.disabled = updatingAll || updateCount === 0;
|
||||
|
||||
if (installed.length === 0) {
|
||||
installedList.replaceChildren(
|
||||
@@ -166,9 +221,8 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
installedList.replaceChildren(
|
||||
...installed.map((view) => {
|
||||
const actions: RowAction[] = [];
|
||||
// Only offer an update for an extension a configured repository still
|
||||
// carries; reinstalling overwrites the APK in place.
|
||||
if (offeredPkgs.has(view.pkg)) {
|
||||
const updateState = getExtensionUpdateState(view, offeredByPkg.get(view.pkg));
|
||||
if (updateState === 'available') {
|
||||
actions.push({
|
||||
label: 'Update',
|
||||
onClick: async () => {
|
||||
@@ -181,6 +235,14 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
}
|
||||
},
|
||||
});
|
||||
} else if (updateState === 'current') {
|
||||
actions.push({ label: 'Up to date', disabled: true });
|
||||
} else if (updateState === 'unknown') {
|
||||
actions.push({
|
||||
label: 'Version unknown',
|
||||
disabled: true,
|
||||
title: 'SubMiner could not read a version code from this APK.',
|
||||
});
|
||||
}
|
||||
actions.push({
|
||||
label: 'Remove',
|
||||
@@ -344,11 +406,13 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
repoFailures.push({ name: failure.repoUrl, error: failure.error });
|
||||
}
|
||||
|
||||
const offeredPkgs = new Set(available.extensions.map((extension) => extension.pkg));
|
||||
const offeredByPkg = new Map(
|
||||
available.extensions.map((extension) => [extension.pkg, extension]),
|
||||
);
|
||||
// The catalogue is the only source of icons, so an installed extension can
|
||||
// only show one while a repository still carries its package.
|
||||
iconsByPkg = buildIconIndex(available.extensions);
|
||||
renderInstalled(snapshot.installed, offeredPkgs, snapshot.extensionsDir);
|
||||
renderInstalled(snapshot.installed, offeredByPkg, snapshot.extensionsDir);
|
||||
// Installed extensions have their own section; leaving them here too would
|
||||
// list every one of them twice.
|
||||
installable = available.extensions.filter((extension) => !extension.installed);
|
||||
@@ -371,6 +435,33 @@ export function createExtensionsPanel(options: ExtensionsPanelOptions) {
|
||||
})();
|
||||
});
|
||||
|
||||
updateAllButton.addEventListener('click', () => {
|
||||
if (updateAllButton.disabled) return;
|
||||
updatingAll = true;
|
||||
updateAllButton.disabled = true;
|
||||
updateAllButton.textContent = 'Updating…';
|
||||
setStatus('Updating extensions…');
|
||||
void (async () => {
|
||||
try {
|
||||
const count = await api.updateAllExtensions();
|
||||
await refresh();
|
||||
await onSourcesChanged();
|
||||
setStatus(`${count} ${count === 1 ? 'extension' : 'extensions'} updated`, 'ok');
|
||||
} catch (error) {
|
||||
await refresh().catch(() => undefined);
|
||||
await onSourcesChanged().catch(() => undefined);
|
||||
setStatus(describe(error), 'error');
|
||||
} finally {
|
||||
updatingAll = false;
|
||||
updateAllButton.disabled = pendingUpdateCount === 0;
|
||||
if (updateAllButton.textContent === 'Updating…') {
|
||||
updateAllButton.textContent =
|
||||
pendingUpdateCount > 0 ? `Update all (${pendingUpdateCount})` : 'No updates';
|
||||
}
|
||||
}
|
||||
})();
|
||||
});
|
||||
|
||||
repoInput.addEventListener('keydown', (event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -51,6 +51,7 @@ test('describeInstalled reports sources and languages when the extension loaded'
|
||||
name: 'One, Two',
|
||||
langs: ['en', 'ja'],
|
||||
sourceCount: 2,
|
||||
versionCode: 1,
|
||||
error: null,
|
||||
}),
|
||||
'multi · 2 sources · en, ja',
|
||||
@@ -59,7 +60,14 @@ test('describeInstalled reports sources and languages when the extension loaded'
|
||||
|
||||
test('describeInstalled falls back to the package alone when nothing loaded', () => {
|
||||
assert.equal(
|
||||
describeInstalled({ pkg: 'broken', name: 'broken', langs: [], sourceCount: 0, error: 'boom' }),
|
||||
describeInstalled({
|
||||
pkg: 'broken',
|
||||
name: 'broken',
|
||||
langs: [],
|
||||
sourceCount: 0,
|
||||
versionCode: null,
|
||||
error: 'boom',
|
||||
}),
|
||||
'broken',
|
||||
);
|
||||
});
|
||||
|
||||
@@ -100,9 +100,14 @@
|
||||
<span class="settings-note" id="extensions-dir"></span>
|
||||
</div>
|
||||
|
||||
<h3 class="ext-group-title">
|
||||
Installed <span class="ext-group-count" id="installed-count"></span>
|
||||
</h3>
|
||||
<div class="ext-group-heading">
|
||||
<h3 class="ext-group-title">
|
||||
Installed <span class="ext-group-count" id="installed-count"></span>
|
||||
</h3>
|
||||
<button class="ghost-button ext-update-all" id="update-all" type="button" disabled>
|
||||
No updates
|
||||
</button>
|
||||
</div>
|
||||
<div class="ext-list" id="installed-list" aria-label="Installed extensions"></div>
|
||||
|
||||
<h3 class="ext-group-title">Repositories</h3>
|
||||
|
||||
+19
-1
@@ -130,8 +130,26 @@
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.ext-group-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.ext-group-heading .ext-group-title {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.ext-update-all {
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* A rule between the groups, but not above the first one. */
|
||||
.ext-list + .ext-group-title {
|
||||
.ext-list + .ext-group-title,
|
||||
.ext-list + .ext-group-heading {
|
||||
margin-top: 6px;
|
||||
padding-top: 14px;
|
||||
border-top: 1px solid var(--line);
|
||||
|
||||
@@ -29,3 +29,19 @@ test('anime browser preference IPC coerces values at the renderer boundary', ()
|
||||
['source', 'invalid', ''],
|
||||
]);
|
||||
});
|
||||
|
||||
test('anime browser bulk update IPC returns the runtime result', async () => {
|
||||
const handlers = new Map<string, (event: unknown, ...args: unknown[]) => unknown>();
|
||||
registerAnimeBrowserIpcHandlers({
|
||||
ipcMain: {
|
||||
handle: (channel, listener) => handlers.set(channel, listener),
|
||||
},
|
||||
runtime: {
|
||||
updateAllExtensions: async () => 3,
|
||||
} as never,
|
||||
});
|
||||
|
||||
const updateAll = handlers.get(IPC_CHANNELS.request.animeBrowserUpdateAllExtensions);
|
||||
assert.ok(updateAll);
|
||||
assert.equal(await updateAll({}), 3);
|
||||
});
|
||||
|
||||
@@ -82,6 +82,7 @@ export function registerAnimeBrowserIpcHandlers(deps: AnimeBrowserIpcDeps): void
|
||||
handle(channels.animeBrowserInstallExtension, (_event, pkg) =>
|
||||
runtime.installExtension(String(pkg)),
|
||||
);
|
||||
handle(channels.animeBrowserUpdateAllExtensions, () => runtime.updateAllExtensions());
|
||||
handle(channels.animeBrowserRemoveExtension, (_event, pkg) =>
|
||||
runtime.removeExtension(String(pkg)),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { mkdtemp, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { AnimeBridgeClient } from '../../anime-bridge/bridge-client';
|
||||
import { createAnimeBrowserRuntime } from './anime-browser-runtime';
|
||||
|
||||
const REPO_URL = 'https://repo.example/anime/index.json';
|
||||
const PKG = 'eu.kanade.tachiyomi.animeextension.all.example';
|
||||
|
||||
function createTestRuntime(
|
||||
directory: string,
|
||||
client: AnimeBridgeClient,
|
||||
repos: readonly string[] = [],
|
||||
) {
|
||||
return createAnimeBrowserRuntime({
|
||||
extensionsDir: () => directory,
|
||||
repos: () => [...repos],
|
||||
setRepos: () => undefined,
|
||||
preferencesFile: path.join(directory, 'preferences.json'),
|
||||
ensureBinaries: async () => ({}) as never,
|
||||
checkBridgeUpdate: async () => null,
|
||||
stageBridgeUpdate: async () => {
|
||||
throw new Error('not under test');
|
||||
},
|
||||
sendMpvCommand: () => undefined,
|
||||
ensureMpvConnected: async () => true,
|
||||
onBridgeState: () => undefined,
|
||||
log: () => undefined,
|
||||
startSidecar: async () => ({
|
||||
client,
|
||||
baseUrl: 'http://127.0.0.1:12345',
|
||||
port: 12345,
|
||||
stop: async () => undefined,
|
||||
onExit: () => undefined,
|
||||
}),
|
||||
startStreamStripProxy: async () => ({
|
||||
origin: 'http://127.0.0.1:12346',
|
||||
port: 12346,
|
||||
close: async () => undefined,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
test('extension mutations run in request order while a download is pending', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'subminer-extension-mutations-'));
|
||||
const apkPath = path.join(directory, `${PKG}.apk`);
|
||||
await writeFile(apkPath, 'old apk');
|
||||
|
||||
let notifyDownloadStarted: () => void = () => undefined;
|
||||
const downloadStarted = new Promise<void>((resolve) => {
|
||||
notifyDownloadStarted = resolve;
|
||||
});
|
||||
let releaseDownload: () => void = () => undefined;
|
||||
const downloadGate = new Promise<void>((resolve) => {
|
||||
releaseDownload = resolve;
|
||||
});
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
if (String(input) === REPO_URL) {
|
||||
return new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
name: 'Aniyomi: Example',
|
||||
pkg: PKG,
|
||||
apk: 'example.apk',
|
||||
lang: 'all',
|
||||
code: 2,
|
||||
version: '2.0',
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
notifyDownloadStarted();
|
||||
await downloadGate;
|
||||
const body = new Uint8Array([0x50, 0x4b, 0x03, 0x04, 0x01]) as unknown as BodyInit;
|
||||
return new Response(body);
|
||||
}) as typeof fetch;
|
||||
|
||||
const client = {
|
||||
listAnimeSources: async () => [{ id: 'one', name: 'Example', lang: 'all' }],
|
||||
} as unknown as AnimeBridgeClient;
|
||||
const runtime = createTestRuntime(directory, client, [REPO_URL]);
|
||||
|
||||
try {
|
||||
await runtime.ensureBridge();
|
||||
const install = runtime.installExtension(PKG);
|
||||
await downloadStarted;
|
||||
const remove = runtime.removeExtension(PKG);
|
||||
releaseDownload();
|
||||
await Promise.all([install, remove]);
|
||||
|
||||
assert.equal(existsSync(apkPath), false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
await runtime.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
test('startup scanning cannot restore an extension removed concurrently', async () => {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), 'subminer-extension-startup-'));
|
||||
const apkPath = path.join(directory, `${PKG}.apk`);
|
||||
await writeFile(apkPath, 'old apk');
|
||||
|
||||
let notifyScanStarted: () => void = () => undefined;
|
||||
const scanStarted = new Promise<void>((resolve) => {
|
||||
notifyScanStarted = resolve;
|
||||
});
|
||||
let releaseScan: () => void = () => undefined;
|
||||
const scanGate = new Promise<void>((resolve) => {
|
||||
releaseScan = resolve;
|
||||
});
|
||||
const client = {
|
||||
listAnimeSources: async () => {
|
||||
notifyScanStarted();
|
||||
await scanGate;
|
||||
return [{ id: 'one', name: 'Example', lang: 'all' }];
|
||||
},
|
||||
} as unknown as AnimeBridgeClient;
|
||||
const runtime = createTestRuntime(directory, client);
|
||||
|
||||
try {
|
||||
const start = runtime.ensureBridge();
|
||||
await scanStarted;
|
||||
const remove = runtime.removeExtension(PKG);
|
||||
releaseScan();
|
||||
await Promise.all([start, remove]);
|
||||
|
||||
assert.equal(existsSync(apkPath), false);
|
||||
assert.deepEqual(runtime.getSnapshot().installed, []);
|
||||
assert.deepEqual(runtime.getSnapshot().sources, []);
|
||||
} finally {
|
||||
await runtime.dispose();
|
||||
}
|
||||
});
|
||||
@@ -50,6 +50,7 @@ import type {
|
||||
ExtensionLoadFailure,
|
||||
} from '../../types/anime-browser';
|
||||
import type { BridgeAnimePage, BridgePreference } from '../../anime-bridge/types';
|
||||
import { findExtensionUpdates, hasExtensionUpdate } from '../../shared/extension-updates';
|
||||
import { createAnimeBrowserPlayback } from './anime-browser-playback';
|
||||
import { createAnimeBrowserQueue } from './anime-browser-queue';
|
||||
import type { AnimeBrowserRuntimeDeps } from './anime-browser-runtime-deps';
|
||||
@@ -72,6 +73,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
let stripProxy: StreamStripProxyHandle | null = null;
|
||||
let starting: Promise<SidecarHandle> | null = null;
|
||||
let updating: Promise<AnimeBrowserBridgeState> | null = null;
|
||||
let extensionMutationTail = Promise.resolve();
|
||||
let extensions: InstalledExtension[] = [];
|
||||
let sources: ExtensionSource[] = [];
|
||||
let loadFailures: ExtensionLoadFailure[] = [];
|
||||
@@ -81,6 +83,15 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
>();
|
||||
const preferenceStore = new PreferenceStore(deps.preferencesFile);
|
||||
|
||||
function withExtensionMutation<T>(operation: () => Promise<T>): Promise<T> {
|
||||
const result = extensionMutationTail.then(operation);
|
||||
extensionMutationTail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
function getBrowserSession(sessionId = 'default') {
|
||||
const existing = browserSessions.get(sessionId);
|
||||
if (existing) return existing;
|
||||
@@ -187,7 +198,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
deps.log(`[anime-browser] stream proxy failed to start: ${describeError(error)}`);
|
||||
}
|
||||
|
||||
await scanExtensions(handle);
|
||||
await withExtensionMutation(() => scanExtensions(handle));
|
||||
void checkForBridgeUpdate(handle);
|
||||
return handle;
|
||||
}
|
||||
@@ -336,8 +347,12 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
return updating;
|
||||
}
|
||||
|
||||
async function installExtensionFrom(extension: RepoExtension): Promise<void> {
|
||||
async function downloadExtension(extension: RepoExtension): Promise<void> {
|
||||
await installExtension({ extensionsDir: deps.extensionsDir(), extension });
|
||||
}
|
||||
|
||||
async function installExtensionFrom(extension: RepoExtension): Promise<void> {
|
||||
await downloadExtension(extension);
|
||||
if (sidecar) await scanExtensions(sidecar);
|
||||
}
|
||||
|
||||
@@ -538,6 +553,7 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
name: extension.name,
|
||||
lang: extension.lang,
|
||||
version: extension.version,
|
||||
versionCode: extension.versionCode,
|
||||
nsfw: extension.nsfw,
|
||||
repoUrl: extension.repoUrl,
|
||||
iconUrl: extension.iconUrl,
|
||||
@@ -549,22 +565,61 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
},
|
||||
|
||||
/** Download an extension by package name, then rescan. */
|
||||
async installExtension(pkg: string): Promise<void> {
|
||||
const repos = deps.repos();
|
||||
if (repos.length === 0) throw new Error('No extension repository is configured.');
|
||||
installExtension(pkg: string): Promise<void> {
|
||||
return withExtensionMutation(async () => {
|
||||
const repos = deps.repos();
|
||||
if (repos.length === 0) throw new Error('No extension repository is configured.');
|
||||
|
||||
const catalogue = await fetchRepoCatalogue(repos);
|
||||
const match = catalogue.extensions.find((candidate) => candidate.pkg === pkg);
|
||||
if (!match) throw new Error(`${pkg} is not offered by any configured repository.`);
|
||||
const catalogue = await fetchRepoCatalogue(repos);
|
||||
const match = catalogue.extensions.find((candidate) => candidate.pkg === pkg);
|
||||
if (!match) throw new Error(`${pkg} is not offered by any configured repository.`);
|
||||
|
||||
await installExtensionFrom(match);
|
||||
const current = extensions.find((candidate) => candidate.fallbackName === pkg);
|
||||
if (
|
||||
current &&
|
||||
current.versionCode !== null &&
|
||||
!hasExtensionUpdate(current.versionCode, match.versionCode)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
await installExtensionFrom(match);
|
||||
});
|
||||
},
|
||||
|
||||
/** Download every strictly newer repository build, then rescan once. */
|
||||
updateAllExtensions(): Promise<number> {
|
||||
return withExtensionMutation(async () => {
|
||||
const repos = deps.repos();
|
||||
if (repos.length === 0) return 0;
|
||||
|
||||
const catalogue = await fetchRepoCatalogue(repos);
|
||||
const installedVersions = extensions.map((extension) => ({
|
||||
pkg: extension.fallbackName,
|
||||
versionCode: extension.versionCode,
|
||||
}));
|
||||
const updates = findExtensionUpdates(installedVersions, catalogue.extensions);
|
||||
|
||||
let installedCount = 0;
|
||||
try {
|
||||
for (const extension of updates) {
|
||||
await downloadExtension(extension);
|
||||
installedCount += 1;
|
||||
}
|
||||
} finally {
|
||||
if (installedCount > 0 && sidecar) await scanExtensions(sidecar);
|
||||
}
|
||||
return installedCount;
|
||||
});
|
||||
},
|
||||
|
||||
/** Remove an installed extension, then rescan. */
|
||||
async removeExtension(pkg: string): Promise<void> {
|
||||
await removeExtensionFile(deps.extensionsDir(), pkg);
|
||||
await preferenceStore.clear(pkg).catch(() => undefined);
|
||||
if (sidecar) await scanExtensions(sidecar);
|
||||
removeExtension(pkg: string): Promise<void> {
|
||||
return withExtensionMutation(async () => {
|
||||
await removeExtensionFile(deps.extensionsDir(), pkg);
|
||||
await preferenceStore.clear(pkg).catch(() => undefined);
|
||||
if (sidecar) await scanExtensions(sidecar);
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -586,8 +641,10 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
|
||||
},
|
||||
|
||||
/** Re-read the extensions directory without restarting the bridge. */
|
||||
async rescanExtensions(): Promise<void> {
|
||||
if (sidecar) await scanExtensions(sidecar);
|
||||
rescanExtensions(): Promise<void> {
|
||||
return withExtensionMutation(async () => {
|
||||
if (sidecar) await scanExtensions(sidecar);
|
||||
});
|
||||
},
|
||||
|
||||
selectSource(sourceId: string, sessionId = 'default'): void {
|
||||
|
||||
@@ -30,6 +30,7 @@ test('anime browser API keeps one opaque session per renderer bridge', async ()
|
||||
await firstApi.getDetails('/frieren');
|
||||
await firstApi.getEpisodes('/frieren', 'source.one');
|
||||
await firstApi.getPlaybackState();
|
||||
await firstApi.updateAllExtensions();
|
||||
await secondApi.getPopular(3);
|
||||
|
||||
const firstSessionId = first.calls[0]?.args[0];
|
||||
@@ -61,6 +62,10 @@ test('anime browser API keeps one opaque session per renderer bridge', async ()
|
||||
channel: IPC_CHANNELS.request.animeBrowserGetPlaybackState,
|
||||
args: [],
|
||||
},
|
||||
{
|
||||
channel: IPC_CHANNELS.request.animeBrowserUpdateAllExtensions,
|
||||
args: [],
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(second.calls, [
|
||||
{
|
||||
|
||||
@@ -85,6 +85,8 @@ export function createAnimeBrowserAPI(ipcRenderer: AnimeBrowserIpcRenderer): Ani
|
||||
ipcRenderer.invoke(request.animeBrowserListAvailableExtensions),
|
||||
installExtension: (pkg: string): Promise<void> =>
|
||||
ipcRenderer.invoke(request.animeBrowserInstallExtension, pkg),
|
||||
updateAllExtensions: (): Promise<number> =>
|
||||
ipcRenderer.invoke(request.animeBrowserUpdateAllExtensions),
|
||||
removeExtension: (pkg: string): Promise<void> =>
|
||||
ipcRenderer.invoke(request.animeBrowserRemoveExtension, pkg),
|
||||
rescanExtensions: (): Promise<void> => ipcRenderer.invoke(request.animeBrowserRescanExtensions),
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { findExtensionUpdates } from './extension-updates';
|
||||
|
||||
test('findExtensionUpdates returns only strictly newer builds for known installed versions', () => {
|
||||
const updates = findExtensionUpdates(
|
||||
[
|
||||
{ pkg: 'old', versionCode: 10 },
|
||||
{ pkg: 'current', versionCode: 12 },
|
||||
{ pkg: 'newer-than-repo', versionCode: 20 },
|
||||
{ pkg: 'unknown', versionCode: null },
|
||||
],
|
||||
[
|
||||
{ pkg: 'old', versionCode: 11, name: 'Old' },
|
||||
{ pkg: 'current', versionCode: 12, name: 'Current' },
|
||||
{ pkg: 'newer-than-repo', versionCode: 19, name: 'Newer' },
|
||||
{ pkg: 'unknown', versionCode: 30, name: 'Unknown' },
|
||||
{ pkg: 'not-installed', versionCode: 1, name: 'Not installed' },
|
||||
],
|
||||
);
|
||||
|
||||
assert.deepEqual(updates, [{ pkg: 'old', versionCode: 11, name: 'Old' }]);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
/** Repository version codes are monotonic, so only a strictly newer code is an update. */
|
||||
export function hasExtensionUpdate(
|
||||
installedVersionCode: number | null,
|
||||
availableVersionCode: number,
|
||||
): boolean {
|
||||
return installedVersionCode !== null && availableVersionCode > installedVersionCode;
|
||||
}
|
||||
|
||||
/** Keep only installed packages whose repository build is strictly newer. */
|
||||
export function findExtensionUpdates<T extends { pkg: string; versionCode: number }>(
|
||||
installed: ReadonlyArray<{ pkg: string; versionCode: number | null }>,
|
||||
offered: readonly T[],
|
||||
): T[] {
|
||||
const installedVersions = new Map(
|
||||
installed.map((extension) => [extension.pkg, extension.versionCode]),
|
||||
);
|
||||
return offered.filter((extension) => {
|
||||
const installedVersion = installedVersions.get(extension.pkg);
|
||||
return installedVersion === undefined
|
||||
? false
|
||||
: hasExtensionUpdate(installedVersion, extension.versionCode);
|
||||
});
|
||||
}
|
||||
@@ -147,6 +147,7 @@ export const IPC_CHANNELS = {
|
||||
animeBrowserSetPreference: 'anime-browser:set-preference',
|
||||
animeBrowserListAvailableExtensions: 'anime-browser:list-available-extensions',
|
||||
animeBrowserInstallExtension: 'anime-browser:install-extension',
|
||||
animeBrowserUpdateAllExtensions: 'anime-browser:update-all-extensions',
|
||||
animeBrowserRemoveExtension: 'anime-browser:remove-extension',
|
||||
animeBrowserRescanExtensions: 'anime-browser:rescan-extensions',
|
||||
animeBrowserAddRepo: 'anime-browser:add-repo',
|
||||
|
||||
@@ -175,6 +175,8 @@ export interface AvailableExtension {
|
||||
name: string;
|
||||
lang: string;
|
||||
version: string;
|
||||
/** Monotonic Android version code used to decide whether an update exists. */
|
||||
versionCode: number;
|
||||
nsfw: boolean;
|
||||
repoUrl: string;
|
||||
/** Where the repository publishes the extension's icon; may 404. */
|
||||
@@ -206,6 +208,8 @@ export interface InstalledExtensionView {
|
||||
langs: string[];
|
||||
/** How many sources it provides; 0 when it failed to load. */
|
||||
sourceCount: number;
|
||||
/** Read from AndroidManifest.xml; null only for an invalid or unusual APK. */
|
||||
versionCode: number | null;
|
||||
/** Why it failed to load, or null when it loaded. */
|
||||
error: string | null;
|
||||
}
|
||||
@@ -315,6 +319,7 @@ export interface AnimeBrowserAPI {
|
||||
) => Promise<SourcePreferenceView[]>;
|
||||
listAvailableExtensions: () => Promise<AvailableExtensionsResult>;
|
||||
installExtension: (pkg: string) => Promise<void>;
|
||||
updateAllExtensions: () => Promise<number>;
|
||||
removeExtension: (pkg: string) => Promise<void>;
|
||||
rescanExtensions: () => Promise<void>;
|
||||
addRepo: (url: string) => Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user