mirror of
https://github.com/ksyasuda/SubMiner.git
synced 2026-08-07 19:21:32 -07:00
ca5fc341cb
Addresses CodeRabbit review feedback on the Anime Browser: - reject repository package/apk names that are not plain identifiers, and verify the install target resolves inside the extensions directory - count mpv's %n% option escape in UTF-8 bytes, and escape backslashes in header values so a trailing one cannot eat the list separator - key the bridge extension-id cache by APK content, so an in-place upgrade re-uploads instead of running the previous build - bound every bridge, release-listing, and download request with a timeout - enforce the APK size limit while streaming rather than after buffering - read APK bytes on demand instead of holding a base64 copy per extension for the lifetime of the browser - serialize preference mutations and write the file atomically - handle the sidecar spawn error event, and wait for the child to exit in stop() before returning - report a failed Anime Browser bootstrap instead of showing the starting banner forever - keep the preferences panel's save confirmation and in-flight multi-select edits by re-rendering only on a structural schema change
134 lines
4.5 KiB
TypeScript
134 lines
4.5 KiB
TypeScript
import { spawn, type ChildProcess } from 'node:child_process';
|
|
import path from 'node:path';
|
|
import { createServer } from 'node:net';
|
|
import { AnimeBridgeClient } from './bridge-client';
|
|
import type { BundleBinaries } from './sidecar-bundle';
|
|
|
|
/** Cold start includes JVM boot plus AndroidCompat init; be generous. */
|
|
export const DEFAULT_READY_TIMEOUT_MS = 30_000;
|
|
const READY_POLL_INTERVAL_MS = 500;
|
|
/** How long to wait for the child to go after each signal before escalating. */
|
|
const STOP_TIMEOUT_MS = 5_000;
|
|
|
|
function delay(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
/** Ask the OS for a free loopback port, then hand it to the JVM. */
|
|
export async function allocatePort(): Promise<number> {
|
|
return new Promise((resolve, reject) => {
|
|
const server = createServer();
|
|
server.once('error', reject);
|
|
server.listen(0, '127.0.0.1', () => {
|
|
const address = server.address();
|
|
if (address === null || typeof address === 'string') {
|
|
server.close();
|
|
reject(new Error('Could not allocate a loopback port for the anime bridge.'));
|
|
return;
|
|
}
|
|
const { port } = address;
|
|
server.close(() => resolve(port));
|
|
});
|
|
});
|
|
}
|
|
|
|
export interface SidecarHandle {
|
|
baseUrl: string;
|
|
port: number;
|
|
client: AnimeBridgeClient;
|
|
stop: () => Promise<void>;
|
|
}
|
|
|
|
export interface StartSidecarOptions {
|
|
binaries: BundleBinaries;
|
|
port?: number;
|
|
readyTimeoutMs?: number;
|
|
spawnImpl?: typeof spawn;
|
|
onLog?: (line: string) => void;
|
|
}
|
|
|
|
/**
|
|
* Launch the bridge and wait until it reports the capabilities this client
|
|
* needs. The desktop launch contract is `java -jar MExtensionServer.jar <port>`,
|
|
* run from the JAR's own directory.
|
|
*/
|
|
export async function startSidecar(options: StartSidecarOptions): Promise<SidecarHandle> {
|
|
const { binaries } = options;
|
|
const port = options.port ?? (await allocatePort());
|
|
const baseUrl = `http://127.0.0.1:${port}`;
|
|
const spawnProcess = options.spawnImpl ?? spawn;
|
|
|
|
const child: ChildProcess = spawnProcess(
|
|
binaries.javaPath,
|
|
['-jar', binaries.jarPath, String(port)],
|
|
{
|
|
cwd: path.dirname(binaries.jarPath),
|
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
},
|
|
);
|
|
|
|
const log = options.onLog;
|
|
if (log) {
|
|
child.stdout?.on('data', (chunk: Buffer) => log(chunk.toString().trimEnd()));
|
|
child.stderr?.on('data', (chunk: Buffer) => log(chunk.toString().trimEnd()));
|
|
}
|
|
|
|
let exited: { code: number | null; signal: NodeJS.Signals | null } | null = null;
|
|
let spawnError: Error | null = null;
|
|
const hasExited = new Promise<void>((resolve) => {
|
|
child.once('exit', (code, signal) => {
|
|
exited = { code, signal };
|
|
resolve();
|
|
});
|
|
// A ChildProcess is an EventEmitter: without this listener a failed spawn
|
|
// (a missing or non-executable java) throws in the main process instead of
|
|
// failing the readiness loop below.
|
|
child.once('error', (error: Error) => {
|
|
spawnError = error;
|
|
if (exited === null) exited = { code: null, signal: null };
|
|
resolve();
|
|
});
|
|
});
|
|
|
|
const stop = async (): Promise<void> => {
|
|
if (exited !== null) return;
|
|
// Graceful first: the server exposes a shutdown endpoint.
|
|
try {
|
|
await fetch(`${baseUrl}/stop`, { signal: AbortSignal.timeout(2000) });
|
|
} catch {
|
|
// Falling through to a signal is fine; the endpoint may already be gone.
|
|
}
|
|
if (exited !== null) return;
|
|
child.kill();
|
|
// kill() only sends the signal. Wait for the process to actually go, so a
|
|
// restart cannot race the old one still holding the port.
|
|
await Promise.race([hasExited, delay(STOP_TIMEOUT_MS)]);
|
|
if (exited === null) {
|
|
child.kill('SIGKILL');
|
|
await Promise.race([hasExited, delay(STOP_TIMEOUT_MS)]);
|
|
}
|
|
};
|
|
|
|
const client = new AnimeBridgeClient({ baseUrl });
|
|
const deadline = Date.now() + (options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS);
|
|
|
|
while (Date.now() < deadline) {
|
|
if (spawnError !== null) {
|
|
throw new Error(`Anime bridge could not start: ${(spawnError as Error).message}`);
|
|
}
|
|
if (exited !== null) {
|
|
const { code, signal } = exited as { code: number | null; signal: NodeJS.Signals | null };
|
|
throw new Error(
|
|
`Anime bridge exited before becoming ready (code ${code}, signal ${signal}).`,
|
|
);
|
|
}
|
|
if (await client.isReady()) return { baseUrl, port, client, stop };
|
|
await delay(READY_POLL_INTERVAL_MS);
|
|
}
|
|
|
|
await stop();
|
|
throw new Error(
|
|
`Anime bridge did not become ready within ${options.readyTimeoutMs ?? DEFAULT_READY_TIMEOUT_MS}ms.`,
|
|
);
|
|
}
|