@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.
789 lines (684 loc) • 26.2 kB
JavaScript
// @ts-check
import { existsSync, readFileSync, readdirSync, openSync, readSync, closeSync } from 'fs';
import { join, relative } from 'path';
import { needleBlue, needleDim, needleLog, needleSupportsColor } from './logging.js';
/** @typedef {import('./local-files-types.js').LocalizationOptions} LocalizationOptions */
/** @typedef {import('./local-files-types.js').AutoPolicy} AutoPolicy */
/** @typedef {import('./local-files-types.js').SceneAnalysisReport} SceneAnalysisReport */
/** @typedef {import('./local-files-types.js').SceneFile} SceneFile */
/** @typedef {import('./local-files-types.js').UrlHandler} UrlHandler */
/** @typedef {import('./local-files-types.js').LocalizationContext} LocalizationContext */
const NEEDLE_COMPONENTS_EXTENSION = "NEEDLE_components";
export const SKYBOX_MAGIC_KEYWORDS = ["studio", "blurred-skybox", "quicklook", "quicklook-ar"];
const SKYBOX_BASE_URL = "https://cdn.needle.tools/static/skybox/";
const HLS_CDN_SEGMENT = "/npm/hls.js@";
/** @type {Map<string, {autoPolicy: AutoPolicy|null, report: SceneAnalysisReport, hasLogged: boolean}>} */
const analysisByProject = new Map();
/**
* @param {"build" | "serve"} command
* @param {unknown} _config
* @param {import('../types').userSettings} userSettings
* @returns {import('vite').Plugin | null}
*/
export function needleLocalFilesSceneAnalysis(command, _config, userSettings) {
if (!makeFilesLocalIsEnabled(userSettings)) return null;
const options = resolveOptions(userSettings);
let pluginRoot = process.cwd();
let pluginCommand = command;
return {
name: "needle:local-files-scene-analysis",
enforce: "pre",
/** @param {import('vite').ResolvedConfig} config */
configResolved(config) {
pluginRoot = config?.root || process.cwd();
pluginCommand = config?.command || command;
},
/** @param {import('vite').ViteDevServer} server */
configureServer(server) {
const projectDir = server?.config?.root || pluginRoot || process.cwd();
ensureProjectAnalysis(projectDir, options);
logSceneAnalysisReport(projectDir, "serve");
server?.httpServer?.once("close", () => {
logSceneAnalysisReport(projectDir, "serve");
analysisByProject.delete(projectDir);
});
},
buildStart() {
const projectDir = pluginRoot || process.cwd();
ensureProjectAnalysis(projectDir, options);
},
buildEnd() {
const projectDir = pluginRoot || process.cwd();
logSceneAnalysisReport(projectDir, "build");
analysisByProject.delete(projectDir);
},
closeBundle() {
const activeCommand = pluginCommand || command;
if (activeCommand !== "serve") return;
const projectDir = pluginRoot || process.cwd();
logSceneAnalysisReport(projectDir, "serve");
analysisByProject.delete(projectDir);
}
};
}
/**
* @param {string} projectDir
* @returns {AutoPolicy | null}
*/
export function getLocalFilesAutoPolicy(projectDir) {
const entry = analysisByProject.get(projectDir);
return entry?.autoPolicy ?? null;
}
/**
* @param {import('../types').userSettings | undefined | null} userSettings
* @returns {boolean}
*/
export function makeFilesLocalIsEnabled(userSettings) {
if (typeof userSettings?.makeFilesLocal === "object") return userSettings?.makeFilesLocal?.enabled === true;
if (userSettings?.makeFilesLocal === "auto") return true;
return userSettings?.makeFilesLocal === true;
}
/**
* @param {import('../types').userSettings | undefined | null} userSettings
* @returns {LocalizationOptions}
*/
export function resolveOptions(userSettings) {
if (typeof userSettings?.makeFilesLocal === "object") {
const raw = userSettings.makeFilesLocal;
const opts = /** @type {LocalizationOptions} */ ({ ...raw });
if (!opts.exclude?.length && opts.excludeUrls?.length) {
opts.exclude = opts.excludeUrls;
}
return opts;
}
if (userSettings?.makeFilesLocal === "auto") {
return { features: "auto" };
}
return {};
}
/**
* @param {string | string[] | undefined} features
* @returns {boolean}
*/
export function hasAutoFeatureSelection(features) {
if (features === "auto") return true;
if (Array.isArray(features)) return features.includes("auto");
return false;
}
/**
* @param {LocalizationOptions} options
* @param {string} projectDir
* @returns {AutoPolicy | null}
*/
export function buildAutoPolicy(options, projectDir) {
if (!hasAutoFeatureSelection(options.features)) return null;
return detectAutoPolicy(projectDir, options);
}
/**
* @param {string} projectDir
* @param {LocalizationOptions} options
* @returns {{ autoPolicy: AutoPolicy|null, report: SceneAnalysisReport, hasLogged: boolean }}
*/
function ensureProjectAnalysis(projectDir, options) {
const existing = analysisByProject.get(projectDir);
if (existing) return existing;
const report = analyzeProjectGlbs(projectDir);
const autoPolicy = hasAutoFeatureSelection(options.features)
? detectAutoPolicy(projectDir, options, report)
: null;
const entry = {
autoPolicy,
report,
hasLogged: false,
};
analysisByProject.set(projectDir, entry);
return entry;
}
/**
* @param {string} projectDir
* @param {string} mode
*/
function logSceneAnalysisReport(projectDir, mode) {
const entry = analysisByProject.get(projectDir);
if (!entry || entry.hasLogged) return;
const message = formatSceneAnalysisReport(entry.report, entry.autoPolicy, projectDir, mode);
needleLog("needle:local-files", message, "log", { dimBody: false });
entry.hasLogged = true;
}
/**
* @param {string} projectDir
* @param {LocalizationOptions} options
* @param {SceneAnalysisReport | null} [cachedAnalysis]
* @returns {AutoPolicy}
*/
export function detectAutoPolicy(projectDir, options, cachedAnalysis = null) {
const features = /** @type {Set<string>} */ (new Set());
features.add("draco");
features.add("ktx2");
features.add("fonts");
features.add("cdn-scripts");
features.add("needle-uploads");
features.add("needle-fonts");
features.add("needle-branding");
const pkgJsonPath = join(projectDir, "package.json");
/** @type {{dependencies?: Record<string,string>, devDependencies?: Record<string,string>} | null} */
let pkgJson = null;
try {
if (existsSync(pkgJsonPath)) {
pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
}
}
catch (_e) { }
if (pkgJson) {
const allDeps = { ...pkgJson.dependencies, ...pkgJson.devDependencies };
if (allDeps["@needle-tools/materialx"]) {
features.add("materialx");
}
}
const indexHtmlPath = join(projectDir, "index.html");
let indexHtml = "";
try {
if (existsSync(indexHtmlPath)) {
indexHtml = readFileSync(indexHtmlPath, "utf-8");
}
}
catch (_e) { }
const indexSkyboxUrls = collectIndexHtmlSkyboxUrls(indexHtml);
const selectedSkyboxUrls = resolveSkyboxSelectionUrls(options.skybox, indexSkyboxUrls);
if (options.skybox === "all") {
features.add("skybox");
}
if (selectedSkyboxUrls?.size) {
features.add("skybox");
}
const srcDir = join(projectDir, "src");
let srcContent = "";
try {
srcContent = collectSourceFiles(srcDir);
}
catch (_e) { }
const registerTypesPath = join(projectDir, "src", "generated", "register_types.ts");
let registerTypes = "";
try {
if (existsSync(registerTypesPath)) {
registerTypes = readFileSync(registerTypesPath, "utf-8");
}
}
catch (_e) { }
const allSrc = srcContent + "\n" + registerTypes + "\n" + indexHtml;
const sceneAnalysis = cachedAnalysis || analyzeProjectGlbs(projectDir);
const hasWebXRComponent = sceneAnalysis.hasWebXRComponent;
const hasVideoPlayerComponent = sceneAnalysis.hasVideoPlayerComponent;
if (hasWebXRComponent) {
features.add("xr");
}
if (/polyhaven\.org/i.test(allSrc)) {
features.add("polyhaven");
}
if (/raw\.githubusercontent\.com/i.test(allSrc)) {
features.add("github-content");
}
if (/threejs\.org\/examples\/models/i.test(allSrc)) {
features.add("threejs-models");
}
if (/cdn\.needle\.tools\/static\/models/i.test(allSrc)) {
features.add("needle-models");
}
if (/cdn\.needle\.tools\/static\/avatars/i.test(allSrc) || /\bAvatarModel\b|\bAvatarLoader\b/i.test(allSrc) || hasWebXRComponent) {
features.add("needle-avatars");
}
if (/materialx|MaterialXLoader/i.test(allSrc)) {
features.add("materialx");
}
if (Array.from(sceneAnalysis.extensions).some(ext => /materialx/i.test(ext))) {
features.add("materialx");
}
if (selectedSkyboxUrls && selectedSkyboxUrls.size > 0) {
features.add("skybox");
}
return {
features,
hasWebXR: hasWebXRComponent,
hasVideoPlayer: hasVideoPlayerComponent,
allowedSkyboxUrls: selectedSkyboxUrls,
selectedWebXRProfiles: getWebXRProfilesForMode(options.webxr),
};
}
/**
* @param {string} projectDir
* @returns {SceneAnalysisReport}
*/
export function analyzeProjectGlbs(projectDir) {
const sceneFiles = collectAssetSceneFiles(projectDir);
const extensions = /** @type {Set<string>} */ (new Set());
const componentTypes = /** @type {Set<string>} */ (new Set());
const componentCounts = /** @type {Map<string, number>} */ (new Map());
const needleExtensionBlobs = /** @type {string[]} */ ([]);
let hasWebXRComponent = false;
let hasVideoPlayerComponent = false;
let totalNodeCount = 0;
let totalVertexCount = 0;
let totalTextureCount = 0;
let totalMeshCount = 0;
let totalPrimitiveCount = 0;
let dracoPrimitiveCount = 0;
let meshoptBufferViewCount = 0;
for (const file of sceneFiles) {
try {
const json = /** @type {Record<string,any>|null} */ (file.type === "glb"
? readGlbJsonChunk(file.path)
: readGltfJsonFile(file.path));
if (!json || typeof json !== "object") continue;
const used = Array.isArray(json.extensionsUsed) ? json.extensionsUsed : [];
for (const ext of used) {
if (typeof ext === "string") extensions.add(ext);
}
if (json.extensions && typeof json.extensions === "object") {
for (const ext of Object.keys(json.extensions)) {
extensions.add(ext);
}
}
totalNodeCount += Array.isArray(json.nodes) ? json.nodes.length : 0;
totalTextureCount += Array.isArray(json.textures) ? json.textures.length : 0;
if (Array.isArray(json.bufferViews)) {
for (const view of json.bufferViews) {
const hasMeshopt = !!view?.extensions?.EXT_meshopt_compression;
if (hasMeshopt) meshoptBufferViewCount++;
}
}
const accessors = Array.isArray(json.accessors) ? json.accessors : [];
const meshes = Array.isArray(json.meshes) ? json.meshes : [];
totalMeshCount += meshes.length;
for (const mesh of meshes) {
const primitives = Array.isArray(mesh?.primitives) ? mesh.primitives : [];
totalPrimitiveCount += primitives.length;
for (const primitive of primitives) {
const hasDraco = !!primitive?.extensions?.KHR_draco_mesh_compression;
if (hasDraco) dracoPrimitiveCount++;
const positionAccessorIndex = getPrimitivePositionAccessorIndex(primitive);
if (positionAccessorIndex < 0) continue;
const accessor = accessors[positionAccessorIndex];
if (!accessor || typeof accessor.count !== "number") continue;
totalVertexCount += accessor.count;
}
}
const componentNames = collectNeedleComponentNames(json);
needleExtensionBlobs.push(...collectNeedleComponentExtensionBlobs(json));
for (const component of componentNames) {
componentTypes.add(component);
componentCounts.set(component, (componentCounts.get(component) || 0) + 1);
if (!hasWebXRComponent && /\bWebXR\b|\bXRRig\b|\bWebARSessionRoot\b/i.test(component)) {
hasWebXRComponent = true;
}
if (!hasVideoPlayerComponent && /\bVideoPlayer\b/i.test(component)) {
hasVideoPlayerComponent = true;
}
}
if ((!hasWebXRComponent || !hasVideoPlayerComponent) && componentNames.length > 0) {
const blob = componentNames.join(" ");
if (!hasWebXRComponent && /\bWebXR\b|\bXRRig\b|\bWebARSessionRoot\b/i.test(blob)) {
hasWebXRComponent = true;
}
if (!hasVideoPlayerComponent && /\bVideoPlayer\b/i.test(blob)) {
hasVideoPlayerComponent = true;
}
}
}
catch (_e) {
}
}
if (!hasWebXRComponent || !hasVideoPlayerComponent) {
for (const blob of needleExtensionBlobs) {
if (!hasWebXRComponent && /\bWebXR\b|\bXRRig\b|\bWebARSessionRoot\b/i.test(blob)) {
hasWebXRComponent = true;
}
if (!hasVideoPlayerComponent && /\bVideoPlayer\b/i.test(blob)) {
hasVideoPlayerComponent = true;
}
if (hasWebXRComponent && hasVideoPlayerComponent) break;
}
}
return {
hasWebXRComponent,
hasVideoPlayerComponent,
extensions,
componentTypes,
componentCounts,
glbFileCount: sceneFiles.filter(f => f.type === "glb").length,
gltfFileCount: sceneFiles.filter(f => f.type === "gltf").length,
totalSceneFileCount: sceneFiles.length,
totalNodeCount,
totalVertexCount,
totalTextureCount,
totalMeshCount,
totalPrimitiveCount,
dracoPrimitiveCount,
meshoptBufferViewCount,
};
}
/**
* @param {Record<string, any> | null | undefined} primitive
* @returns {number}
*/
function getPrimitivePositionAccessorIndex(primitive) {
const direct = primitive?.attributes?.POSITION;
if (typeof direct === "number") return direct;
const draco = primitive?.extensions?.KHR_draco_mesh_compression?.attributes?.POSITION;
if (typeof draco === "number") return draco;
const fallback = firstNumericValue(primitive?.attributes);
if (fallback >= 0) return fallback;
const dracoFallback = firstNumericValue(primitive?.extensions?.KHR_draco_mesh_compression?.attributes);
if (dracoFallback >= 0) return dracoFallback;
return -1;
}
/**
* @param {Record<string, unknown> | null | undefined} obj
* @returns {number}
*/
function firstNumericValue(obj) {
if (!obj || typeof obj !== "object") return -1;
const values = Object.values(obj);
for (const value of values) {
if (typeof value === "number") return value;
}
return -1;
}
/**
* @param {unknown} json
* @returns {string[]}
*/
function collectNeedleComponentNames(json) {
const names = new Set();
/** @param {unknown} node */
function visit(node) {
if (!node || typeof node !== "object") return;
if (Array.isArray(node)) {
for (const item of node) visit(item);
return;
}
for (const [key, value] of Object.entries(node)) {
if (key === NEEDLE_COMPONENTS_EXTENSION && value) {
collectComponentNamesFromNeedleExtension(value, names);
}
visit(value);
}
}
visit(json);
return Array.from(names);
}
/**
* @param {import('./local-files-types.js').NeedleComponentExtension | null | undefined} value
* @param {Set<string>} names
*/
function collectComponentNamesFromNeedleExtension(value, names) {
const builtinComponents = value?.builtin_components ?? [];
for (const entry of builtinComponents) {
const candidateName = getBuiltinComponentName(entry);
if (candidateName) names.add(candidateName);
}
}
/**
* @param {import('./local-files-types.js').NeedleComponentEntry | null | undefined} node
* @returns {string}
*/
function getBuiltinComponentName(node) {
if (!node || typeof node !== "object") return "";
if (typeof node.name === "string" && node.name.trim()) return node.name.trim();
return "";
}
/**
* @param {SceneAnalysisReport} report
* @param {AutoPolicy | null} autoPolicy
* @param {string} projectDir
* @param {string} _mode
* @returns {string}
*/
function formatSceneAnalysisReport(report, autoPolicy, projectDir, _mode) {
const supportsColor = needleSupportsColor();
const projectName = relative(process.cwd(), projectDir) || ".";
const componentList = report.componentCounts?.size > 0
? Array.from(report.componentCounts.entries())
.sort((a, b) => String(a[0]).localeCompare(String(b[0])))
.map(([name, count]) => {
const label = String(name);
if (!count || count <= 1) return label;
const suffix = ` ${count}×`;
return supportsColor ? `${label}${needleDim(suffix)}` : `${label}${suffix}`;
})
.join(", ")
: "(none)";
const extensionList = report.extensions.size > 0
? Array.from(report.extensions).sort((a, b) => a.localeCompare(b)).join(", ")
: "(none)";
const autoFeatures = autoPolicy?.features?.size
? Array.from(autoPolicy.features).sort((a, b) => a.localeCompare(b)).join(", ")
: "(n/a)";
const key = (text) => supportsColor ? needleBlue(text) : text;
const lines = [
"Scene analysis report",
key("Project") + " : " + projectName,
key("Scene files") + " : " + report.totalSceneFileCount + " (" + report.glbFileCount + " .glb, " + report.gltfFileCount + " .gltf)",
key("Nodes") + " : " + report.totalNodeCount,
key("Meshes / Primitives") + " : " + report.totalMeshCount + " / " + report.totalPrimitiveCount,
key("Vertices") + " : " + report.totalVertexCount,
key("Textures") + " : " + report.totalTextureCount,
key("Compression usage") + " : Draco primitives=" + report.dracoPrimitiveCount + ", Meshopt bufferViews=" + report.meshoptBufferViewCount,
key("Components") + " : " + componentList,
key("Extensions") + " : " + extensionList,
key("Detected Features") + " : " + autoFeatures,
];
return lines.join("\n");
}
/**
* @param {string} projectDir
* @returns {string[]}
*/
export function collectAssetGlbs(projectDir) {
const files = collectAssetSceneFiles(projectDir);
return files.filter(file => file.type === "glb").map(file => file.path);
}
/**
* @param {string} projectDir
* @returns {SceneFile[]}
*/
function collectAssetSceneFiles(projectDir) {
const assetsDir = join(projectDir, "assets");
if (!existsSync(assetsDir)) return [];
/** @type {SceneFile[]} */
const out = [];
/** @param {string} dir */
function walk(dir) {
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
walk(fullPath);
continue;
}
if (/^image_\d+_.*\.glb$/i.test(entry.name)) continue;
if (/^mesh_lod_\d+_.*\.glb$/i.test(entry.name)) continue;
if (/\.glb$/i.test(entry.name)) {
out.push({ path: fullPath, type: "glb" });
continue;
}
if (/\.gltf$/i.test(entry.name)) {
out.push({ path: fullPath, type: "gltf" });
continue;
}
}
}
walk(assetsDir);
return out;
}
/**
* @param {string} filePath
* @returns {unknown}
*/
function readGltfJsonFile(filePath) {
const text = readFileSync(filePath, "utf8");
return JSON.parse(text);
}
/**
* @param {string} filePath
* @returns {Record<string, unknown>}
*/
export function readGlbJsonChunk(filePath) {
const fd = openSync(filePath, "r");
try {
const header = Buffer.allocUnsafe(20);
const bytesRead = readSync(fd, header, 0, header.length, 0);
if (bytesRead < 20) throw new Error("Invalid GLB header: " + filePath);
const magic = header.readUInt32LE(0);
const version = header.readUInt32LE(4);
const chunkLength = header.readUInt32LE(12);
const chunkType = header.readUInt32LE(16);
if (magic !== 0x46546c67 || version !== 2) throw new Error("Not a GLB v2: " + filePath);
if (chunkType !== 0x4E4F534A) throw new Error("First GLB chunk is not JSON: " + filePath);
const jsonBuffer = Buffer.allocUnsafe(chunkLength);
const jsonBytesRead = readSync(fd, jsonBuffer, 0, chunkLength, 20);
if (jsonBytesRead < chunkLength) throw new Error("Failed to read GLB JSON chunk: " + filePath);
const jsonText = jsonBuffer.toString("utf8").replace(/\u0000+$/g, "");
return JSON.parse(jsonText);
}
finally {
closeSync(fd);
}
}
/**
* @param {unknown} json
* @returns {string[]}
*/
export function collectNeedleComponentExtensionBlobs(json) {
const blobs = /** @type {string[]} */ ([]);
/** @param {unknown} node */
function visit(node) {
if (!node || typeof node !== "object") return;
if (Array.isArray(node)) {
for (const item of node) visit(item);
return;
}
for (const [key, value] of Object.entries(node)) {
if (key === NEEDLE_COMPONENTS_EXTENSION && value) {
try {
blobs.push(JSON.stringify(value));
}
catch (_e) { }
}
visit(value);
}
}
visit(json);
return blobs;
}
/**
* @param {string} indexHtml
* @returns {Set<string>}
*/
export function collectIndexHtmlSkyboxUrls(indexHtml) {
const urls = new Set();
if (!indexHtml) return urls;
const attrRegex = /\b(background-image|environment-image)\s*=\s*["']([^"']+)["']/gi;
let match;
while ((match = attrRegex.exec(indexHtml)) !== null) {
const value = (match[2] || "").trim();
const resolved = resolveSkyboxValueToUrl(value);
if (resolved) urls.add(resolved);
}
return urls;
}
/**
* @param {string | string[] | undefined | null} skyboxOption
* @param {Set<string>} indexSkyboxUrls
* @returns {Set<string> | null}
*/
export function resolveSkyboxSelectionUrls(skyboxOption, indexSkyboxUrls) {
if (skyboxOption === "all") return null;
if (Array.isArray(skyboxOption)) {
const urls = new Set();
for (const entry of skyboxOption) {
const resolved = resolveSkyboxValueToUrl(entry);
if (resolved) urls.add(resolved);
}
return urls;
}
return indexSkyboxUrls;
}
/**
* @param {string | null | undefined} value
* @returns {string | null}
*/
export function resolveSkyboxValueToUrl(value) {
if (!value) return null;
if (/^https?:\/\//i.test(value)) return value;
const normalized = value.replace(/^\/+/, "");
if (/\.ktx2$/i.test(normalized)) return SKYBOX_BASE_URL + normalized;
if (/^[a-z0-9\-]+$/i.test(normalized)) {
return SKYBOX_BASE_URL + normalized + ".ktx2";
}
return null;
}
/**
* @param {string | undefined} mode
* @returns {string[]}
*/
export function getWebXRProfilesForMode(mode) {
const allProfiles = [
"generic-hand",
"generic-trigger",
"oculus-touch-v2",
"oculus-touch-v3",
"meta-quest-touch-pro",
"pico-4",
"pico-neo3",
];
if (mode === "minimal") return ["generic-hand", "generic-trigger"];
if (mode === "quest") return ["generic-hand", "generic-trigger", "oculus-touch-v2", "oculus-touch-v3", "meta-quest-touch-pro"];
if (mode === "pico") return ["generic-hand", "generic-trigger", "pico-4", "pico-neo3"];
return allProfiles;
}
/**
* @param {string} dir
* @param {number} [depth]
* @returns {string}
*/
function collectSourceFiles(dir, depth = 0) {
if (depth > 5) return "";
if (!existsSync(dir)) return "";
let content = "";
const entries = readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
if (entry.name === "node_modules" || entry.name === ".git") continue;
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
content += collectSourceFiles(fullPath, depth + 1);
}
else if (/\.(ts|js|tsx|jsx|html|vue|svelte)$/i.test(entry.name)) {
try {
content += readFileSync(fullPath, "utf-8") + "\n";
}
catch (_e) { }
}
}
return content;
}
/**
* @param {string} url
* @param {UrlHandler} handler
* @param {LocalizationContext} context
* @returns {boolean}
*/
export function shouldHandleUrlInAutoMode(url, handler, context) {
if (!hasAutoFeatureSelection(context.options.features) || !context.autoPolicy) return true;
if (handler.feature === "cdn-scripts" && url.includes(HLS_CDN_SEGMENT)) {
return context.autoPolicy.hasVideoPlayer;
}
if (handler.feature === "needle-avatars" && /\/static\/avatars\/default/i.test(url) && !context.autoPolicy.hasWebXR) {
return false;
}
if (handler.feature === "skybox") {
const allowed = context.autoPolicy.allowedSkyboxUrls;
if (!allowed) return true;
return allowed.has(url);
}
return true;
}