mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-09-21 17:16:20 -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:
@@ -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