@needle-tools/engine 5.1.0 → 5.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +1 -1
  2. package/dist/{needle-engine.bundle-DAAfj8Cs.min.js → needle-engine.bundle-BUT4ua7U.min.js} +145 -145
  3. package/dist/{needle-engine.bundle-DvOrfXws.umd.cjs → needle-engine.bundle-Cb39lTJn.umd.cjs} +144 -144
  4. package/dist/{needle-engine.bundle-5oIL49YP.js → needle-engine.bundle-Dv-p5m4z.js} +5774 -5741
  5. package/dist/needle-engine.d.ts +11 -5
  6. package/dist/needle-engine.js +191 -191
  7. package/dist/needle-engine.min.js +1 -1
  8. package/dist/needle-engine.umd.cjs +1 -1
  9. package/lib/engine/api.d.ts +1 -1
  10. package/lib/engine/api.js +1 -1
  11. package/lib/engine/api.js.map +1 -1
  12. package/lib/engine/engine_init.js +2 -2
  13. package/lib/engine/engine_init.js.map +1 -1
  14. package/lib/engine/engine_license.d.ts +7 -7
  15. package/lib/engine/engine_license.js +72 -72
  16. package/lib/engine/engine_license.js.map +1 -1
  17. package/lib/engine/engine_networking_blob.js +3 -3
  18. package/lib/engine/engine_networking_blob.js.map +1 -1
  19. package/lib/engine/engine_networking_peer.js +19 -13
  20. package/lib/engine/engine_networking_peer.js.map +1 -1
  21. package/lib/engine/engine_utils_qrcode.js +2 -2
  22. package/lib/engine/engine_utils_qrcode.js.map +1 -1
  23. package/lib/engine/webcomponents/WebXRButtons.js +5 -6
  24. package/lib/engine/webcomponents/WebXRButtons.js.map +1 -1
  25. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js +2 -2
  26. package/lib/engine/webcomponents/needle menu/needle-menu-spatial.js.map +1 -1
  27. package/lib/engine/webcomponents/needle menu/needle-menu.js +5 -5
  28. package/lib/engine/webcomponents/needle menu/needle-menu.js.map +1 -1
  29. package/lib/engine/webcomponents/needle-engine.js +2 -2
  30. package/lib/engine/webcomponents/needle-engine.js.map +1 -1
  31. package/lib/engine/webcomponents/needle-engine.loading.js +2 -2
  32. package/lib/engine/webcomponents/needle-engine.loading.js.map +1 -1
  33. package/lib/engine/xr/NeedleXRSession.d.ts +7 -1
  34. package/lib/engine/xr/NeedleXRSession.js +70 -11
  35. package/lib/engine/xr/NeedleXRSession.js.map +1 -1
  36. package/lib/engine/xr/TempXRContext.js +2 -2
  37. package/lib/engine/xr/TempXRContext.js.map +1 -1
  38. package/lib/engine-components/export/usdz/USDZExporter.js +4 -4
  39. package/lib/engine-components/export/usdz/USDZExporter.js.map +1 -1
  40. package/lib/engine-components/webxr/WebXR.js +25 -14
  41. package/lib/engine-components/webxr/WebXR.js.map +1 -1
  42. package/package.json +2 -2
  43. package/plugins/common/license.js +4 -4
  44. package/plugins/vite/license.js +4 -4
  45. package/src/engine/api.ts +1 -1
  46. package/src/engine/engine_init.ts +2 -2
  47. package/src/engine/engine_license.ts +69 -69
  48. package/src/engine/engine_networking_blob.ts +3 -3
  49. package/src/engine/engine_networking_peer.ts +12 -13
  50. package/src/engine/engine_utils_qrcode.ts +2 -2
  51. package/src/engine/webcomponents/WebXRButtons.ts +5 -6
  52. package/src/engine/webcomponents/needle menu/needle-menu-spatial.ts +2 -2
  53. package/src/engine/webcomponents/needle menu/needle-menu.ts +5 -5
  54. package/src/engine/webcomponents/needle-engine.loading.ts +6 -6
  55. package/src/engine/webcomponents/needle-engine.ts +2 -2
  56. package/src/engine/xr/NeedleXRSession.ts +72 -14
  57. package/src/engine/xr/TempXRContext.ts +2 -2
  58. package/src/engine-components/export/usdz/USDZExporter.ts +4 -4
  59. package/src/engine-components/webxr/WebXR.ts +23 -15
@@ -23,6 +23,28 @@ import type { IXRRig } from "./XRRig.js";
23
23
  const measure_SessionStartedMarker = "NeedleXRSession onStart";
24
24
  const measure_SessionEndedMarker = "NeedleXRSession onEnd";
25
25
 
26
+ /**
27
+ * Whether the `xr-spatial-tracking` permission policy is allowed in the current document.
28
+ * WebXR APIs (isSessionSupported / requestSession) require it; an embedded page/iframe must
29
+ * be granted it via the iframe `allow` attribute (delegated from a parent that has it) and/or
30
+ * a `Permissions-Policy` response header. Returns true when the policy API is unavailable
31
+ * (can't determine → don't block, preserve prior behavior).
32
+ * @link https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe#allow
33
+ */
34
+ function isXrSpatialTrackingAllowed(): boolean {
35
+ try {
36
+ const doc = globalThis.document as (Document & { permissionsPolicy?: { allowsFeature(f: string): boolean }, featurePolicy?: { allowsFeature(f: string): boolean } }) | undefined;
37
+ const policy = doc?.permissionsPolicy ?? doc?.featurePolicy;
38
+ if (policy && typeof policy.allowsFeature === "function") {
39
+ return policy.allowsFeature("xr-spatial-tracking");
40
+ }
41
+ }
42
+ catch {
43
+ /* ignore — fall through to default */
44
+ }
45
+ return true;
46
+ }
47
+
26
48
 
27
49
  /** @link https://developer.mozilla.org/en-US/docs/Web/API/XRFrame/fillPoses */
28
50
  declare type FillPosesFunction = (spaces: IterableIterator<XRJointSpace>, referenceSpace: XRSpace, targetArray: Float32Array) => void;
@@ -85,6 +107,10 @@ async function handleSessionGranted() {
85
107
  let defaultMode: XRSessionMode = "immersive-vr";
86
108
 
87
109
  try {
110
+ // If xr-spatial-tracking isn't allowed in this document (e.g. an embed whose page/iframe
111
+ // wasn't granted it), XR can't run here — skip the probe so we don't log a
112
+ // Permissions-Policy violation on load.
113
+ if (!isXrSpatialTrackingAllowed()) return;
88
114
  // In app clips we default to AR
89
115
  if (DeviceUtilities.isNeedleAppClip()) {
90
116
  defaultMode = "immersive-ar";
@@ -304,7 +330,9 @@ export class NeedleXRSession implements INeedleXRSession {
304
330
  *
305
331
  * **IMPORTANT**: the resulting URL must exactly match an App Clip experience URL you have registered (with custom card metadata).
306
332
  * For this reason the URL is opened as-is, without appending any query parameters.
307
- * @link https://appclip.needle.tools
333
+ *
334
+ * Note: custom App Clip branding requires an active PRO subscription. See the iOS WebXR App Clip docs (Custom Branding for iOS AR).
335
+ * @link https://engine.needle.tools/docs/how-to-guides/xr/ios-webxr-app-clip#custom-branding-for-ios-ar
308
336
  */
309
337
  static get appClipUrl(): string | null { return this._appClipUrl; }
310
338
  static set appClipUrl(value: string | null) {
@@ -367,19 +395,49 @@ export class NeedleXRSession implements INeedleXRSession {
367
395
  * @param mode The XRSessionMode to check if it is supported
368
396
  * @returns true if the browser supports the given XRSessionMode
369
397
  */
370
- static isSessionSupported(mode: XRSessionMode) {
371
- // We cache the result of the session support check to avoid unnecessary calls to isSessionSupported. This is especially important for user input events that internally run session support checks during onclick where the event must be resolved as fast as possible.
372
- if (this._sessionSupportedCache[mode] !== undefined) {
373
- return Promise.resolve(this._sessionSupportedCache[mode]);
374
- }
375
- return this.xrSystem?.isSessionSupported(mode).then(supported => {
376
- this._sessionSupportedCache[mode] = supported;
377
- return supported;
378
- }).catch(err => { if (debug) console.error(err); return false }) ?? Promise.resolve(false);
398
+ static isSessionSupported(mode: XRSessionMode): Promise<boolean> {
399
+ // Re-probe when XR devices change (e.g. a headset connects).
400
+ this.ensureDeviceChangeInvalidation();
401
+
402
+ // Cache AND de-duplicate the check by storing the in-flight PROMISE (not just the
403
+ // resolved value). A burst of callers in the same tick (XR buttons, offer-session,
404
+ // support checks) then shares ONE underlying navigator.xr.isSessionSupported() call
405
+ // per mode. Each call otherwise repeats the `xr-spatial-tracking` Permissions-Policy
406
+ // check and floods the console. It also keeps user-input onclick checks fast.
407
+ const cached = this._sessionSupportedCache[mode];
408
+ if (cached !== undefined) return cached;
409
+
410
+ let result: Promise<boolean>;
411
+ // isSessionSupported() requires the `xr-spatial-tracking` permission policy to be
412
+ // allowed in this document. In an embed whose page/iframe wasn't granted it, calling
413
+ // the API logs a Permissions-Policy violation and can't succeed — so skip the call
414
+ // and report "unsupported" instead of spamming the console.
415
+ // See https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/iframe#allow
416
+ if (!isXrSpatialTrackingAllowed()) {
417
+ if (debug) console.warn("[WebXR] xr-spatial-tracking is not allowed in this document — reporting XR as unsupported. The embedding page/iframe must grant the policy.");
418
+ result = Promise.resolve(false);
419
+ }
420
+ else {
421
+ result = (this.xrSystem?.isSessionSupported(mode) ?? Promise.resolve(false))
422
+ .catch(err => { if (debug) console.error(err); return false; });
423
+ }
424
+ this._sessionSupportedCache[mode] = result;
425
+ return result;
426
+ }
427
+
428
+ private static _sessionSupportedCache: { [mode in XRSessionMode]?: Promise<boolean> } = {};
429
+ private static _deviceChangeHooked = false;
430
+ /** Invalidate the support cache when the set of XR devices changes, so a newly
431
+ * connected headset is picked up by the next isSessionSupported() call. */
432
+ private static ensureDeviceChangeInvalidation() {
433
+ if (this._deviceChangeHooked) return;
434
+ this._deviceChangeHooked = true;
435
+ try {
436
+ this.xrSystem?.addEventListener?.("devicechange", () => { this._sessionSupportedCache = {}; });
437
+ }
438
+ catch { /* xrSystem may not support events; cache simply won't be invalidated */ }
379
439
  }
380
440
 
381
- private static _sessionSupportedCache: { [mode in XRSessionMode]?: boolean } = {};
382
-
383
441
  private static _currentSessionRequest?: Promise<XRSession>;
384
442
  private static _activeSession: NeedleXRSession | null;
385
443
 
@@ -650,7 +708,7 @@ export class NeedleXRSession implements INeedleXRSession {
650
708
  // Setup VR initialization parameters
651
709
  case "immersive-ar":
652
710
  {
653
- const supported = await this.xrSystem?.isSessionSupported('immersive-ar')
711
+ const supported = await NeedleXRSession.isSessionSupported('immersive-ar')
654
712
  if (supported !== true) {
655
713
  console.error(mode + ' is not supported by this browser.');
656
714
  return null;
@@ -671,7 +729,7 @@ export class NeedleXRSession implements INeedleXRSession {
671
729
  // Setup AR initialization parameters
672
730
  case "immersive-vr":
673
731
  {
674
- const supported = await this.xrSystem?.isSessionSupported('immersive-vr')
732
+ const supported = await NeedleXRSession.isSessionSupported('immersive-vr')
675
733
  if (supported !== true) {
676
734
  console.error(mode + ' is not supported by this browser.');
677
735
  return null;
@@ -3,7 +3,7 @@ import { ArrayCamera, AxesHelper, Camera, Color, DirectionalLight, Fog, GridHelp
3
3
  import { needleLogoOnlySVG } from "../assets/index.js";
4
4
  import { isDevEnvironment } from "../debug/index.js";
5
5
  import { ObjectUtils, PrimitiveType } from "../engine_create_objects.js";
6
- import { $iujBhf } from "../engine_license.js";
6
+ import { $Mhcqc } from "../engine_license.js";
7
7
  import { Mathf } from "../engine_math.js";
8
8
  import { delay, DeviceUtilities } from "../engine_utils.js";
9
9
 
@@ -215,7 +215,7 @@ export class TemporaryXRContext {
215
215
  this._scene.background = new Color(0x000000);
216
216
 
217
217
  let logoSrc = needleLogoOnlySVG;
218
- if ($iujBhf()) {
218
+ if ($Mhcqc()) {
219
219
  const htmlComponent = document.querySelector("needle-engine");
220
220
  if (htmlComponent) {
221
221
  const licenseLogo = htmlComponent.getAttribute("logo-src");
@@ -3,7 +3,7 @@ import { Euler, Material, Matrix4, Mesh, Object3D, Quaternion, Vector3 } from "t
3
3
 
4
4
  import { isDevEnvironment, showBalloonMessage, showBalloonWarning } from "../../../engine/debug/index.js";
5
5
  import { findObjectOfType } from "../../../engine/engine_components.js";
6
- import { rXELCf } from "../../../engine/engine_license.js";
6
+ import { __dSkNu } from "../../../engine/engine_license.js";
7
7
  import { serializable } from "../../../engine/engine_serialization.js";
8
8
  import { getFormattedDate, Progress } from "../../../engine/engine_time_utils.js";
9
9
  import { DeviceUtilities, getParam } from "../../../engine/engine_utils.js";
@@ -277,7 +277,7 @@ export class USDZExporter extends Behaviour {
277
277
  let name = this.exportFileName ?? this.objectToExport?.name ?? this.name;
278
278
  name += "-" + getFormattedDate(); // seems iOS caches the file in some cases, this ensures we always have a fresh file
279
279
 
280
- if (!rXELCf()) {
280
+ if (!__dSkNu()) {
281
281
  if (name !== "") name += "-";
282
282
  name += "MadeWithNeedle";
283
283
  }
@@ -682,7 +682,7 @@ export class USDZExporter extends Behaviour {
682
682
  if (debug)
683
683
  showBalloonMessage("Quicklook url: " + callToActionURL);
684
684
  if (callToActionURL) {
685
- if (!rXELCf()) {
685
+ if (!__dSkNu()) {
686
686
  console.warn("Quicklook closed: custom redirects require a Needle Engine Pro license: https://needle.tools/pricing", callToActionURL)
687
687
  }
688
688
  else {
@@ -697,7 +697,7 @@ export class USDZExporter extends Behaviour {
697
697
  private buildQuicklookOverlay(): CustomBranding {
698
698
  const obj: CustomBranding = {};
699
699
  if (this.customBranding) Object.assign(obj, this.customBranding);
700
- if (!rXELCf()) {
700
+ if (!__dSkNu()) {
701
701
  console.log("Custom Quicklook banner text requires pro license: https://needle.tools/pricing");
702
702
  obj.callToAction = "Close";
703
703
  obj.checkoutTitle = "🌵 Made with Needle";
@@ -237,22 +237,30 @@ export class WebXR extends Behaviour {
237
237
  // Showing the QuickLook button depends on whether we're on iOS or visionOS –
238
238
  // on iOS we have AppClip support, so we don't need QuickLook button there,
239
239
  // while on visionOS we use QuickLook for AR experiences for now (unless it happens to have WebXR support by then).
240
- navigator.xr?.isSessionSupported("immersive-ar").catch(() => false).then((arSupported) => {
241
-
242
- const isVisionOSFallback = DeviceUtilities.isVisionOS() && !arSupported;
243
-
244
- if (this.useQuicklookExport || isVisionOSFallback) {
245
- const existingUSDZExporter = GameObject.findObjectOfType(USDZExporter);
246
- if (!existingUSDZExporter) {
247
- // if no USDZ Exporter is found we add one and assign the scene to be exported
248
- if (debug) console.log("WebXR: Adding USDZExporter");
249
- this._usdzExporter = GameObject.addComponent(this.gameObject, USDZExporter);
250
- this._usdzExporter.objectToExport = this.context.scene;
251
- this._usdzExporter.autoExportAnimations = true;
252
- this._usdzExporter.autoExportAudioSources = true;
253
- }
240
+ const addUsdzExporterIfMissing = () => {
241
+ const existingUSDZExporter = GameObject.findObjectOfType(USDZExporter);
242
+ if (!existingUSDZExporter) {
243
+ // if no USDZ Exporter is found we add one and assign the scene to be exported
244
+ if (debug) console.log("WebXR: Adding USDZExporter");
245
+ this._usdzExporter = GameObject.addComponent(this.gameObject, USDZExporter);
246
+ this._usdzExporter.objectToExport = this.context.scene;
247
+ this._usdzExporter.autoExportAnimations = true;
248
+ this._usdzExporter.autoExportAudioSources = true;
254
249
  }
255
- });
250
+ };
251
+ // QuickLook is added when explicitly requested, or as the visionOS fallback when
252
+ // WebXR AR isn't available. Only probe XR support on visionOS (the only case that
253
+ // consumes the result) and go through the cached NeedleXRSession.isARSupported() so
254
+ // we don't eagerly hit navigator.xr on every enable — that probe trips the
255
+ // `xr-spatial-tracking` Permissions-Policy in embeds that weren't granted it.
256
+ if (this.useQuicklookExport) {
257
+ addUsdzExporterIfMissing();
258
+ }
259
+ else if (DeviceUtilities.isVisionOS()) {
260
+ NeedleXRSession.isARSupported().then((arSupported) => {
261
+ if (!arSupported) addUsdzExporterIfMissing();
262
+ });
263
+ }
256
264
 
257
265
  this.handleCreatingHTML();
258
266
  this.handleOfferSession();