UNPKG

@needle-tools/engine

Version:

Needle Engine is a web-based runtime for 3D apps. It runs on your machine for development with great integrations into editors like Unity or Blender - and can be deployed onto any device! It is flexible, extensible and networking and XR are built-in.

707 lines (629 loc) 24.1 kB
import { BUILD_TIME, GENERATOR, PUBLIC_KEY, VERSION } from "./engine_constants.js"; import { ContextEvent, ContextRegistry } from "./engine_context_registry.js"; import { onInitialized } from "./engine_lifecycle_api.js"; import { isLocalNetwork } from "./engine_networking_utils.js"; import { Context } from "./engine_setup.js"; import { SSR } from "./engine_ssr.js"; import type { IContext } from "./engine_types.js"; import { getParam } from "./engine_utils.js"; const debug = getParam("__debuglic__"); const qKUrR: ((result: boolean) => void)[] = []; // DO NOT EDIT MANUALLY let EzdGPQg: string = ""; // eslint-disable-next-line prefer-const let _$InwX: string = ""; if (debug) { console.log("License Type: " + EzdGPQg); if (_$InwX) { console.log("License JWT: " + _$InwX); try { const payload = JSON.parse(atob(_$InwX.split(".")[1].replace(/-/g, '+').replace(/_/g, '/'))); console.log("License JWT payload:", payload); } catch { console.log("License JWT payload: (failed to decode)"); } } else { console.log("License JWT: (none)"); } } /** @internal */ export function _cxKhKwDL() { switch (EzdGPQg) { case "pro": case "enterprise": return true; }; return false; } /** @internal */ export function __otwqOR() { switch (EzdGPQg) { case "indie": return true; } return false; } /** @internal */ export function __mPmwPS() { switch (EzdGPQg) { case "edu": return true; } return false; } /** @internal */ export function LynjGsV() { return _cxKhKwDL() || __otwqOR() || __mPmwPS(); } /** @internal */ export function $yRlAEF(cb: (result: boolean) => void) { if (_cxKhKwDL() || __otwqOR() || __mPmwPS()) return cb(true); qKUrR.push(cb); } function cLH(result: boolean) { for (const cb of qKUrR) { try { cb(result); } catch { // ignore } } } // #region JWT // ECDSA P-256 public key for verifying license JWTs (verification-only, safe to ship) /* eslint-disable no-secrets/no-secrets -- public key, not a secret */ const ACBIZP = { kty: "EC", crv: "P-256", x: "A34nyKMjhQYVgzeE4tyLUYdx34TAKogDa7v7PRaO9Lg", y: "JZI9IQavGCpGjEG_-pa0J-MHQYWJYINUM-MnvSu0TmE", } as const; /* eslint-enable no-secrets/no-secrets */ /** Base64url decode (RFC 7515) */ function base64urlDecode(str: string): Uint8Array { const base64 = str.replace(/-/g, '+').replace(/_/g, '/'); const padded = base64 + '='.repeat((4 - base64.length % 4) % 4); const binary = atob(padded); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes; } /** * Verify a JWT license token and return the `type` claim if valid. * Returns null if the JWT is missing, malformed, or has an invalid signature. */ async function IKPk(jwt: string): Promise<string | null> { if (!jwt) return null; try { const parts = jwt.split("."); if (parts.length !== 3) return null; const [headerB64, payloadB64, signatureB64] = parts; const key = await crypto.subtle.importKey( "jwk", ACBIZP, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"] ); const signingInput = new TextEncoder().encode(`${headerB64}.${payloadB64}`); const signature = base64urlDecode(signatureB64); const valid = await crypto.subtle.verify( { name: "ECDSA", hash: "SHA-256" }, key, signature.buffer as ArrayBuffer, signingInput ); if (!valid) { if (debug) console.warn("JWT: signature verification failed"); return null; } const payloadStr = new TextDecoder().decode(base64urlDecode(payloadB64)); const payload = JSON.parse(payloadStr); if (typeof payload.type !== "string" || payload.type.length === 0) { if (debug) console.warn("JWT: missing or invalid 'type' claim"); return null; } // Optional: check expiration if present (future-proof — server doesn't set this yet) if (typeof payload.exp === "number") { const nowSeconds = Math.floor(Date.now() / 1000); if (nowSeconds > payload.exp) { if (debug) console.warn("JWT: token has expired (exp=" + payload.exp + ", now=" + nowSeconds + ")"); return null; } } // Optional: check audience/domain if present (future-proof — server doesn't set this yet) if (payload.aud) { const currentHost = typeof window !== "undefined" ? window.location.hostname : undefined; if (currentHost) { const allowed = Array.isArray(payload.aud) ? payload.aud : [payload.aud]; if (!allowed.some((a: string) => currentHost === a || currentHost.endsWith("." + a))) { if (debug) console.warn("JWT: domain '" + currentHost + "' not in allowed audience: " + allowed.join(", ")); return null; } } } if (debug) console.log("JWT: verified license type: " + payload.type); return payload.type; } catch (err) { if (debug) console.error("JWT: verification error", err); return null; } } /** Verify the injected JWT and update the license type if valid. * The engine ONLY trusts the JWT — the plain EzdGPQg string is ignored. */ let _jwtVerificationPromise: Promise<void> | undefined = undefined; async function _EyK(): Promise<void> { // NOTE: do NOT add an `if (!_$InwX) return` guard here. // esbuild (Vite dependency pre-bundling) would see the variable as constant "" // and tree-shake the entire JWT verification code path. const jwt = _$InwX; // Clear after reading — this reassignment also prevents esbuild from // constant-folding the variable (it now has multiple assignment sites). _$InwX = ""; const verifiedType = await IKPk(jwt); if (verifiedType) { EzdGPQg = verifiedType; if (debug) console.log("License type set from verified JWT: " + verifiedType); cLH(LynjGsV()); } else { EzdGPQg = "basic"; if (debug && jwt) console.warn("JWT verification failed — license reset to basic"); } } // #endregion JWT License Verification // #region Telemetry export namespace Telemetry { if (typeof window !== "undefined") { window.addEventListener("error", (event: ErrorEvent) => { sendError(Context.Current, "unhandled_error", event); }); window.addEventListener("unhandledrejection", (event: PromiseRejectionEvent) => { sendError(Context.Current, "unhandled_promise_rejection", { message: event.reason?.message, stack: event.reason?.stack, timestamp: Date.now(), }); }); } export function init() { if(!SSR) onInitialized((ctx => sendPageViewEvent(ctx)), { once: true }); } function sendPageViewEvent(ctx: IContext): Promise<void> | void { if (!isAllowed(ctx)) { if (debug) console.debug("Telemetry is disabled via no-telemetry attribute"); return; } return doFetch({ site_id: "dabb8317376f", type: "pageview", pathname: window.location.pathname, hostname: window.location.hostname, page_title: document.title, referrer: document.referrer, user_agent: navigator.userAgent, querystring: window.location.search, language: navigator.language, screenWidth: window.screen.width, screenHeight: window.screen.height, event_name: "page_view" }).then(res => { if (res instanceof Response && res.ok && isLocalNetwork()) { const src = ctx.domElement?.getAttribute("src") || ""; const sessionKey = src + VERSION + GENERATOR + BUILD_TIME + PUBLIC_KEY; if (window.sessionStorage.getItem("session_key") !== sessionKey) { window.sessionStorage.setItem("session_key", sessionKey); sendEvent(ctx, "info", { src: ctx.domElement?.getAttribute("src") || "", version: VERSION, generator: GENERATOR, build_time: BUILD_TIME, public_key: PUBLIC_KEY, }); } } return; }) } export function isAllowed(context: IContext | null | undefined): boolean { let domElement = context?.domElement as HTMLElement | null; if (!domElement) domElement = document.querySelector<HTMLElement>("needle-engine"); if (!domElement && !context) return false; const attribute = domElement?.getAttribute("no-telemetry"); if (attribute === "" || attribute === "true" || attribute === "1") { if (EzdGPQg === "pro" || EzdGPQg === "enterprise") { if (debug) console.debug("Telemetry is disabled via no-telemetry attribute"); return false; } } return true; } const id = "dabb8317376f"; /** * Sends a telemetry event */ export async function sendEvent(context: IContext | null | undefined, eventName: string, properties?: Record<string, any>) { if (!isAllowed(context)) { if (debug) console.debug("Telemetry is disabled"); return; } const body = { site_id: id, type: "custom_event", pathname: window.location.pathname, event_name: eventName, properties: properties ? JSON.stringify(properties) : undefined, } return doFetch(body); } type ErrorData = { message?: string; stack?: string; filename?: string; lineno?: number; colno?: number; timestamp?: number; } export async function sendError(context: IContext, errorName: string, error: ErrorData | ErrorEvent | Error) { if (!isAllowed(context)) { if (debug) console.debug("Telemetry is disabled"); return; } if (error instanceof ErrorEvent) { error = { message: error.message, stack: error.error?.stack, filename: error.filename, lineno: error.lineno, colno: error.colno, timestamp: error.timeStamp || Date.now(), }; } else if (error instanceof Error) { error = { message: error.message, stack: error.stack, timestamp: Date.now(), }; } const body = { site_id: id, type: "error", event_name: errorName || "error", properties: JSON.stringify({ error_name: errorName, message: error.message, stack: error.stack, filename: error.filename, lineno: error.lineno, colno: error.colno, timestamp: error.timestamp, }) } return doFetch(body); } function doFetch(body: Record<string, any>) { try { const url = "https://needle.tools/api/v1/rum/t"; return fetch(url, { method: "POST", body: JSON.stringify(body), headers: { 'Content-Type': 'application/json' }, // Ensures request completes even if page unloads keepalive: true, // Allow CORS requests mode: 'cors', // Low priority to avoid blocking other requests // @ts-ignore priority: 'low', }).catch(e => { if (debug) console.error("Failed to send telemetry", e); }) } catch (err) { if (debug) console.error(err); } return Promise.resolve(); } } export function _$AnFl() { if(EzdGPQg === "") EzdGPQg = "basic"; // Start JWT verification — must be here (not top-level) to avoid tree-shaking _jwtVerificationPromise = _EyK(); Telemetry.init(); ContextRegistry.registerCallback(ContextEvent.ContextRegistered, evt => { $vyRL(evt.context); uZY(evt.context); setTimeout(() => cEROvL(evt.context), 2000); }); } export let _$oYJ: Promise<void> | undefined = undefined; let applicationIsForbidden = false; let applicationForbiddenText = ""; async function __lVbhf() { // Only perform the runtime license check once if (_$oYJ) return _$oYJ; // Wait for JWT verification to complete first (if running) if (_jwtVerificationPromise) { await _jwtVerificationPromise; } if (EzdGPQg === "basic") { try { const licenseUrl = "https://needle.tools/api/v1/needle-engine/check?location=" + encodeURIComponent(window.location.href) + "&version=" + VERSION + "&generator=" + encodeURIComponent(GENERATOR); const res = await fetch(licenseUrl, { method: "GET", }).catch(_err => { if (debug) console.error("License check failed", _err); return undefined; }); if (res?.status === 200) { applicationIsForbidden = false; if (debug) console.log("License check succeeded"); EzdGPQg = "pro"; cLH(true); } else if (res?.status === 403) { cLH(false); applicationIsForbidden = true; applicationForbiddenText = await res.text(); } else { cLH(false); if (debug) console.log("License check failed with status " + res?.status); } } catch (err) { cLH(false); if (debug) console.error("License check failed", err); } } else if (debug) console.log("Runtime license check is skipped because license is already applied as \"" + EzdGPQg + "\""); } _$oYJ = __lVbhf(); async function uZY(ctx: IContext) { function createForbiddenElement() { const div = document.createElement("div"); div.className = "needle-forbidden"; div.style.cssText = ` position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: all; zIndex: 2147483647; line-height: 1.5; backdrop-filter: blur(15px); -webkit-backdrop-filter: blur(15px); `; const expectedStyle = div.style.cssText; const text = document.createElement("div"); div.appendChild(text); text.style.cssText = ` position: absolute; left: 0; right: 0; top:0; bottom: 0; padding: 10%; color: white; font-size: 20px; font-family: sans-serif; text-align: center; pointer-events: all; display: flex; justify-content: center; align-items: center; background-color: rgba(0,0,0,.3); text-shadow: 0 0 2px black; `; const expectedTextStyle = text.style.cssText; const forbiddenText = applicationForbiddenText?.length > 1 ? applicationForbiddenText : "This web application has been paused.<br/>You might be in violation of the Needle Engine terms of use.<br/>Please contact the Needle support if you think this is a mistake."; text.innerHTML = forbiddenText; setInterval(() => { if (text.innerHTML !== forbiddenText) text.innerHTML = forbiddenText; if (text.parentNode !== div) div.appendChild(text); if (div.style.cssText !== expectedStyle) div.style.cssText = expectedStyle; if (text.style.cssText !== expectedTextStyle) text.style.cssText = expectedTextStyle; }, 500) return div; } let forbiddenElement = createForbiddenElement(); const expectedCSS = forbiddenElement.style.cssText; setInterval(() => { if (applicationIsForbidden === true) { if (forbiddenElement.style.cssText !== expectedCSS) forbiddenElement = createForbiddenElement(); if (ctx.domElement.shadowRoot) { if (forbiddenElement.parentNode !== ctx.domElement.shadowRoot) ctx.domElement.shadowRoot?.appendChild(forbiddenElement); } else if (forbiddenElement.parentNode != document.body) { document.body.appendChild(forbiddenElement); } } }, 500) } async function $vyRL(ctx: IContext) { try { if (!_cxKhKwDL() && !__otwqOR()) { return LGWV(ctx); } } catch (err) { if (debug) console.log("License check failed", err) return LGWV(ctx) } if (debug) LGWV(ctx) } async function LGWV(ctx: IContext) { // if the engine loads faster than the license check, we need to capture the ready event here let isReady = false; ctx.domElement.addEventListener("ready", () => isReady = true); await _$oYJ?.catch(() => { }); if (_cxKhKwDL() || __otwqOR()) return; if (LynjGsV() === false) _kYgJQd(); // check if the engine is already ready (meaning has finished loading) if (isReady) { _$XDPaPw(ctx); } else { ctx.domElement.addEventListener("ready", () => { _$XDPaPw(ctx); }); } } // const licenseElementIdentifier = "needle-license-element"; // const licenseDuration = 10000; // const licenseDelay = 1200; function _$XDPaPw(ctx: IContext) { const style = ` position: relative; display: block; background-size: 20px; background-position: 10px 5px; background-repeat:no-repeat; background-image:url('${base64Logo}'); background-max-size: 40px; padding: 10px; padding-left: 30px; `; if (EzdGPQg === "edu") { if (navigator.webdriver) { console.log("This project is supported by Needle for Education – https://needle.tools"); } else { console.log("%c " + "This project is supported by Needle for Education – https://needle.tools", style); } } else { // if the user has a basic license we already show the logo in the menu and log a license message return; } const banner = document.createElement("div"); banner.className = "needle-non-commercial-use"; banner.innerHTML = "Made with Needle for Education"; ctx.domElement.shadowRoot?.appendChild(banner); let bannerStyle = ` position: absolute; font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; font-size: 12px; color: rgb(100, 100, 100); /*mix-blend-mode: difference;*/ background-color: transparent; z-index: 10000; cursor: pointer; user-select: none; opacity: 0; bottom: 6px; right: 12px; transform: translateY(0px); transition: all .5s ease-in-out 1s; `; banner.style.cssText = bannerStyle; banner.addEventListener("click", () => { window.open("https://needle.tools", "_blank") }); let expectedBannerStyle = banner.style.cssText; setTimeout(() => { bannerStyle = bannerStyle.replace("opacity: 0", "opacity: 1"); bannerStyle = bannerStyle.replace("transform: translateY(10px)", "transform: translateY(0)"); banner.style.cssText = bannerStyle; expectedBannerStyle = banner.style.cssText; }, 100); // ensure the banner is always visible const interval = setInterval(() => { const parent = ctx.domElement.shadowRoot || ctx.domElement; if (banner.parentNode !== parent) { parent.appendChild(banner); } if (expectedBannerStyle != banner.style.cssText) { banner.style.cssText = bannerStyle; expectedBannerStyle = banner.style.cssText; } }, 1000); if (__mPmwPS()) { const removeDelay = 20_000; setTimeout(() => { clearInterval(interval); banner?.remove(); // show the logo every x minutes const intervalInMinutes = 5; setTimeout(() => { if (ctx.domElement.parentNode) _$XDPaPw(ctx); }, 1000 * 60 * intervalInMinutes) }, removeDelay); } } const base64Logo = "data:image/webp;base64,UklGRrABAABXRUJQVlA4WAoAAAAQAAAAHwAAHwAAQUxQSKEAAAARN6CmbSM4WR7vdARON11EBDq3fLiNbVtVzpMCPlKAEzsx0Y/x+Ovuv4dn0EFE/ydAvz6YggXzgh5sVgXM/zOC/4sii7qgGvB5N7hmuQYwkvazWAu1JPW41FXSHq6pnaQWvqYH18Fc0j1hO/BFTtIeSBlJi5w6qIIO7IOrwhFsB2Yxukif0FTRLpXswHR8MxbslKe9VZsn/Ub5C7YFOpqSTABWUDgg6AAAAFAGAJ0BKiAAIAA+7VyoTqmkpCI3+qgBMB2JbACdMt69DwMIQBLhkTO6XwY00UEDK6cNIDnuNibPf0EgAP7Y1myuiQHLDsF/0h5unrGh6WAbv7aegg2ZMd3uRKfT/3SJztcaujYfTvMXspfCTmYcoO6a+vhC3ss4M8uM58t4siiu59I4aOl59e9Sr6xoxYlHf2v+NnBNpJYeJf8jABQAId/PXuBkLEFkiCucgSGEcfhvajql/j3reCGl0M5/9gQWy7ayNPs+wlvIxFnNfSlfuND4CZOCyxOHhRqOmHN4ULHo3tCSrUNvgAA="; let lastLogTime = 0; async function _kYgJQd(_logo?: string) { const now = Date.now(); if (now - lastLogTime < 2000) return; lastLogTime = now; const style = ` position: relative; display: block; font-size: 18px; background-size: 20px; background-position: 10px 5px; background-repeat:no-repeat; background-image:url('${base64Logo}'); background-max-size: 40px; margin-bottom: 5px; margin-top: .3em; margin-bottom: .5em; padding: .2em; padding-left: 25px; border-radius: .5em; border: 2px solid rgba(160,160,160,.3); `; // url must contain https for firefox to make it clickable const version = VERSION; const licenseText = `Needle Engine — No license active, commercial use is not allowed. Visit https://needle.tools/pricing for more information and licensing options! v${version}`; if (Context.Current?.xr || navigator.webdriver) { console.log(licenseText); } else { console.log("%c " + licenseText, style); } } async function cEROvL(context: IContext) { // We can't send beacons from cross-origin isolated pages if (window.crossOriginIsolated) return; if (!Telemetry.isAllowed(context)) { if (debug) console.debug("Telemetry is disabled via no-telemetry attribute"); return; } try { const analyticsUrl = "htt" + "ps://" + "needle" + ".tools/" + "api/v1/needle-engine/ping"; if (analyticsUrl) { // current url without query parameters const currentUrl = window.location.href.split("?")[0]; const license = EzdGPQg; const beaconData = { license, url: currentUrl, hostname: window.location.hostname, pathname: window.location.pathname, // search: window.location.search, // hash: window.location.hash, version: VERSION, generator: GENERATOR, build_time: BUILD_TIME, public_key: PUBLIC_KEY, }; const res = navigator.sendBeacon?.(analyticsUrl, JSON.stringify(beaconData)); if (debug) console.debug("Sent beacon: " + res); } } catch (err) { if (debug) console.log("Failed to send non-commercial usage message to analytics backend", err); } }