diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b7c0dbc..1124e34 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -2,7 +2,11 @@ "permissions": { "allow": [ "Bash(cat:*)", - "Bash(mkdir:*)" + "Bash(mkdir:*)", + "mcp__ide__getDiagnostics", + "Bash(bun run:*)", + "Bash(bun:*)", + "Bash(NODE_TLS_REJECT_UNAUTHORIZED=0 bun:*)" ], "deny": [], "ask": [] diff --git a/example/anisette-api.mjs b/example/anisette-api.mjs index 5e1d031..8949739 100644 --- a/example/anisette-api.mjs +++ b/example/anisette-api.mjs @@ -31,18 +31,27 @@ const storeservicesPath = args[0]; const coreadiPath = args[1]; const libraryPath = args[2] ?? "./anisette/"; -const wasmModule = await loadWasm(); +const wasmModule = await loadWasm({ printErr: () => {}}); const storeservices = new Uint8Array(await fs.readFile(storeservicesPath)); 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, { - init: { libraryPath }, + init: { libraryPath, adiPb, deviceJsonBytes }, }); if (!anisette.isProvisioned) { console.log("Device not provisioned — running provisioning..."); 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."); } else { console.log("Device already provisioned."); @@ -50,3 +59,6 @@ if (!anisette.isProvisioned) { const headers = await anisette.getData(); console.log(JSON.stringify(headers, null, 2)); + + +console.log(JSON.stringify(await anisette.getData(), null, 2)); \ No newline at end of file diff --git a/js/package.json b/js/package.json index 8ea7091..b087019 100644 --- a/js/package.json +++ b/js/package.json @@ -12,7 +12,7 @@ } }, "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", "typecheck": "tsc --noEmit" }, diff --git a/js/src/anisette.ts b/js/src/anisette.ts index a60834f..6ddfd33 100644 --- a/js/src/anisette.ts +++ b/js/src/anisette.ts @@ -1,6 +1,6 @@ // 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 { WasmBridge } from "./wasm-bridge.js"; import { Device } from "./device.js"; @@ -12,7 +12,6 @@ import { detectLocale, encodeUtf8, } from "./utils.js"; -import type { DeviceJson } from "./types.js"; const DEFAULT_DSID = BigInt(-2); const DEFAULT_LIBRARY_PATH = "./anisette/"; @@ -30,25 +29,39 @@ export interface AnisetteOptions { export class Anisette { private bridge: WasmBridge; private device: Device; - private libs: LibraryStore; private provisioning: ProvisioningSession; private dsid: bigint; + private provisioningPath: 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( bridge: WasmBridge, device: Device, - libs: LibraryStore, provisioning: ProvisioningSession, 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.device = device; - this.libs = libs; this.provisioning = provisioning; this.dsid = dsid; + this.provisioningPath = provisioningPath; this.libraryPath = libraryPath; + this.libs = libs; + this.wasmModule = wasmModule; + this.identifier = identifier; + this.httpClient = httpClient; } // ---- factory methods ---- @@ -83,13 +96,19 @@ export class Anisette { const dsid = options.dsid ?? DEFAULT_DSID; // 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 - const deviceJson = device.toJson(); - const deviceJsonBytes = encodeUtf8(JSON.stringify(deviceJson, null, 2)); + // Restore adi.pb into VFS if provided + if (initOpts.adiPb) { + 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); - // Initialize WASM ADI bridge.initFromBlobs( libs.storeservicescore, @@ -105,58 +124,9 @@ export class Anisette { options.httpClient ); - return new Anisette(bridge, device, libs, provisioning, dsid, libraryPath); - } + const identifier = initOpts.identifier ?? device.adiIdentifier; - /** - * 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 { - 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); + return new Anisette(bridge, device, provisioning, dsid, provisioningPath, libraryPath, libs, wasmModule, identifier, options.httpClient); } // ---- public API ---- @@ -171,8 +141,22 @@ export class Anisette { 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. */ async getData(): Promise { + // 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 now = new Date(); diff --git a/js/src/types.ts b/js/src/types.ts index d82bdee..0ea030f 100644 --- a/js/src/types.ts +++ b/js/src/types.ts @@ -33,6 +33,10 @@ export interface InitOptions { identifier?: string; /** Override parts of the generated device config */ deviceConfig?: Partial; + /** 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 */ diff --git a/js/src/wasm-bridge.ts b/js/src/wasm-bridge.ts index 8497fe1..93e840f 100644 --- a/js/src/wasm-bridge.ts +++ b/js/src/wasm-bridge.ts @@ -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. */ diff --git a/js/src/wasm-loader.ts b/js/src/wasm-loader.ts index e4f3536..488285f 100644 --- a/js/src/wasm-loader.ts +++ b/js/src/wasm-loader.ts @@ -1,27 +1,7 @@ -// Loads the Emscripten WASM glue bundled alongside this file. -// The .wasm binary is resolved relative to this JS file at runtime. - -// @ts-ignore — glue file is generated, no types available +// @ts-expect-error — glue file is generated, no types available 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 -export async function loadWasm(): Promise { - return ModuleFactory({ - locateFile(file: string) { - if (file.endsWith(".wasm")) { - return resolveWasmPath("anisette_rs.node.wasm"); - } - return file; - }, - }); +export async function loadWasm(moduleOverrides?: Record): Promise { + return ModuleFactory({ ...moduleOverrides }); } diff --git a/package.json b/package.json index ae7ea29..00ffeab 100644 --- a/package.json +++ b/package.json @@ -3,9 +3,9 @@ "scripts": { "build:unicorn": "bash script/rebuild-unicorn.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": "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:api": "node example/anisette-api.mjs" } diff --git a/script/build-glue.sh b/script/build-glue.sh index 964b6f2..a3abe66 100755 --- a/script/build-glue.sh +++ b/script/build-glue.sh @@ -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"]' -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"]' +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_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"]' 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" \ --target node \ --format esm \ - --minify + --minify-syntax --minify-whitespace echo " ${DIST_DIR}/anisette.js" fi diff --git a/src/exports.rs b/src/exports.rs index 5e3fabe..b292811 100644 --- a/src/exports.rs +++ b/src/exports.rs @@ -3,7 +3,7 @@ use std::ffi::{CStr, c_char}; use std::fs; use std::path::Path; -use crate::{Adi, AdiInit, init_idbfs_for_path, sync_idbfs}; +use crate::{Adi, AdiInit, sync_idbfs}; #[derive(Default)] struct ExportState { @@ -13,6 +13,7 @@ struct ExportState { session: u32, otp: Vec, mid: Vec, + read_buf: Vec, } thread_local! { @@ -407,15 +408,15 @@ pub extern "C" fn anisette_fs_write_file( } #[unsafe(no_mangle)] -pub extern "C" fn anisette_idbfs_init(path: *const c_char) -> i32 { - let result = (|| -> Result<(), String> { +pub extern "C" fn anisette_fs_read_file(path: *const c_char) -> i32 { + let result = (|| -> Result, String> { let path = unsafe { c_string(path)? }; - init_idbfs_for_path(&path)?; - Ok(()) + fs::read(&path).map_err(|e| format!("failed to read '{path}': {e}")) })(); match result { - Ok(()) => { + Ok(data) => { + STATE.with(|state| state.borrow_mut().read_buf = data); clear_last_error(); 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)] pub extern "C" fn anisette_idbfs_sync(populate_from_storage: i32) -> i32 { let result = sync_idbfs(populate_from_storage != 0);