feat: don't store to LS during collab (#2909)

This commit is contained in:
David Luzar 2021-02-03 19:14:26 +01:00 committed by GitHub
parent 02598c6163
commit ce507b0a0b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 223 additions and 239 deletions

View File

@ -19,12 +19,16 @@ import {
} from "../app_constants"; } from "../app_constants";
import { import {
decryptAESGEM, decryptAESGEM,
generateCollaborationLink, generateCollaborationLinkData,
getCollaborationLinkData, getCollaborationLink,
SocketUpdateDataSource, SocketUpdateDataSource,
SOCKET_SERVER, SOCKET_SERVER,
} from "../data"; } from "../data";
import { isSavedToFirebase, saveToFirebase } from "../data/firebase"; import {
isSavedToFirebase,
loadFromFirebase,
saveToFirebase,
} from "../data/firebase";
import { import {
importUsernameFromLocalStorage, importUsernameFromLocalStorage,
saveUsernameToLocalStorage, saveUsernameToLocalStorage,
@ -33,9 +37,9 @@ import {
import Portal from "./Portal"; import Portal from "./Portal";
import RoomDialog from "./RoomDialog"; import RoomDialog from "./RoomDialog";
import { createInverseContext } from "../../createInverseContext"; import { createInverseContext } from "../../createInverseContext";
import { t } from "../../i18n";
interface CollabState { interface CollabState {
isCollaborating: boolean;
modalIsShown: boolean; modalIsShown: boolean;
errorMessage: string; errorMessage: string;
username: string; username: string;
@ -45,7 +49,8 @@ interface CollabState {
type CollabInstance = InstanceType<typeof CollabWrapper>; type CollabInstance = InstanceType<typeof CollabWrapper>;
export interface CollabAPI { export interface CollabAPI {
isCollaborating: CollabState["isCollaborating"]; /** function so that we can access the latest value from stale callbacks */
isCollaborating: () => boolean;
username: CollabState["username"]; username: CollabState["username"];
onPointerUpdate: CollabInstance["onPointerUpdate"]; onPointerUpdate: CollabInstance["onPointerUpdate"];
initializeSocketClient: CollabInstance["initializeSocketClient"]; initializeSocketClient: CollabInstance["initializeSocketClient"];
@ -72,6 +77,8 @@ export { CollabContext, CollabContextConsumer };
class CollabWrapper extends PureComponent<Props, CollabState> { class CollabWrapper extends PureComponent<Props, CollabState> {
portal: Portal; portal: Portal;
excalidrawAPI: Props["excalidrawAPI"]; excalidrawAPI: Props["excalidrawAPI"];
isCollaborating: boolean = false;
private socketInitializationTimer?: NodeJS.Timeout; private socketInitializationTimer?: NodeJS.Timeout;
private lastBroadcastedOrReceivedSceneVersion: number = -1; private lastBroadcastedOrReceivedSceneVersion: number = -1;
private collaborators = new Map<string, Collaborator>(); private collaborators = new Map<string, Collaborator>();
@ -79,7 +86,6 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
constructor(props: Props) { constructor(props: Props) {
super(props); super(props);
this.state = { this.state = {
isCollaborating: false,
modalIsShown: false, modalIsShown: false,
errorMessage: "", errorMessage: "",
username: importUsernameFromLocalStorage() || "", username: importUsernameFromLocalStorage() || "",
@ -113,15 +119,16 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
} }
private onUnload = () => { private onUnload = () => {
this.destroySocketClient(); this.destroySocketClient({ isUnload: true });
}; };
private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => { private beforeUnload = withBatchedUpdates((event: BeforeUnloadEvent) => {
const syncableElements = getSyncableElements( const syncableElements = getSyncableElements(
this.getSceneElementsIncludingDeleted(), this.getSceneElementsIncludingDeleted(),
); );
if ( if (
this.state.isCollaborating && this.isCollaborating &&
!isSavedToFirebase(this.portal, syncableElements) !isSavedToFirebase(this.portal, syncableElements)
) { ) {
// this won't run in time if user decides to leave the site, but // this won't run in time if user decides to leave the site, but
@ -133,7 +140,7 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
event.returnValue = ""; event.returnValue = "";
} }
if (this.state.isCollaborating || this.portal.roomId) { if (this.isCollaborating || this.portal.roomId) {
try { try {
localStorage?.setItem( localStorage?.setItem(
STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG, STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
@ -159,143 +166,175 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
}; };
openPortal = async () => { openPortal = async () => {
window.history.pushState({}, APP_NAME, await generateCollaborationLink()); return this.initializeSocketClient(null);
const elements = this.excalidrawAPI.getSceneElements();
// remove deleted elements from elements array & history to ensure we don't
// expose potentially sensitive user data in case user manually deletes
// existing elements (or clears scene), which would otherwise be persisted
// to database even if deleted before creating the room.
this.excalidrawAPI.history.clear();
this.excalidrawAPI.updateScene({
elements,
commitToHistory: true,
});
return this.initializeSocketClient();
}; };
closePortal = () => { closePortal = () => {
this.saveCollabRoomToFirebase(); this.saveCollabRoomToFirebase();
window.history.pushState({}, APP_NAME, window.location.origin); if (window.confirm(t("alerts.collabStopOverridePrompt"))) {
this.destroySocketClient(); window.history.pushState({}, APP_NAME, window.location.origin);
this.destroySocketClient();
}
}; };
private destroySocketClient = () => { private destroySocketClient = (opts?: { isUnload: boolean }) => {
this.collaborators = new Map(); if (!opts?.isUnload) {
this.excalidrawAPI.updateScene({ this.collaborators = new Map();
collaborators: this.collaborators, this.excalidrawAPI.updateScene({
}); collaborators: this.collaborators,
this.setState({ });
isCollaborating: false, this.setState({
activeRoomLink: "", activeRoomLink: "",
}); });
this.isCollaborating = false;
}
this.portal.close(); this.portal.close();
}; };
private initializeSocketClient = async (): Promise<ImportedDataState | null> => { private initializeSocketClient = async (
existingRoomLinkData: null | { roomId: string; roomKey: string },
): Promise<ImportedDataState | null> => {
if (this.portal.socket) { if (this.portal.socket) {
return null; return null;
} }
const scenePromise = resolvablePromise<ImportedDataState | null>(); let roomId;
let roomKey;
const roomMatch = getCollaborationLinkData(window.location.href); if (existingRoomLinkData) {
({ roomId, roomKey } = existingRoomLinkData);
if (roomMatch) { } else {
const roomId = roomMatch[1]; ({ roomId, roomKey } = await generateCollaborationLinkData());
const roomKey = roomMatch[2]; window.history.pushState(
{},
// fallback in case you're not alone in the room but still don't receive APP_NAME,
// initial SCENE_UPDATE message getCollaborationLink({ roomId, roomKey }),
this.socketInitializationTimer = setTimeout(() => {
this.initializeSocket();
scenePromise.resolve(null);
}, INITIAL_SCENE_UPDATE_TIMEOUT);
const { default: socketIOClient }: any = await import(
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
); );
this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
// All socket listeners are moving to Portal
this.portal.socket!.on(
"client-broadcast",
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
if (!this.portal.roomKey) {
return;
}
const decryptedData = await decryptAESGEM(
encryptedData,
this.portal.roomKey,
iv,
);
switch (decryptedData.type) {
case "INVALID_RESPONSE":
return;
case SCENE.INIT: {
if (!this.portal.socketInitialized) {
const remoteElements = decryptedData.payload.elements;
const reconciledElements = this.reconcileElements(
remoteElements,
);
this.handleRemoteSceneUpdate(reconciledElements, {
init: true,
});
this.initializeSocket();
scenePromise.resolve({ elements: reconciledElements });
}
break;
}
case SCENE.UPDATE:
this.handleRemoteSceneUpdate(
this.reconcileElements(decryptedData.payload.elements),
);
break;
case "MOUSE_LOCATION": {
const {
pointer,
button,
username,
selectedElementIds,
} = decryptedData.payload;
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
decryptedData.payload.socketId ||
// @ts-ignore legacy, see #2094 (#2097)
decryptedData.payload.socketID;
const collaborators = new Map(this.collaborators);
const user = collaborators.get(socketId) || {}!;
user.pointer = pointer;
user.button = button;
user.selectedElementIds = selectedElementIds;
user.username = username;
collaborators.set(socketId, user);
this.excalidrawAPI.updateScene({
collaborators,
});
break;
}
}
},
);
this.portal.socket!.on("first-in-room", () => {
if (this.portal.socket) {
this.portal.socket.off("first-in-room");
}
this.initializeSocket();
scenePromise.resolve(null);
});
this.setState({
isCollaborating: true,
activeRoomLink: window.location.href,
});
return scenePromise;
} }
return null; const scenePromise = resolvablePromise<ImportedDataState | null>();
this.isCollaborating = true;
const { default: socketIOClient }: any = await import(
/* webpackChunkName: "socketIoClient" */ "socket.io-client"
);
this.portal.open(socketIOClient(SOCKET_SERVER), roomId, roomKey);
if (existingRoomLinkData) {
this.excalidrawAPI.resetScene();
try {
const elements = await loadFromFirebase(
roomId,
roomKey,
this.portal.socket,
);
if (elements) {
scenePromise.resolve({
elements,
});
}
} catch (error) {
// log the error and move on. other peers will sync us the scene.
console.error(error);
}
} else {
const elements = this.excalidrawAPI.getSceneElements();
// remove deleted elements from elements array & history to ensure we don't
// expose potentially sensitive user data in case user manually deletes
// existing elements (or clears scene), which would otherwise be persisted
// to database even if deleted before creating the room.
this.excalidrawAPI.history.clear();
this.excalidrawAPI.updateScene({
elements,
commitToHistory: true,
});
}
// fallback in case you're not alone in the room but still don't receive
// initial SCENE_UPDATE message
this.socketInitializationTimer = setTimeout(() => {
this.initializeSocket();
scenePromise.resolve(null);
}, INITIAL_SCENE_UPDATE_TIMEOUT);
// All socket listeners are moving to Portal
this.portal.socket!.on(
"client-broadcast",
async (encryptedData: ArrayBuffer, iv: Uint8Array) => {
if (!this.portal.roomKey) {
return;
}
const decryptedData = await decryptAESGEM(
encryptedData,
this.portal.roomKey,
iv,
);
switch (decryptedData.type) {
case "INVALID_RESPONSE":
return;
case SCENE.INIT: {
if (!this.portal.socketInitialized) {
this.initializeSocket();
const remoteElements = decryptedData.payload.elements;
const reconciledElements = this.reconcileElements(remoteElements);
this.handleRemoteSceneUpdate(reconciledElements, {
init: true,
});
// noop if already resolved via init from firebase
scenePromise.resolve({ elements: reconciledElements });
}
break;
}
case SCENE.UPDATE:
this.handleRemoteSceneUpdate(
this.reconcileElements(decryptedData.payload.elements),
);
break;
case "MOUSE_LOCATION": {
const {
pointer,
button,
username,
selectedElementIds,
} = decryptedData.payload;
const socketId: SocketUpdateDataSource["MOUSE_LOCATION"]["payload"]["socketId"] =
decryptedData.payload.socketId ||
// @ts-ignore legacy, see #2094 (#2097)
decryptedData.payload.socketID;
const collaborators = new Map(this.collaborators);
const user = collaborators.get(socketId) || {}!;
user.pointer = pointer;
user.button = button;
user.selectedElementIds = selectedElementIds;
user.username = username;
collaborators.set(socketId, user);
this.excalidrawAPI.updateScene({
collaborators,
});
break;
}
}
},
);
this.portal.socket!.on("first-in-room", () => {
if (this.portal.socket) {
this.portal.socket.off("first-in-room");
}
this.initializeSocket();
scenePromise.resolve(null);
});
this.setState({
activeRoomLink: window.location.href,
});
return scenePromise;
}; };
private initializeSocket = () => { private initializeSocket = () => {
@ -480,9 +519,11 @@ class CollabWrapper extends PureComponent<Props, CollabState> {
/** Getter of context value. Returned object is stable. */ /** Getter of context value. Returned object is stable. */
getContextValue = (): CollabAPI => { getContextValue = (): CollabAPI => {
this.contextValue = this.contextValue || ({} as CollabAPI); if (!this.contextValue) {
this.contextValue = {} as CollabAPI;
}
this.contextValue.isCollaborating = this.state.isCollaborating; this.contextValue.isCollaborating = () => this.isCollaborating;
this.contextValue.username = this.state.username; this.contextValue.username = this.state.username;
this.contextValue.onPointerUpdate = this.onPointerUpdate; this.contextValue.onPointerUpdate = this.onPointerUpdate;
this.contextValue.initializeSocketClient = this.initializeSocketClient; this.contextValue.initializeSocketClient = this.initializeSocketClient;

View File

@ -122,7 +122,7 @@ class Portal {
data as SocketUpdateData, data as SocketUpdateData,
); );
if (syncAll && this.collab.state.isCollaborating) { if (syncAll && this.collab.isCollaborating) {
await Promise.all([ await Promise.all([
broadcastPromise, broadcastPromise,
this.collab.saveCollabRoomToFirebase(syncableElements), this.collab.saveCollabRoomToFirebase(syncableElements),

View File

@ -148,6 +148,7 @@ export const saveToFirebase = async (
export const loadFromFirebase = async ( export const loadFromFirebase = async (
roomId: string, roomId: string,
roomKey: string, roomKey: string,
socket: SocketIOClient.Socket | null,
): Promise<readonly ExcalidrawElement[] | null> => { ): Promise<readonly ExcalidrawElement[] | null> => {
const firebase = await getFirebase(); const firebase = await getFirebase();
const db = firebase.firestore(); const db = firebase.firestore();
@ -160,5 +161,12 @@ export const loadFromFirebase = async (
const storedScene = doc.data() as FirebaseStoredScene; const storedScene = doc.data() as FirebaseStoredScene;
const ciphertext = storedScene.ciphertext.toUint8Array(); const ciphertext = storedScene.ciphertext.toUint8Array();
const iv = storedScene.iv.toUint8Array(); const iv = storedScene.iv.toUint8Array();
return restoreElements(await decryptElements(roomKey, iv, ciphertext));
const elements = await decryptElements(roomKey, iv, ciphertext);
if (socket) {
firebaseSceneVersionCache.set(socket, getSceneVersion(elements));
}
return restoreElements(elements);
}; };

View File

@ -125,17 +125,27 @@ export const decryptAESGEM = async (
}; };
export const getCollaborationLinkData = (link: string) => { export const getCollaborationLinkData = (link: string) => {
if (link.length === 0) {
return;
}
const hash = new URL(link).hash; const hash = new URL(link).hash;
return hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/); const match = hash.match(/^#room=([a-zA-Z0-9_-]+),([a-zA-Z0-9_-]+)$/);
return match ? { roomId: match[1], roomKey: match[2] } : null;
}; };
export const generateCollaborationLink = async () => { export const generateCollaborationLinkData = async () => {
const id = await generateRandomID(); const roomId = await generateRandomID();
const key = await generateEncryptionKey(); const roomKey = await generateEncryptionKey();
return `${window.location.origin}${window.location.pathname}#room=${id},${key}`;
if (!roomKey) {
throw new Error("Couldn't generate room key");
}
return { roomId, roomKey };
};
export const getCollaborationLink = (data: {
roomId: string;
roomKey: string;
}) => {
return `${window.location.origin}${window.location.pathname}#room=${data.roomId},${data.roomKey}`;
}; };
export const getImportedKey = (key: string, usage: KeyUsage) => export const getImportedKey = (key: string, usage: KeyUsage) =>

View File

@ -39,11 +39,9 @@ import CollabWrapper, {
} from "./collab/CollabWrapper"; } from "./collab/CollabWrapper";
import { LanguageList } from "./components/LanguageList"; import { LanguageList } from "./components/LanguageList";
import { exportToBackend, getCollaborationLinkData, loadScene } from "./data"; import { exportToBackend, getCollaborationLinkData, loadScene } from "./data";
import { loadFromFirebase } from "./data/firebase";
import { import {
importFromLocalStorage, importFromLocalStorage,
saveToLocalStorage, saveToLocalStorage,
STORAGE_KEYS,
} from "./data/localStorage"; } from "./data/localStorage";
const languageDetector = new LanguageDetector(); const languageDetector = new LanguageDetector();
@ -66,50 +64,9 @@ const onBlur = () => {
saveDebounced.flush(); saveDebounced.flush();
}; };
const shouldForceLoadScene = (
scene: ResolutionType<typeof loadScene>,
): boolean => {
if (!scene.elements.length) {
return true;
}
const roomMatch = getCollaborationLinkData(window.location.href);
if (!roomMatch) {
return false;
}
const roomId = roomMatch[1];
let collabForceLoadFlag;
try {
collabForceLoadFlag = localStorage?.getItem(
STORAGE_KEYS.LOCAL_STORAGE_KEY_COLLAB_FORCE_FLAG,
);
} catch {}
if (collabForceLoadFlag) {
try {
const {
room: previousRoom,
timestamp,
}: { room: string; timestamp: number } = JSON.parse(collabForceLoadFlag);
// if loading same room as the one previously unloaded within 15sec
// force reload without prompting
if (previousRoom === roomId && Date.now() - timestamp < 15000) {
return true;
}
} catch {}
}
return false;
};
type Scene = ImportedDataState & { commitToHistory: boolean };
const initializeScene = async (opts: { const initializeScene = async (opts: {
resetScene: ExcalidrawImperativeAPI["resetScene"]; collabAPI: CollabAPI;
initializeSocketClient: CollabAPI["initializeSocketClient"]; }): Promise<ImportedDataState | null> => {
}): Promise<Scene | null> => {
const searchParams = new URLSearchParams(window.location.search); const searchParams = new URLSearchParams(window.location.search);
const id = searchParams.get("id"); const id = searchParams.get("id");
const jsonMatch = window.location.hash.match( const jsonMatch = window.location.hash.match(
@ -120,20 +77,17 @@ const initializeScene = async (opts: {
let scene = await loadScene(null, null, initialData); let scene = await loadScene(null, null, initialData);
let isCollabScene = !!getCollaborationLinkData(window.location.href); let roomLinkData = getCollaborationLinkData(window.location.href);
const isExternalScene = !!(id || jsonMatch || isCollabScene); const isExternalScene = !!(id || jsonMatch || roomLinkData);
if (isExternalScene) { if (isExternalScene) {
if ( if (roomLinkData || window.confirm(t("alerts.loadSceneOverridePrompt"))) {
shouldForceLoadScene(scene) ||
window.confirm(t("alerts.loadSceneOverridePrompt"))
) {
// Backwards compatibility with legacy url format // Backwards compatibility with legacy url format
if (id) { if (id) {
scene = await loadScene(id, null, initialData); scene = await loadScene(id, null, initialData);
} else if (jsonMatch) { } else if (jsonMatch) {
scene = await loadScene(jsonMatch[1], jsonMatch[2], initialData); scene = await loadScene(jsonMatch[1], jsonMatch[2], initialData);
} }
if (!isCollabScene) { if (!roomLinkData) {
window.history.replaceState({}, APP_NAME, window.location.origin); window.history.replaceState({}, APP_NAME, window.location.origin);
} }
} else { } else {
@ -150,38 +104,12 @@ const initializeScene = async (opts: {
}); });
} }
isCollabScene = false; roomLinkData = null;
window.history.replaceState({}, APP_NAME, window.location.origin); window.history.replaceState({}, APP_NAME, window.location.origin);
} }
} }
if (isCollabScene) { if (roomLinkData) {
// when joining a room we don't want user's local scene data to be merged return opts.collabAPI.initializeSocketClient(roomLinkData);
// into the remote scene
opts.resetScene();
const scenePromise = opts.initializeSocketClient();
try {
const [, roomId, roomKey] = getCollaborationLinkData(
window.location.href,
)!;
const elements = await loadFromFirebase(roomId, roomKey);
if (elements) {
return {
elements,
commitToHistory: true,
};
}
return {
...(await scenePromise),
commitToHistory: true,
};
} catch (error) {
// log the error and move on. other peers will sync us the scene.
console.error(error);
}
return null;
} else if (scene) { } else if (scene) {
return scene; return scene;
} }
@ -242,24 +170,16 @@ function ExcalidrawWrapper() {
return; return;
} }
initializeScene({ initializeScene({ collabAPI }).then((scene) => {
resetScene: excalidrawAPI.resetScene,
initializeSocketClient: collabAPI.initializeSocketClient,
}).then((scene) => {
initialStatePromiseRef.current.promise.resolve(scene); initialStatePromiseRef.current.promise.resolve(scene);
}); });
const onHashChange = (_: HashChangeEvent) => { const onHashChange = (_: HashChangeEvent) => {
if (window.location.hash.length > 1) { initializeScene({ collabAPI }).then((scene) => {
initializeScene({ if (scene) {
resetScene: excalidrawAPI.resetScene, excalidrawAPI.updateScene(scene);
initializeSocketClient: collabAPI.initializeSocketClient, }
}).then((scene) => { });
if (scene) {
excalidrawAPI.updateScene(scene);
}
});
}
}; };
const titleTimeout = setTimeout( const titleTimeout = setTimeout(
@ -285,9 +205,13 @@ function ExcalidrawWrapper() {
elements: readonly ExcalidrawElement[], elements: readonly ExcalidrawElement[],
appState: AppState, appState: AppState,
) => { ) => {
saveDebounced(elements, appState); if (collabAPI?.isCollaborating()) {
if (collabAPI?.isCollaborating) {
collabAPI.broadcastElements(elements); collabAPI.broadcastElements(elements);
} else {
// collab scenes are persisted to the server, so we don't have to persist
// them locally, which has the added benefit of not overwriting whatever
// the user was working on before joining
saveDebounced(elements, appState);
} }
}; };
@ -352,7 +276,7 @@ function ExcalidrawWrapper() {
initialData={initialStatePromiseRef.current.promise} initialData={initialStatePromiseRef.current.promise}
user={{ name: collabAPI?.username }} user={{ name: collabAPI?.username }}
onCollabButtonClick={collabAPI?.onCollabButtonClick} onCollabButtonClick={collabAPI?.onCollabButtonClick}
isCollaborating={collabAPI?.isCollaborating} isCollaborating={collabAPI?.isCollaborating()}
onPointerUpdate={collabAPI?.onPointerUpdate} onPointerUpdate={collabAPI?.onPointerUpdate}
onExportToBackend={onExportToBackend} onExportToBackend={onExportToBackend}
renderFooter={renderFooter} renderFooter={renderFooter}

View File

@ -136,6 +136,7 @@
"decryptFailed": "Couldn't decrypt data.", "decryptFailed": "Couldn't decrypt data.",
"uploadedSecurly": "The upload has been secured with end-to-end encryption, which means that Excalidraw server and third parties can't read the content.", "uploadedSecurly": "The upload has been secured with end-to-end encryption, which means that Excalidraw server and third parties can't read the content.",
"loadSceneOverridePrompt": "Loading external drawing will replace your existing content. Do you wish to continue?", "loadSceneOverridePrompt": "Loading external drawing will replace your existing content. Do you wish to continue?",
"collabStopOverridePrompt": "Stopping the session will overwrite your previous, locally stored drawing. Are you sure?\n\n(If you want to keep your local drawing, simply close the browser tab instead.)",
"errorLoadingLibrary": "There was an error loading the third party library.", "errorLoadingLibrary": "There was an error loading the third party library.",
"confirmAddLibrary": "This will add {{numShapes}} shape(s) to your library. Are you sure?", "confirmAddLibrary": "This will add {{numShapes}} shape(s) to your library. Are you sure?",
"imageDoesNotContainScene": "Importing images isn't supported at the moment.\n\nDid you want to import a scene? This image does not seem to contain any scene data. Have you enabled this during export?", "imageDoesNotContainScene": "Importing images isn't supported at the moment.\n\nDid you want to import a scene? This image does not seem to contain any scene data. Have you enabled this during export?",