@@ -0,0 +1,169 @@
import type { BiometricPosition } from './biometric-enrollment.types' ;
/**
* Client for Mantra's Windows RD Service — the local agent that ships with
* MORPHS (and every other Mantra scanner) and is the only way a browser can
* reach a USB-attached device. It implements UIDAI's standard Registered
* Device Service contract (the same one every L1-certified scanner uses),
* exposing three verbs over plain HTTP on `127.0.0.1`:
*
* RDSERVICE http://127.0.0.1:<port>/ → discovery: is it running,
* what are the paths for
* DEVICEINFO and CAPTURE
* DEVICEINFO http://127.0.0.1:<port>/rd/info → device identity
* CAPTURE http://127.0.0.1:<port>/rd/capture → the actual scan
*
* The service binds one port out of 11100– 11105 (11100 unless something else
* already holds it), so discovery has to probe the range rather than assume
* a fixed port.
*
* This is the one piece "swap the simulated bytes for the real vendor SDK
* once a vendor is chosen" (see `BiometricEnrollmentPage.tsx`) always meant —
* everything else in the enroll pipeline (base64 template + format tag,
* encrypt, store) was already shaped for whatever this returns.
*/
const CANDIDATE_PORTS = [ 11100 , 11101 , 11102 , 11103 , 11104 , 11105 ] as const ;
const DISCOVERY_TIMEOUT_MS = 800 ;
const CAPTURE_TIMEOUT_MS = 30 _000 ;
export interface MantraDeviceInfo {
/** Device provider/serial identity as reported by RD service — goes in `deviceId`. */
deviceId : string ;
/** RD service version string, kept for support/troubleshooting, not sent to the API. */
rdsVersion : string ;
/** The port discovery found it on. */
port : number ;
}
export interface MantraCaptureResult {
/** Base64 PID template block — passed straight through as `EnrollBiometric.template`. */
template : string ;
templateFormat : string ;
/** RD service's own capture quality score, 0– 100, when the response carries one. */
qualityScore? : number ;
deviceId : string ;
}
export class MantraCaptureUnavailableError extends Error {
constructor ( message = 'Mantra RD Service was not found on this machine.' ) {
super ( message ) ;
this . name = 'MantraCaptureUnavailableError' ;
}
}
export class MantraCaptureFailedError extends Error {
constructor (
message : string ,
public readonly errCode? : string ,
) {
super ( message ) ;
this . name = 'MantraCaptureFailedError' ;
}
}
function withTimeout ( ms : number ) : { signal : AbortSignal ; cancel : ( ) = > void } {
const controller = new AbortController ( ) ;
const timer = setTimeout ( ( ) = > controller . abort ( ) , ms ) ;
return { signal : controller.signal , cancel : ( ) = > clearTimeout ( timer ) } ;
}
async function rdRequest ( port : number , path : string , method : string , timeoutMs : number , body? : string ) : Promise < string > {
const { signal , cancel } = withTimeout ( timeoutMs ) ;
try {
// RD Service answers plain HTTP on localhost, and browsers block a page
// served over https:// from fetching http:// — this must be called from
// a counter page itself served over http://localhost or with the RD
// service's own local HTTPS certificate trusted, per Mantra's install guide.
const res = await fetch ( ` http://127.0.0.1: ${ port } ${ path } ` , {
method ,
body ,
headers : body ? { 'Content-Type' : 'application/xml' } : undefined ,
signal ,
} ) ;
return await res . text ( ) ;
} finally {
cancel ( ) ;
}
}
/** Parses `attr="value"` out of one XML tag without pulling in an XML library for three fields. */
function attr ( xml : string , tag : string , name : string ) : string | undefined {
const tagMatch = xml . match ( new RegExp ( ` < ${ tag } \\ b[^>]*> ` , 'i' ) ) ;
if ( ! tagMatch ) return undefined ;
const attrMatch = tagMatch [ 0 ] . match ( new RegExp ( ` ${ name } ="([^"]*)" ` , 'i' ) ) ;
return attrMatch ? . [ 1 ] ;
}
/**
* Probes the RD service port range and reads back device identity. Resolves
* to `null` (never throws) when nothing answers — callers use that to decide
* whether to show "Simulate Scan" instead, exactly like `simulateEnabled`
* already gates it server-side.
*/
export async function discoverMantraDevice ( ) : Promise < MantraDeviceInfo | null > {
for ( const port of CANDIDATE_PORTS ) {
try {
const discovery = await rdRequest ( port , '/' , 'RDSERVICE' , DISCOVERY_TIMEOUT_MS ) ;
if ( ! /status="READY"/i . test ( discovery ) ) continue ;
const infoPath = attr ( discovery , 'Interface' , 'path' ) ? ? '/rd/info' ;
const info = await rdRequest ( port , infoPath , 'DEVICEINFO' , DISCOVERY_TIMEOUT_MS ) ;
const dpId = attr ( info , 'DeviceInfo' , 'dpId' ) ? ? attr ( info , 'DeviceInfo' , 'dc' ) ;
const rdsVersion = attr ( info , 'DeviceInfo' , 'rdsVer' ) ? ? 'unknown' ;
if ( ! dpId ) continue ;
return { deviceId : dpId , rdsVersion , port } ;
} catch {
// Nothing on this port (connection refused/timeout) — try the next one.
continue ;
}
}
return null ;
}
/**
* One fingerprint capture for a specific finger. `position` drives nothing
* in the RD-service request itself (a single-finger scanner like a slap
* reader doesn't need to be told which finger it is), but the caller must
* still know which finger was placed, since MORPHS's four-finger and
* two-thumb slaps arrive pre-segmented by the vendor SDK into one image per
* finger with no position label of their own.
*/
export async function captureFingerprint (
device : MantraDeviceInfo ,
position : BiometricPosition ,
) : Promise < MantraCaptureResult > {
const pidOptions = ` <PidOptions ver="1.0"><Opts fCount="1" fType="0" format="0" pidVer="2.0" timeout=" ${ CAPTURE_TIMEOUT_MS } " posh="UNKNOWN" env="P" /></PidOptions> ` ;
let response : string ;
try {
response = await rdRequest ( device . port , '/rd/capture' , 'CAPTURE' , CAPTURE_TIMEOUT_MS , pidOptions ) ;
} catch ( err ) {
throw new MantraCaptureFailedError (
err instanceof Error && err . name === 'AbortError' ? 'Capture timed out.' : 'Could not reach the scanner.' ,
) ;
}
const errCode = attr ( response , 'Resp' , 'errCode' ) ;
if ( errCode && errCode !== '0' ) {
const errInfo = attr ( response , 'Resp' , 'errInfo' ) ? ? ` RD service error ${ errCode } ` ;
throw new MantraCaptureFailedError ( errInfo , errCode ) ;
}
const dataMatch = response . match ( /<Data[^>]*>([\s\S]*?)<\/Data>/i ) ;
if ( ! dataMatch ) {
throw new MantraCaptureFailedError ( 'Capture response had no template data.' ) ;
}
const qScore = attr ( response , 'Resp' , 'qScore' ) ;
return {
template : dataMatch [ 1 ] . trim ( ) ,
// PidData carries an encrypted PID block per UIDAI's spec, not a raw
// ISO/WSQ template — recorded as such so a real matcher/decryption step
// downstream isn't misled into treating it as plaintext ISO-19794-4.
templateFormat : 'UIDAI-PID-2.0' ,
qualityScore : qScore ? Number ( qScore ) : undefined ,
deviceId : device.deviceId ,
} ;
}