feat: Enhance Anisette API with WASM file handling and provisioning improvements

This commit is contained in:
2026-02-28 14:19:32 +08:00
parent d05cc41660
commit d75671596c
10 changed files with 110 additions and 99 deletions

View File

@@ -2,7 +2,11 @@
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(cat:*)", "Bash(cat:*)",
"Bash(mkdir:*)" "Bash(mkdir:*)",
"mcp__ide__getDiagnostics",
"Bash(bun run:*)",
"Bash(bun:*)",
"Bash(NODE_TLS_REJECT_UNAUTHORIZED=0 bun:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View File

@@ -31,18 +31,27 @@ const storeservicesPath = args[0];
const coreadiPath = args[1]; const coreadiPath = args[1];
const libraryPath = args[2] ?? "./anisette/"; const libraryPath = args[2] ?? "./anisette/";
const wasmModule = await loadWasm(); const wasmModule = await loadWasm({ printErr: () => {}});
const storeservices = new Uint8Array(await fs.readFile(storeservicesPath)); const storeservices = new Uint8Array(await fs.readFile(storeservicesPath));
const coreadi = new Uint8Array(await fs.readFile(coreadiPath)); const coreadi = new Uint8Array(await fs.readFile(coreadiPath));
const readOptional = (p) => fs.readFile(p).then((b) => new Uint8Array(b)).catch(() => {console.warn(`Optional file not found: ${p}`); return null; });
const [adiPb, deviceJsonBytes] = await Promise.all([
readOptional(path.join(libraryPath, "adi.pb")),
readOptional(path.join(libraryPath, "device.json")),
]);
const anisette = await Anisette.fromSo(storeservices, coreadi, wasmModule, { const anisette = await Anisette.fromSo(storeservices, coreadi, wasmModule, {
init: { libraryPath }, init: { libraryPath, adiPb, deviceJsonBytes },
}); });
if (!anisette.isProvisioned) { if (!anisette.isProvisioned) {
console.log("Device not provisioned — running provisioning..."); console.log("Device not provisioned — running provisioning...");
await anisette.provision(); await anisette.provision();
await fs.mkdir(libraryPath, { recursive: true });
await fs.writeFile(path.join(libraryPath, "adi.pb"), anisette.getAdiPb());
await fs.writeFile(path.join(libraryPath, "device.json"), anisette.getDeviceJson());
console.log("Provisioning complete."); console.log("Provisioning complete.");
} else { } else {
console.log("Device already provisioned."); console.log("Device already provisioned.");
@@ -50,3 +59,6 @@ if (!anisette.isProvisioned) {
const headers = await anisette.getData(); const headers = await anisette.getData();
console.log(JSON.stringify(headers, null, 2)); console.log(JSON.stringify(headers, null, 2));
console.log(JSON.stringify(await anisette.getData(), null, 2));

View File

@@ -12,7 +12,7 @@
} }
}, },
"scripts": { "scripts": {
"build": "bun build src/index.ts --outfile dist/anisette.js --target node --format esm --minify", "build": "bun build src/index.ts --outfile ../dist/anisette.js --target node --format esm --minify-syntax --minify-whitespace",
"build:cjs": "bun build src/index.ts --outfile dist/anisette.cjs --target node --format cjs --minify", "build:cjs": "bun build src/index.ts --outfile dist/anisette.cjs --target node --format cjs --minify",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },

View File

@@ -1,6 +1,6 @@
// Main Anisette class — the public-facing API // Main Anisette class — the public-facing API
import type { AnisetteDeviceConfig, AnisetteHeaders, InitOptions } from "./types.js"; import type { AnisetteHeaders, InitOptions } from "./types.js";
import type { HttpClient } from "./http.js"; import type { HttpClient } from "./http.js";
import { WasmBridge } from "./wasm-bridge.js"; import { WasmBridge } from "./wasm-bridge.js";
import { Device } from "./device.js"; import { Device } from "./device.js";
@@ -12,7 +12,6 @@ import {
detectLocale, detectLocale,
encodeUtf8, encodeUtf8,
} from "./utils.js"; } from "./utils.js";
import type { DeviceJson } from "./types.js";
const DEFAULT_DSID = BigInt(-2); const DEFAULT_DSID = BigInt(-2);
const DEFAULT_LIBRARY_PATH = "./anisette/"; const DEFAULT_LIBRARY_PATH = "./anisette/";
@@ -30,25 +29,39 @@ export interface AnisetteOptions {
export class Anisette { export class Anisette {
private bridge: WasmBridge; private bridge: WasmBridge;
private device: Device; private device: Device;
private libs: LibraryStore;
private provisioning: ProvisioningSession; private provisioning: ProvisioningSession;
private dsid: bigint; private dsid: bigint;
private provisioningPath: string;
private libraryPath: string; private libraryPath: string;
private libs: LibraryStore;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private wasmModule: any;
private identifier: string;
private httpClient: HttpClient | undefined;
private constructor( private constructor(
bridge: WasmBridge, bridge: WasmBridge,
device: Device, device: Device,
libs: LibraryStore,
provisioning: ProvisioningSession, provisioning: ProvisioningSession,
dsid: bigint, dsid: bigint,
libraryPath: string provisioningPath: string,
libraryPath: string,
libs: LibraryStore,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
wasmModule: any,
identifier: string,
httpClient: HttpClient | undefined,
) { ) {
this.bridge = bridge; this.bridge = bridge;
this.device = device; this.device = device;
this.libs = libs;
this.provisioning = provisioning; this.provisioning = provisioning;
this.dsid = dsid; this.dsid = dsid;
this.provisioningPath = provisioningPath;
this.libraryPath = libraryPath; this.libraryPath = libraryPath;
this.libs = libs;
this.wasmModule = wasmModule;
this.identifier = identifier;
this.httpClient = httpClient;
} }
// ---- factory methods ---- // ---- factory methods ----
@@ -83,13 +96,19 @@ export class Anisette {
const dsid = options.dsid ?? DEFAULT_DSID; const dsid = options.dsid ?? DEFAULT_DSID;
// Load or generate device config // Load or generate device config
const device = Device.fromJson(null, initOpts.deviceConfig); const savedDeviceJson = initOpts.deviceJsonBytes
? (() => { try { return JSON.parse(new TextDecoder().decode(initOpts.deviceJsonBytes)) as import("./types.js").DeviceJson; } catch { return null; } })()
: null;
const device = Device.fromJson(savedDeviceJson, initOpts.deviceConfig);
// Write device.json into WASM VFS so the emulator can read it // Restore adi.pb into VFS if provided
const deviceJson = device.toJson(); if (initOpts.adiPb) {
const deviceJsonBytes = encodeUtf8(JSON.stringify(deviceJson, null, 2)); bridge.writeVirtualFile(joinPath(provisioningPath, "adi.pb"), initOpts.adiPb);
}
// Write device.json into WASM VFS
const deviceJsonBytes = initOpts.deviceJsonBytes ?? encodeUtf8(JSON.stringify(device.toJson(), null, 2));
bridge.writeVirtualFile(joinPath(libraryPath, "device.json"), deviceJsonBytes); bridge.writeVirtualFile(joinPath(libraryPath, "device.json"), deviceJsonBytes);
// Initialize WASM ADI // Initialize WASM ADI
bridge.initFromBlobs( bridge.initFromBlobs(
libs.storeservicescore, libs.storeservicescore,
@@ -105,58 +124,9 @@ export class Anisette {
options.httpClient options.httpClient
); );
return new Anisette(bridge, device, libs, provisioning, dsid, libraryPath); const identifier = initOpts.identifier ?? device.adiIdentifier;
}
/** return new Anisette(bridge, device, provisioning, dsid, provisioningPath, libraryPath, libs, wasmModule, identifier, options.httpClient);
* Load a previously saved session (device.json + adi.pb written back into VFS).
* Pass the saved device.json and adi.pb bytes alongside the library blobs.
*/
static async fromSaved(
storeservicescore: Uint8Array,
coreadi: Uint8Array,
deviceJsonBytes: Uint8Array,
adiPbBytes: Uint8Array,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
wasmModule: any,
options: AnisetteOptions = {}
): Promise<Anisette> {
const bridge = new WasmBridge(wasmModule);
const initOpts = options.init ?? {};
const libraryPath = initOpts.libraryPath ?? DEFAULT_LIBRARY_PATH;
const provisioningPath = initOpts.provisioningPath ?? libraryPath;
const dsid = options.dsid ?? DEFAULT_DSID;
// Parse saved device config
let deviceJson: DeviceJson | null = null;
try {
deviceJson = JSON.parse(new TextDecoder().decode(deviceJsonBytes)) as DeviceJson;
} catch {
// ignore parse errors — will generate fresh device
}
const device = Device.fromJson(deviceJson, initOpts.deviceConfig);
// Restore VFS files
bridge.writeVirtualFile(joinPath(libraryPath, "device.json"), deviceJsonBytes);
bridge.writeVirtualFile(joinPath(libraryPath, "adi.pb"), adiPbBytes);
const libs = LibraryStore.fromBlobs(storeservicescore, coreadi);
bridge.initFromBlobs(
libs.storeservicescore,
libs.coreadi,
libraryPath,
provisioningPath,
initOpts.identifier ?? device.adiIdentifier
);
const provisioning = new ProvisioningSession(
bridge,
device,
options.httpClient
);
return new Anisette(bridge, device, libs, provisioning, dsid, libraryPath);
} }
// ---- public API ---- // ---- public API ----
@@ -171,8 +141,22 @@ export class Anisette {
await this.provisioning.provision(this.dsid); await this.provisioning.provision(this.dsid);
} }
/** Read adi.pb from the WASM VFS for persistence. */
getAdiPb(): Uint8Array {
return this.bridge.readVirtualFile(joinPath(this.provisioningPath, "adi.pb"));
}
/** Generate Anisette headers. Throws if not provisioned. */ /** Generate Anisette headers. Throws if not provisioned. */
async getData(): Promise<AnisetteHeaders> { async getData(): Promise<AnisetteHeaders> {
// Reinit WASM state before each call to avoid emulator corruption on repeated use
const adiPb = this.bridge.readVirtualFile(joinPath(this.provisioningPath, "adi.pb"));
const deviceJsonBytes = encodeUtf8(JSON.stringify(this.device.toJson(), null, 2));
this.bridge = new WasmBridge(this.wasmModule);
this.bridge.writeVirtualFile(joinPath(this.provisioningPath, "adi.pb"), adiPb);
this.bridge.writeVirtualFile(joinPath(this.libraryPath, "device.json"), deviceJsonBytes);
this.bridge.initFromBlobs(this.libs.storeservicescore, this.libs.coreadi, this.libraryPath, this.provisioningPath, this.identifier);
this.provisioning = new ProvisioningSession(this.bridge, this.device, this.httpClient);
const { otp, machineId } = this.bridge.requestOtp(this.dsid); const { otp, machineId } = this.bridge.requestOtp(this.dsid);
const now = new Date(); const now = new Date();

View File

@@ -33,6 +33,10 @@ export interface InitOptions {
identifier?: string; identifier?: string;
/** Override parts of the generated device config */ /** Override parts of the generated device config */
deviceConfig?: Partial<AnisetteDeviceConfig>; deviceConfig?: Partial<AnisetteDeviceConfig>;
/** Existing adi.pb bytes to restore into the WASM VFS (for resuming a provisioned session) */
adiPb?: Uint8Array;
/** Existing device.json bytes to restore into the WASM VFS */
deviceJsonBytes?: Uint8Array;
} }
/** Raw device.json structure as stored on disk / in WASM VFS */ /** Raw device.json structure as stored on disk / in WASM VFS */

View File

@@ -100,6 +100,22 @@ export class WasmBridge {
} }
} }
/**
* Read a file from the WASM virtual filesystem.
*/
readVirtualFile(filePath: string): Uint8Array {
const pathPtr = this.allocCString(filePath);
try {
const result = this.m._anisette_fs_read_file(pathPtr) as number;
this.check(result, `anisette_fs_read_file(${filePath})`);
} finally {
this.free(pathPtr);
}
const ptr = this.m._anisette_fs_read_ptr() as number;
const len = this.m._anisette_fs_read_len() as number;
return this.readBytes(ptr, len);
}
/** /**
* Write a file into the WASM virtual filesystem. * Write a file into the WASM virtual filesystem.
*/ */

View File

@@ -1,27 +1,7 @@
// Loads the Emscripten WASM glue bundled alongside this file. // @ts-expect-error — glue file is generated, no types available
// The .wasm binary is resolved relative to this JS file at runtime.
// @ts-ignore — glue file is generated, no types available
import ModuleFactory from "../../dist/anisette_rs.node.js"; import ModuleFactory from "../../dist/anisette_rs.node.js";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
import path from "node:path";
// Resolve the .wasm file next to the bundled output JS
function resolveWasmPath(outputFile: string): string {
// __filename of the *bundled* output — bun sets import.meta.url correctly
const dir = path.dirname(fileURLToPath(import.meta.url));
return path.join(dir, outputFile);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function loadWasm(): Promise<any> { export async function loadWasm(moduleOverrides?: Record<string, any>): Promise<any> {
return ModuleFactory({ return ModuleFactory({ ...moduleOverrides });
locateFile(file: string) {
if (file.endsWith(".wasm")) {
return resolveWasmPath("anisette_rs.node.wasm");
}
return file;
},
});
} }

View File

@@ -3,9 +3,9 @@
"scripts": { "scripts": {
"build:unicorn": "bash script/rebuild-unicorn.sh", "build:unicorn": "bash script/rebuild-unicorn.sh",
"build:glue": "bash script/build-glue.sh", "build:glue": "bash script/build-glue.sh",
"build:release": "bash script/build-glue.sh --release",
"build:js": "cd js && bun install && bun run build", "build:js": "cd js && bun install && bun run build",
"build": "bash script/build-glue.sh && cd js && bun install && bun run build", "build": "bash script/build-glue.sh && cd js && bun install && bun run build",
"release": "bash script/build-glue.sh --release && cd js && bun install && bun run build",
"run:node": "node example/run-node.mjs", "run:node": "node example/run-node.mjs",
"run:api": "node example/anisette-api.mjs" "run:api": "node example/anisette-api.mjs"
} }

View File

@@ -16,8 +16,8 @@ NODE_DIST_WASM="${DIST_DIR}/anisette_rs.node.wasm"
WEB_EXPORTED_FUNCTIONS='["_malloc","_free","_anisette_init_from_blobs","_anisette_is_machine_provisioned","_anisette_start_provisioning","_anisette_end_provisioning","_anisette_request_otp","_anisette_get_cpim_ptr","_anisette_get_cpim_len","_anisette_get_session","_anisette_get_otp_ptr","_anisette_get_otp_len","_anisette_get_mid_ptr","_anisette_get_mid_len","_anisette_last_error_ptr","_anisette_last_error_len","_anisette_fs_write_file","_anisette_idbfs_init","_anisette_idbfs_sync","_anisette_set_identifier","_anisette_set_provisioning_path"]' WEB_EXPORTED_FUNCTIONS='["_malloc","_free","_anisette_init_from_blobs","_anisette_is_machine_provisioned","_anisette_start_provisioning","_anisette_end_provisioning","_anisette_request_otp","_anisette_get_cpim_ptr","_anisette_get_cpim_len","_anisette_get_session","_anisette_get_otp_ptr","_anisette_get_otp_len","_anisette_get_mid_ptr","_anisette_get_mid_len","_anisette_last_error_ptr","_anisette_last_error_len","_anisette_fs_write_file","_anisette_fs_read_file","_anisette_fs_read_ptr","_anisette_fs_read_len","_anisette_idbfs_sync","_anisette_set_identifier","_anisette_set_provisioning_path"]'
NODE_EXPORTED_FUNCTIONS='["_malloc","_free","_anisette_init_from_blobs","_anisette_is_machine_provisioned","_anisette_start_provisioning","_anisette_end_provisioning","_anisette_request_otp","_anisette_get_cpim_ptr","_anisette_get_cpim_len","_anisette_get_session","_anisette_get_otp_ptr","_anisette_get_otp_len","_anisette_get_mid_ptr","_anisette_get_mid_len","_anisette_last_error_ptr","_anisette_last_error_len","_anisette_fs_write_file","_anisette_set_identifier","_anisette_set_provisioning_path"]' NODE_EXPORTED_FUNCTIONS='["_malloc","_free","_anisette_init_from_blobs","_anisette_is_machine_provisioned","_anisette_start_provisioning","_anisette_end_provisioning","_anisette_request_otp","_anisette_get_cpim_ptr","_anisette_get_cpim_len","_anisette_get_session","_anisette_get_otp_ptr","_anisette_get_otp_len","_anisette_get_mid_ptr","_anisette_get_mid_len","_anisette_last_error_ptr","_anisette_last_error_len","_anisette_fs_write_file","_anisette_fs_read_file","_anisette_fs_read_ptr","_anisette_fs_read_len","_anisette_set_identifier","_anisette_set_provisioning_path"]'
WEB_EXPORTED_RUNTIME_METHODS='["FS","HEAPU8","UTF8ToString","stringToUTF8","lengthBytesUTF8"]' WEB_EXPORTED_RUNTIME_METHODS='["FS","HEAPU8","UTF8ToString","stringToUTF8","lengthBytesUTF8"]'
NODE_EXPORTED_RUNTIME_METHODS='["HEAPU8","UTF8ToString","stringToUTF8","lengthBytesUTF8"]' NODE_EXPORTED_RUNTIME_METHODS='["HEAPU8","UTF8ToString","stringToUTF8","lengthBytesUTF8"]'
@@ -103,7 +103,7 @@ if command -v bun >/dev/null 2>&1 && [[ -f "${JS_DIR}/src/index.ts" ]]; then
--outfile "${DIST_DIR}/anisette.js" \ --outfile "${DIST_DIR}/anisette.js" \
--target node \ --target node \
--format esm \ --format esm \
--minify --minify-syntax --minify-whitespace
echo " ${DIST_DIR}/anisette.js" echo " ${DIST_DIR}/anisette.js"
fi fi

View File

@@ -3,7 +3,7 @@ use std::ffi::{CStr, c_char};
use std::fs; use std::fs;
use std::path::Path; use std::path::Path;
use crate::{Adi, AdiInit, init_idbfs_for_path, sync_idbfs}; use crate::{Adi, AdiInit, sync_idbfs};
#[derive(Default)] #[derive(Default)]
struct ExportState { struct ExportState {
@@ -13,6 +13,7 @@ struct ExportState {
session: u32, session: u32,
otp: Vec<u8>, otp: Vec<u8>,
mid: Vec<u8>, mid: Vec<u8>,
read_buf: Vec<u8>,
} }
thread_local! { thread_local! {
@@ -407,15 +408,15 @@ pub extern "C" fn anisette_fs_write_file(
} }
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
pub extern "C" fn anisette_idbfs_init(path: *const c_char) -> i32 { pub extern "C" fn anisette_fs_read_file(path: *const c_char) -> i32 {
let result = (|| -> Result<(), String> { let result = (|| -> Result<Vec<u8>, String> {
let path = unsafe { c_string(path)? }; let path = unsafe { c_string(path)? };
init_idbfs_for_path(&path)?; fs::read(&path).map_err(|e| format!("failed to read '{path}': {e}"))
Ok(())
})(); })();
match result { match result {
Ok(()) => { Ok(data) => {
STATE.with(|state| state.borrow_mut().read_buf = data);
clear_last_error(); clear_last_error();
0 0
} }
@@ -426,6 +427,16 @@ pub extern "C" fn anisette_idbfs_init(path: *const c_char) -> i32 {
} }
} }
#[unsafe(no_mangle)]
pub extern "C" fn anisette_fs_read_ptr() -> *const u8 {
STATE.with(|state| state.borrow().read_buf.as_ptr())
}
#[unsafe(no_mangle)]
pub extern "C" fn anisette_fs_read_len() -> usize {
STATE.with(|state| state.borrow().read_buf.len())
}
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
pub extern "C" fn anisette_idbfs_sync(populate_from_storage: i32) -> i32 { pub extern "C" fn anisette_idbfs_sync(populate_from_storage: i32) -> i32 {
let result = sync_idbfs(populate_from_storage != 0); let result = sync_idbfs(populate_from_storage != 0);