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.

387 lines (346 loc) 14.4 kB
import { isDevEnvironment } from "./debug/index.js"; import { BUILD_TIME, GENERATOR, PUBLIC_KEY, VERSION } from "./engine_constants.js"; import { ContextEvent, ContextRegistry } from "./engine_context_registry.js"; import { Context } from "./engine_setup.js"; import type { IContext } from "./engine_types.js"; import { getParam } from "./engine_utils.js"; const debug = getParam("debuglicense"); const _licenseCheckResultChangedCallbacks: ((result: boolean) => void)[] = []; // This is modified by a bundler (e.g. vite) // Do not edit manually let NEEDLE_ENGINE_LICENSE_TYPE: string = "basic"; if (debug) console.log("License Type: " + NEEDLE_ENGINE_LICENSE_TYPE) /** @internal */ export function hasProLicense() { switch (NEEDLE_ENGINE_LICENSE_TYPE) { case "pro": case "enterprise": return true; }; return false; } /** @internal */ export function hasIndieLicense() { switch (NEEDLE_ENGINE_LICENSE_TYPE) { case "indie": return true; } return false; } /** @internal */ export function hasEduLicense() { switch (NEEDLE_ENGINE_LICENSE_TYPE) { case "edu": return true; } return false; } /** @internal */ export function hasCommercialLicense() { return hasProLicense() || hasIndieLicense() || hasEduLicense(); } /** @internal */ export function onLicenseCheckResultChanged(cb: (result: boolean) => void) { if (hasProLicense() || hasIndieLicense() || hasEduLicense()) return cb(true); _licenseCheckResultChangedCallbacks.push(cb); } function invokeLicenseCheckResultChanged(result: boolean) { for (const cb of _licenseCheckResultChangedCallbacks) { try { cb(result); } catch { // ignore } } } ContextRegistry.registerCallback(ContextEvent.ContextRegistered, evt => { showLicenseInfo(evt.context); handleForbidden(evt.context); setTimeout(() => sendUsageMessageToAnalyticsBackend(evt.context), 2000); }); export let runtimeLicenseCheckPromise: Promise<void> | undefined = undefined; let applicationIsForbidden = false; let applicationForbiddenText = ""; async function checkLicense() { // Only perform the runtime license check once if (runtimeLicenseCheckPromise) return runtimeLicenseCheckPromise; if (NEEDLE_ENGINE_LICENSE_TYPE === "basic") { try { const licenseUrl = "https://engine.needle.tools/licensing/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"); NEEDLE_ENGINE_LICENSE_TYPE = "pro"; invokeLicenseCheckResultChanged(true); } else if (res?.status === 403) { invokeLicenseCheckResultChanged(false); applicationIsForbidden = true; applicationForbiddenText = await res.text(); } else { invokeLicenseCheckResultChanged(false); if (debug) console.log("License check failed with status " + res?.status); } } catch (err) { invokeLicenseCheckResultChanged(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 \"" + NEEDLE_ENGINE_LICENSE_TYPE + "\""); } runtimeLicenseCheckPromise = checkLicense(); async function handleForbidden(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 showLicenseInfo(ctx: IContext) { try { if (!hasProLicense() && !hasIndieLicense()) { return onNonCommercialVersionDetected(ctx); } } catch (err) { if (debug) console.log("License check failed", err) return onNonCommercialVersionDetected(ctx) } if (debug) onNonCommercialVersionDetected(ctx) } async function onNonCommercialVersionDetected(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 runtimeLicenseCheckPromise?.catch(() => { }); if (hasProLicense() || hasIndieLicense()) return; if (hasCommercialLicense() === false) logNonCommercialUse(); // check if the engine is already ready (meaning has finished loading) if (isReady) { insertNonCommercialUseHint(ctx); } else { ctx.domElement.addEventListener("ready", () => { insertNonCommercialUseHint(ctx); }); } } // const licenseElementIdentifier = "needle-license-element"; // const licenseDuration = 10000; // const licenseDelay = 1200; function insertNonCommercialUseHint(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 (NEEDLE_ENGINE_LICENSE_TYPE === "edu") { 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 (hasEduLicense()) { const removeDelay = 20_000; setTimeout(() => { clearInterval(interval); banner?.remove(); // show the logo every x minutes const intervalInMinutes = 5; setTimeout(() => { if (ctx.domElement.parentNode) insertNonCommercialUseHint(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 logNonCommercialUse(_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) { console.log(licenseText); } else { console.log("%c " + licenseText, style); } } async function sendUsageMessageToAnalyticsBackend(context: IContext) { // We can't send beacons from cross-origin isolated pages if (window.crossOriginIsolated) return; const licenseType = NEEDLE_ENGINE_LICENSE_TYPE; if (licenseType === "pro") { const attribute = context?.domElement?.getAttribute("no-telemetry"); if (attribute === "" || attribute === "true" || attribute === "1") { if (debug) console.debug("Telemetry is disabled"); return; } if (debug) console.debug("Telemetry attribute: " + attribute); } try { const analyticsUrl = "https:" + "//needle-engine-an" + "alytics-v2-r26roub2hq-lz" + ".a.run.app"; if (analyticsUrl) { // current url without query parameters const currentUrl = window.location.href.split("?")[0]; let endpoint = "api/v2/new/request"; if (!analyticsUrl.endsWith("/")) endpoint = "/" + endpoint; const license = NEEDLE_ENGINE_LICENSE_TYPE; const finalUrl = `${analyticsUrl}${endpoint}`; if (debug) console.debug("Sending beacon"); 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?.(finalUrl, 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); } }