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.

659 lines (657 loc) 23.9 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 { getParam } from "./engine_utils.js"; const debug = getParam("__debuglic__"); const _$zbtJJVy = []; // DO NOT EDIT MANUALLY let miOmwPp = ""; // eslint-disable-next-line prefer-const let _$ALiqbpo = ""; if (debug) { console.log("License Type: " + miOmwPp); if (_$ALiqbpo) { console.log("License JWT: " + _$ALiqbpo); try { const payload = JSON.parse(atob(_$ALiqbpo.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 _$QyV() { switch (miOmwPp) { case "pro": case "enterprise": return true; } ; return false; } /** @internal */ export function __Carg() { switch (miOmwPp) { case "indie": return true; } return false; } /** @internal */ export function NVzibd() { switch (miOmwPp) { case "edu": return true; } return false; } /** @internal */ export function bLW() { return _$QyV() || __Carg() || NVzibd(); } /** @internal */ export function $bxGE(cb) { if (_$QyV() || __Carg() || NVzibd()) return cb(true); _$zbtJJVy.push(cb); } function __JogRhlxZ(result) { for (const cb of _$zbtJJVy) { 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 CcFRcciC = { kty: "EC", crv: "P-256", x: "A34nyKMjhQYVgzeE4tyLUYdx34TAKogDa7v7PRaO9Lg", y: "JZI9IQavGCpGjEG_-pa0J-MHQYWJYINUM-MnvSu0TmE", }; /* eslint-enable no-secrets/no-secrets */ /** Base64url decode (RFC 7515) */ function base64urlDecode(str) { 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 ZhJ(jwt) { 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", CcFRcciC, { 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, 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) => 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 miOmwPp string is ignored. */ let _jwtVerificationPromise = undefined; async function $ESIFccD() { // NOTE: do NOT add an `if (!_$ALiqbpo) 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 = _$ALiqbpo; // Clear after reading — this reassignment also prevents esbuild from // constant-folding the variable (it now has multiple assignment sites). _$ALiqbpo = ""; const verifiedType = await ZhJ(jwt); if (verifiedType) { miOmwPp = verifiedType; if (debug) console.log("License type set from verified JWT: " + verifiedType); __JogRhlxZ(bLW()); } else { miOmwPp = "basic"; if (debug && jwt) console.warn("JWT verification failed — license reset to basic"); } } // #endregion JWT License Verification // #region Telemetry export var Telemetry; (function (Telemetry) { if (typeof window !== "undefined") { window.addEventListener("error", (event) => { sendError(Context.Current, "unhandled_error", event); }); window.addEventListener("unhandledrejection", (event) => { sendError(Context.Current, "unhandled_promise_rejection", { message: event.reason?.message, stack: event.reason?.stack, timestamp: Date.now(), }); }); } function init() { if (!SSR) onInitialized((ctx => sendPageViewEvent(ctx)), { once: true }); } Telemetry.init = init; function sendPageViewEvent(ctx) { 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; }); } function isAllowed(context) { let domElement = context?.domElement; if (!domElement) domElement = document.querySelector("needle-engine"); if (!domElement && !context) return false; const attribute = domElement?.getAttribute("no-telemetry"); if (attribute === "" || attribute === "true" || attribute === "1") { if (miOmwPp === "pro" || miOmwPp === "enterprise") { if (debug) console.debug("Telemetry is disabled via no-telemetry attribute"); return false; } } return true; } Telemetry.isAllowed = isAllowed; const id = "dabb8317376f"; /** * Sends a telemetry event */ async function sendEvent(context, eventName, properties) { 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); } Telemetry.sendEvent = sendEvent; async function sendError(context, errorName, 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); } Telemetry.sendError = sendError; function doFetch(body) { 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(); } })(Telemetry || (Telemetry = {})); export function $cLf() { if (miOmwPp === "") miOmwPp = "basic"; // Start JWT verification — must be here (not top-level) to avoid tree-shaking _jwtVerificationPromise = $ESIFccD(); Telemetry.init(); ContextRegistry.registerCallback(ContextEvent.ContextRegistered, evt => { _JTSOMz(evt.context); __rlGEVK(evt.context); setTimeout(() => __OmIq(evt.context), 2000); }); } export let _MHgSj = undefined; let applicationIsForbidden = false; let applicationForbiddenText = ""; async function $pek() { // Only perform the runtime license check once if (_MHgSj) return _MHgSj; // Wait for JWT verification to complete first (if running) if (_jwtVerificationPromise) { await _jwtVerificationPromise; } if (miOmwPp === "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"); miOmwPp = "pro"; __JogRhlxZ(true); } else if (res?.status === 403) { __JogRhlxZ(false); applicationIsForbidden = true; applicationForbiddenText = await res.text(); } else { __JogRhlxZ(false); if (debug) console.log("License check failed with status " + res?.status); } } catch (err) { __JogRhlxZ(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 \"" + miOmwPp + "\""); } _MHgSj = $pek(); async function __rlGEVK(ctx) { 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 _JTSOMz(ctx) { try { if (!_$QyV() && !__Carg()) { return dtvHGhBg(ctx); } } catch (err) { if (debug) console.log("License check failed", err); return dtvHGhBg(ctx); } if (debug) dtvHGhBg(ctx); } async function dtvHGhBg(ctx) { // 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 _MHgSj?.catch(() => { }); if (_$QyV() || __Carg()) return; if (bLW() === false) __PJeisZrd(); // check if the engine is already ready (meaning has finished loading) if (isReady) { __CRBs(ctx); } else { ctx.domElement.addEventListener("ready", () => { __CRBs(ctx); }); } } // const licenseElementIdentifier = "needle-license-element"; // const licenseDuration = 10000; // const licenseDelay = 1200; function __CRBs(ctx) { 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 (miOmwPp === "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 (NVzibd()) { const removeDelay = 20_000; setTimeout(() => { clearInterval(interval); banner?.remove(); // show the logo every x minutes const intervalInMinutes = 5; setTimeout(() => { if (ctx.domElement.parentNode) __CRBs(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 __PJeisZrd(_logo) { 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 __OmIq(context) { // 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 = miOmwPp; 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); } } //# sourceMappingURL=engine_license.js.map