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
+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,
};
});