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:
2026-09-02 19:10:03 -07:00
parent 484a9e047d
commit 18beac13f4
22 changed files with 887 additions and 81 deletions
+1 -1
View File
@@ -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.
+6 -2
View File
@@ -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
-33
View 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`.
+112
View File
@@ -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);
});
+256
View File
@@ -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;
}
+34 -12
View File
@@ -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) => {
+7 -1
View File
@@ -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,
};
});
+47
View File
@@ -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' });
});
+100 -9
View File
@@ -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 {
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();
+9 -1
View File
@@ -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',
);
});
+5
View File
@@ -100,9 +100,14 @@
<span class="settings-note" id="extensions-dir"></span>
</div>
<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
View File
@@ -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();
}
});
+62 -5
View File
@@ -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,7 +565,8 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
},
/** Download an extension by package name, then rescan. */
async installExtension(pkg: string): Promise<void> {
installExtension(pkg: string): Promise<void> {
return withExtensionMutation(async () => {
const repos = deps.repos();
if (repos.length === 0) throw new Error('No extension repository is configured.');
@@ -557,14 +574,52 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
const match = catalogue.extensions.find((candidate) => candidate.pkg === pkg);
if (!match) throw new Error(`${pkg} is not offered by any configured repository.`);
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> {
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> {
rescanExtensions(): Promise<void> {
return withExtensionMutation(async () => {
if (sidecar) await scanExtensions(sidecar);
});
},
selectSource(sourceId: string, sessionId = 'default'): void {
+5
View File
@@ -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, [
{
+2
View File
@@ -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),
+23
View File
@@ -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' }]);
});
+23
View File
@@ -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);
});
}
+1
View File
@@ -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',
+5
View File
@@ -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>;