import {zenidLog} from "../../core/zenid-log.js"; import {WebVisualContainer} from "./web-visual-container.js"; import {WebCamera} from "./web-camera.js"; import {EmptyVisualizerImplementation, VisualizerImplementation} from "./web-visualizer.js"; import {EncodedImage, MsLivenessGiveMeResponse, MsLivenessStateContainerForPublicData} from "../../core/generated/zenid-types.generated.js"; import "../../MsLiveness/FaceLivenessDetector.js"; import {OriginalImageFormat, EncodedImageFormat} from "../../core/generated/zenid-enums.generated.js"; import {BaseVisualizerOptions, CameraFacingMode, ICamera} from "../../interfaces/verification-system-interfaces.js"; import ZenidSDK from "../../core/internal/zenid-sdk.js"; import {ZenidController} from "../../core/zenid-controller.js"; import {blobToBase64, uint8ArrayToBase64} from "../../core/helpers/binary-helpers.js"; import {FaceLivenessDetector} from "../../core/verifiers/FaceLivenessDetector"; import {BackendApi} from "./backend-api.js"; import {zenidSdkCss} from "./zenid-sdk-css.js"; export class ZenidWebController extends ZenidController { // Static fields private static instance: ZenidWebController; private static cssLoaded: boolean = false; // Instance fields private cameraFeedElement?: HTMLElement | null; // True while the native iOS camera is open for a manual photo. Opening it fires `pagehide`, // which must not be treated as a page unload (see setupIosEventHandlers). private nativeCaptureInProgress = false; // Captures the base class default so setupVisualElements can distinguish it from a customer-injected visualizer private readonly _defaultVisualizer = this.visualizer; /** * Options for the default SDK visualizer (font, drawText, etc.). * Ignored when a custom visualizer is assigned to this.visualizer before init(). */ public visualizerOptions: BaseVisualizerOptions = {}; private constructor() { super(); // Add iOS-specific event handlers this.setupIosEventHandlers(); } // Static methods static get Instance(): ZenidWebController { if (!ZenidWebController.instance) { ZenidWebController.instance = new ZenidWebController(); } return ZenidWebController.instance; } // WARNING: if you change init()'s signature, make sure to run `git grep "init(backendApi"` to find all instances of it being mentioned in the docs and change them accordingly. // Right now the full signature is listed in the following docs: // ZenidWeb/Views/Manual/WebApiDemo.cshtml // ZenidWeb/FeatureNotes/WebSDK_Migration_Guide.md // ZenidWeb/FeatureNotes/COOP_COEP_Guide.md async init(backendApi: BackendApi, absoluteModelsUrl: string, loadEssentialCss: boolean = true, useSingleThread: boolean = false, assetUrls?: { wasmUrl?: string; emscriptenJsUrl?: string }): Promise { if (loadEssentialCss) this.loadCriticalCss(); this.assetUrls = assetUrls; await super.init(backendApi, absoluteModelsUrl, useSingleThread); } // Public methods override async takeNativePhoto(facingMode: CameraFacingMode): Promise { // On iOS: show big button to open camera because Safari requires user input // On other mobile devices: use normal hidden input // On desktop: use getUserMedia for direct capture (no dialog) if (this.isIos()) { return await this.takePhotoWithNativeCameraIOS(facingMode); } else if (this.isMobileDevice()) { return await this.takePhotoWithNativeCamera(facingMode); } else { return await this.takePhotoWithStream(facingMode); } } override async takePhotoWithStream(facingMode: CameraFacingMode, resolution?: number): Promise { let cameraSnapshot = await this.camera.getHighResolutionFrameAsEncodedBlob(resolution); if (!cameraSnapshot) throw new Error('Failed to capture camera frame'); return { Data: await blobToBase64(cameraSnapshot), Format: EncodedImageFormat.JPEG, // currently ignored on C++ side }; } // Protected methods protected override async setupVisualElements(): Promise { this.cameraFeedElement = document.querySelector('[data-zenid-camera-feed]'); if (!this.cameraFeedElement) { const errorMessage = 'You have to define a camera feed container using "data-zenid-camera-feed" attribute'; zenidLog.error(errorMessage); throw new Error(errorMessage); } const roiContainer = document.querySelector('[data-zenid-roi]'); const cameraFeedContainer = new WebVisualContainer(this.cameraFeedElement!); let roiContainerWrapper: WebVisualContainer = roiContainer ? new WebVisualContainer(roiContainer) : cameraFeedContainer; // Stop and cleanup old camera before creating new one if (this.camera) { await this.camera.stop(); } this.camera = new WebCamera(cameraFeedContainer, roiContainerWrapper); // If it is our own VisualizerImplementation, create/recreate the default visualizer, preserving options across reinit. const isOwnVisualizer = this.visualizer === this._defaultVisualizer || this.visualizer instanceof VisualizerImplementation; if (isOwnVisualizer) { const oldOptions = this.visualizer instanceof VisualizerImplementation ? this.visualizer.options : {}; this.visualizer.cleanup(); this.visualizer = new VisualizerImplementation(roiContainerWrapper, { ...oldOptions, ...this.visualizerOptions }); } await super.setupVisualElements(); } protected override async startMsLiveness(state: MsLivenessStateContainerForPublicData): Promise { let faceLivenessDetector = document.createElement("azure-ai-vision-face-ui") as FaceLivenessDetector; let faceLivenessDetectorWrapper = document.createElement("azure-ai-vision-face-ui-wrapper"); // we need a wrapper because we don't own the component and can't define customElements.define for it // Use stored language setting, if not set, navigator.language, then en-US fallback let locale = await ZenidSDK.Instance.getLanguageLocale() || navigator.language || "en-US"; zenidLog.info(`MS Liveness using locale: ${locale}`); // Pre-load i18n dictionary. Derive the base URL from resourcePath (absoluteModelsUrl) // which the customer provides at init(). The facelivenessdetector-assets dir is a sibling // of the models dir, so go up one level. This works with both bundlers and direct ES module usage. const sdkAssetsBase = this.resourcePath.replace(/\/[^/]+\/?$/, ''); // The Azure azure-ai-vision-face-ui component has no public baseAssetsURL setter — it // reads the private _baseAssetsURL field in connectedCallback() to build the URL for // /facelivenessdetector-assets/js/AzureAIVisionFace*.{js,wasm}. Default is "./" which // breaks under bundling (resolves relative to document, not to where the customer hosts // SDK assets). Set it before append so the component fetches from sdkAssetsBase instead. // Customer must ensure facelivenessdetector-assets/ is reachable at sdkAssetsBase // (e.g., via CopyWebpackPlugin — these files must keep their original names because the // component requests them by literal name, not via static imports the bundler can hash). (faceLivenessDetector as FaceLivenessDetector & { _baseAssetsURL: string })._baseAssetsURL = sdkAssetsBase; const resolvedLocale = resolveMsLivenessLocale(locale); faceLivenessDetector.locale = resolvedLocale; try { const i18nUrl = `${sdkAssetsBase}/facelivenessdetector-assets/i18n/${resolvedLocale}/en.json`; const i18nResponse = await fetch(i18nUrl); if (i18nResponse.ok) faceLivenessDetector.languageDictionary = await i18nResponse.json(); } catch (e) { zenidLog.warn('Failed to pre-load MsLiveness i18n:', e); } faceLivenessDetectorWrapper.appendChild(faceLivenessDetector); this.cameraFeedElement!.appendChild(faceLivenessDetectorWrapper); let result: MsLivenessGiveMeResponse = {}; try { zenidLog.info("MS liveness: creating session..."); const sessionInfo: any = await this.backendApi.msLivenessCreateSession(this.challengeToken); if (sessionInfo.ErrorCode || sessionInfo.ErrorText) { const errorMessage = `MS liveness: backend error. ErrorCode: ${sessionInfo.ErrorCode ?? ""}, ErrorText: ${sessionInfo.ErrorText ?? ""}`; zenidLog.error(errorMessage); result.ErrorMessage = errorMessage; result.IsSuccess = false; return result; } if (!sessionInfo.AuthToken || !sessionInfo.SessionId) { const errorMessage = "MS liveness: backend response missing AuthToken or SessionId."; zenidLog.error(errorMessage); result.ErrorMessage = errorMessage; result.IsSuccess = false; return result; } zenidLog.info("MS liveness: session created, sessionId:", sessionInfo.SessionId); let msResult = await faceLivenessDetector.start(sessionInfo.AuthToken); if (msResult.resultId) { zenidLog.info("MS liveness resultId: ", msResult.resultId); result.SessionId = sessionInfo.SessionId; const livenessSessionResponseJson = await this.backendApi.msLivenessGetSessionResult(result.SessionId!); let sessionImageId = livenessSessionResponseJson.results.attempts[0].result.sessionImageId; let faceResult = livenessSessionResponseJson.results.attempts[0].result.livenessDecision; result.IsSuccess = faceResult == "realface"; if (!result.IsSuccess) { zenidLog.warn("MS liveness: liveness decision:", faceResult); return result; } const imageBase64 = await this.backendApi.msLivenessGetSessionImage(sessionImageId); if (imageBase64) { result.Data = imageBase64; } else { zenidLog.error('MS liveness: failed to fetch session image'); } } else { zenidLog.warn("MS liveness: no resultId returned from Azure detector"); result.IsSuccess = false; } } catch (error) { let errorMessage = error instanceof Error ? error.message : String(error); zenidLog.error("MS liveness error: ", errorMessage); result.ErrorMessage = errorMessage; result.IsSuccess = false; } finally { this.cameraFeedElement!.removeChild(faceLivenessDetectorWrapper); } return result; } // Private methods private async fileToBase64EncodedImage(file: File): Promise { zenidLog.info(`Processing file: ${file.name}, size: ${file.size} bytes, type: ${file.type}`); let buf = await new Promise((resolve, reject) => { let fr = new FileReader(); // TODO: does error fire first before loadend? fr.onerror = () => reject(new Error("File reading error")); fr.onloadend = () => resolve(fr.result as ArrayBuffer); fr.readAsArrayBuffer(file); }); return { Data: uint8ArrayToBase64(new Uint8Array(buf)), Format: EncodedImageFormat.JPEG, }; } /** * Load critical CSS required for SDK functionality. * CSS is inlined at build time for bundler compatibility (Webpack, Vite). */ private loadCriticalCss(): void { if (ZenidWebController.cssLoaded) { return; } const style = document.createElement('style'); style.textContent = zenidSdkCss; style.setAttribute('data-zenid-sdk-css', 'true'); document.head.appendChild(style); ZenidWebController.cssLoaded = true; zenidLog.debug('ZenID SDK critical CSS injected'); } /** * Set up iOS-specific event handlers to work around WebKit bugs */ private setupIosEventHandlers(): void { if (!this.isIos()) return; // This is required on iOS Safari / WebKit that has a bug in wasm memory.atomic.wait32 // instruction that prevents waiting pthreads from releasing resources. // useSingleThread is set in init() (after this constructor), so we check it when the event fires. // Add pagehide handler to clean up SDK resources when page is hidden or closed. window.addEventListener('pagehide', async () => { // Skip the teardown in single-threaded mode (no pthreads to release) and while the // native camera is open: opening it fires `pagehide` even though the page is only // transiently backgrounded, and tearing the SDK down there freezes the scan with no // recovery (returning from the camera is not a bfcache `pageshow` restore). if (this.useSingleThread || this.nativeCaptureInProgress) return; zenidLog.info('pagehide event detected on iOS, cleaning up resources'); // Stop the verifier processing loop await this.stop(); await ZenidSDK.Instance.unloadAll(); // Synchronous spin (not setTimeout): inside pagehide, awaiting yields to the browser, // which then discards the page before the wait32-stuck pthreads can release. const endTime = Date.now() + 1500; while (Date.now() < endTime) { // Empty loop to wait } }); // Add pageshow handler that reloads websdk worker using cached settings for features // when the page is restored from cache after a back button press. window.addEventListener('pageshow', async (evt) => { if (this.useSingleThread) return; // Quit if we didn't restore from cache (i.e. from back button press) if (!evt.persisted) return; zenidLog.info('pageshow event detected on iOS, reloading resources'); // Stop the verifier processing loop await this.stop(); // Kill all previous threads and workers ZenidSDK.Instance.killAll(); await ZenidSDK.Instance.setup(this.resourcePath, this.useSingleThread, this.assetUrls); await this.initSdk(); }); } private createCameraIcon(): SVGElement { const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); svg.setAttribute('width', '80'); svg.setAttribute('height', '80'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('fill', 'none'); // Create camera lens circle path const lensPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); lensPath.setAttribute('d', 'M12 16C14.2091 16 16 14.2091 16 12C16 9.79086 14.2091 8 12 8C9.79086 8 8 9.79086 8 12C8 14.2091 9.79086 16 12 16Z'); lensPath.setAttribute('stroke', '#333'); lensPath.setAttribute('stroke-width', '2'); lensPath.setAttribute('stroke-linecap', 'round'); lensPath.setAttribute('stroke-linejoin', 'round'); // Create camera body path const bodyPath = document.createElementNS('http://www.w3.org/2000/svg', 'path'); bodyPath.setAttribute('d', 'M3 7C3 5.89543 3.89543 5 5 5H8L9 3H15L16 5H19C20.1046 5 21 5.89543 21 7V18C21 19.1046 20.1046 20 19 20H5C3.89543 20 3 19.1046 3 18V7Z'); bodyPath.setAttribute('stroke', '#333'); bodyPath.setAttribute('stroke-width', '2'); bodyPath.setAttribute('stroke-linecap', 'round'); bodyPath.setAttribute('stroke-linejoin', 'round'); svg.appendChild(lensPath); svg.appendChild(bodyPath); return svg; } private async takePhotoWithNativeCamera(facingMode: CameraFacingMode): Promise { zenidLog.info('takePhotoWithNativeCamera'); return new Promise((resolve, reject) => { const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.setAttribute('capture', facingMode); input.onchange = async (event: Event) => { const files = (event.target as HTMLInputElement).files; if (files && files.length > 0) { try { const result = await this.fileToBase64EncodedImage(files[0]); resolve(result); } catch (err) { reject(err); } } else { reject(new Error('No photo was captured')); } document.body.removeChild(input); }; input.style.display = 'none'; document.body.appendChild(input); input.click(); }); } private async takePhotoWithNativeCameraIOS(facingMode: CameraFacingMode): Promise { zenidLog.info('takePhotoWithNativeCameraIOS - using label workaround'); // Opening the native camera backgrounds the WebView and fires `pagehide`. Flag it so the // pagehide handler doesn't mistake it for a page unload and tear the SDK down (which would // freeze the scan). Cleared on every settle path below via finally(). this.nativeCaptureInProgress = true; return new Promise((resolve, reject) => { // Create container const container = document.createElement('div'); container.className = 'zenid-ios-camera-modal'; // Create wrapper for centered content const wrapper = document.createElement('div'); wrapper.className = 'zenid-ios-camera-wrapper'; // Create label with camera UI const label = document.createElement('label'); label.className = 'zenid-ios-camera-label'; // Camera icon (SVG) const cameraIcon = this.createCameraIcon(); // Text const text = document.createElement('div'); text.className = 'zenid-ios-camera-text'; text.textContent = 'Tap to open camera'; // Hidden file input const input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*'; input.capture = facingMode; input.style.display = 'none'; // Cancel button const cancelButton = document.createElement('button'); cancelButton.textContent = 'Cancel'; cancelButton.className = 'zenid-ios-camera-cancel'; // Event handlers input.onchange = async (event: Event) => { const files = (event.target as HTMLInputElement).files; if (files && files.length > 0) { try { if (files[0].type !== 'image/jpeg') zenidLog.warn(`Didn't get JPEG from iPhone camera, got ${files[0].type} instead'`); const result = await this.fileToBase64EncodedImage(files[0]); document.body.removeChild(container); resolve(result); } catch (err) { document.body.removeChild(container); reject(err); } } else { document.body.removeChild(container); reject(new Error('No photo was captured')); } }; cancelButton.onclick = () => { document.body.removeChild(container); reject(new Error('Camera capture cancelled')); }; // Clicking outside the modal cancels container.onclick = (e) => { if (e.target === container) { document.body.removeChild(container); reject(new Error('Camera capture cancelled')); } }; // Assemble UI label.appendChild(cameraIcon); label.appendChild(text); label.appendChild(input); wrapper.appendChild(label); wrapper.appendChild(cancelButton); container.appendChild(wrapper); document.body.appendChild(container); }).finally(() => { this.nativeCaptureInProgress = false; }); } } // Each locale folder in facelivenessdetector-assets/i18n/ always contains a file named "en.json" // (Azure's naming convention — the name is not language-specific). // Uses Intl.Locale.maximize() to expand short codes like "cs" → "cs-CZ" using the browser's locale database. function resolveMsLivenessLocale(locale: string): string { if (locale.includes('-')) return locale; // already a full tag try { const maximized = new Intl.Locale(locale).maximize(); const region = maximized.region; return region ? `${maximized.language}-${region}` : locale; } catch { return locale; } }