Skip to content

Commit

Permalink
Add autosave feature for Cloud projects (#5545)
Browse files Browse the repository at this point in the history
- Cloud projects are now autosaved on each game preview. Should the editor crash, it will help recover your project.
- The saved project is stored on the device for performance reasons.
- Warning: if you're using GDevelop online on a public computer, this feature saves a copy of your project in the browser storage. To make sure no one can access it, make sure to log out the editor when you leave the computer.
  • Loading branch information
AlexandreSi authored Aug 8, 2023
1 parent 8766f73 commit fb6e09d
Show file tree
Hide file tree
Showing 9 changed files with 154 additions and 28 deletions.
29 changes: 19 additions & 10 deletions newIDE/app/src/MainFrame/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -761,7 +761,6 @@ const MainFrame = (props: Props) => {
...fileMetadata,
name: project.getName(),
gameId: project.getProjectUuid(),
lastModifiedDate: Date.now(),
},
storageProviderName: storageProvider.internalName,
});
Expand Down Expand Up @@ -859,7 +858,7 @@ const MainFrame = (props: Props) => {
const storageProviderOperations = getStorageProviderOperations();

const {
hasAutoSave,
getAutoSaveCreationDate,
onGetAutoSave,
onOpen,
getOpenErrorMessage,
Expand All @@ -875,16 +874,21 @@ const MainFrame = (props: Props) => {
}

const checkForAutosave = async (): Promise<FileMetadata> => {
if (!hasAutoSave || !onGetAutoSave) {
if (!getAutoSaveCreationDate || !onGetAutoSave) {
return fileMetadata;
}

const canOpenAutosave = await hasAutoSave(fileMetadata, true);
if (!canOpenAutosave) return fileMetadata;
const autoSaveCreationDate = await getAutoSaveCreationDate(
fileMetadata,
true
);
if (!autoSaveCreationDate) return fileMetadata;

const answer = await showConfirmation({
title: t`This project has an auto-saved version`,
message: t`GDevelop automatically saved a newer version of this project. This new version might differ from the one that you manually saved. Which version would you like to open?`,
message: t`GDevelop automatically saved a newer version of this project on ${new Date(
autoSaveCreationDate
).toLocaleString()}. This new version might differ from the one that you manually saved. Which version would you like to open?`,
dismissButtonLabel: t`My manual save`,
confirmButtonLabel: t`GDevelop auto-save`,
});
Expand All @@ -894,16 +898,21 @@ const MainFrame = (props: Props) => {
};

const checkForAutosaveAfterFailure = async (): Promise<?FileMetadata> => {
if (!hasAutoSave || !onGetAutoSave) {
if (!getAutoSaveCreationDate || !onGetAutoSave) {
return null;
}

const canOpenAutosave = await hasAutoSave(fileMetadata, false);
if (!canOpenAutosave) return null;
const autoSaveCreationDate = await getAutoSaveCreationDate(
fileMetadata,
false
);
if (!autoSaveCreationDate) return null;

const answer = await showConfirmation({
title: t`This project cannot be opened`,
message: t`The project file appears to be corrupted, but an autosave file exists (backup made automatically by GDevelop). Would you like to try to load it instead?`,
message: t`The project file appears to be corrupted, but an autosave file exists (backup made automatically by GDevelop on ${new Date(
autoSaveCreationDate
).toLocaleString()}). Would you like to try to load it instead?`,
confirmButtonLabel: t`Load autosave`,
});
if (!answer) return null;
Expand Down
12 changes: 10 additions & 2 deletions newIDE/app/src/Profile/AuthenticatedUserProvider.js
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import AdditionalUserInfoDialog, {
import { Trans } from '@lingui/macro';
import Snackbar from '@material-ui/core/Snackbar';
import RequestDeduplicator from '../Utils/RequestDeduplicator';
import { burstCloudProjectAutoSaveCache } from '../ProjectsStorage/CloudStorageProvider/CloudProjectOpener';

type Props = {|
authentication: Authentication,
Expand Down Expand Up @@ -80,6 +81,13 @@ type State = {|
userSnackbarMessage: ?React.Node,
|};

const cleanUserTracesOnDevice = async () => {
await Promise.all([
clearCloudProjectCookies(),
burstCloudProjectAutoSaveCache(),
]);
};

export default class AuthenticatedUserProvider extends React.Component<
Props,
State
Expand Down Expand Up @@ -635,7 +643,7 @@ export default class AuthenticatedUserProvider extends React.Component<
await this.props.authentication.logout();
}
this._markAuthenticatedUserAsLoggedOut();
clearCloudProjectCookies();
cleanUserTracesOnDevice();
this.showUserSnackbar({
message: <Trans>You're now logged out</Trans>,
});
Expand Down Expand Up @@ -767,7 +775,7 @@ export default class AuthenticatedUserProvider extends React.Component<
try {
await authentication.deleteAccount(authentication.getAuthorizationHeader);
this._markAuthenticatedUserAsLoggedOut();
clearCloudProjectCookies();
cleanUserTracesOnDevice();
this.openEditProfileDialog(false);
this.showUserSnackbar({
message: <Trans>Your account has been deleted!</Trans>,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,12 @@ import { type AuthenticatedUser } from '../../Profile/AuthenticatedUserContext';
import { type FileMetadata } from '..';
import { unzipFirstEntryOfBlob } from '../../Utils/Zip.js/Utils';

export const CLOUD_PROJECT_AUTOSAVE_CACHE_KEY =
'gdevelop-cloud-project-autosave';
const CLOUD_PROJECT_AUTOSAVE_PREFIX = 'cache-autosave:';
export const isCacheApiAvailable =
typeof window !== 'undefined' && 'caches' in window;

class CloudProjectReadingError extends Error {
constructor() {
super();
Expand All @@ -31,6 +37,30 @@ export const generateOnOpen = (authenticatedUser: AuthenticatedUser) => async (
|}> => {
const cloudProjectId = fileMetadata.fileIdentifier;

if (cloudProjectId.startsWith(CLOUD_PROJECT_AUTOSAVE_PREFIX)) {
if (!isCacheApiAvailable) throw new Error('Cache API is not available.');
const { profile } = authenticatedUser;
if (!profile) {
throw new Error(
'User seems not to be logged in. Cannot retrieve autosaved filed from cache.'
);
}
const hasCache = await caches.has(CLOUD_PROJECT_AUTOSAVE_CACHE_KEY);
if (!hasCache) {
throw new Error('Cloud project autosave cache could not be retrieved.');
}
const cloudProjectId = fileMetadata.fileIdentifier.replace(
CLOUD_PROJECT_AUTOSAVE_PREFIX,
''
);
const cache = await caches.open(CLOUD_PROJECT_AUTOSAVE_CACHE_KEY);
const cacheKey = `${profile.id}/${cloudProjectId}`;
const cachedResponse = await cache.match(cacheKey);
const cachedResponseBody = await cachedResponse.text();
const cachedSerializedProject = JSON.parse(cachedResponseBody).project;
return { content: JSON.parse(cachedSerializedProject) };
}

onProgress && onProgress((1 / 4) * 100, t`Calibrating sensors`);
const cloudProject = await getCloudProject(authenticatedUser, cloudProjectId);
if (!cloudProject) throw new Error("Cloud project couldn't be fetched.");
Expand Down Expand Up @@ -66,3 +96,50 @@ export const generateOnEnsureCanAccessResources = (
const cloudProjectId = fileMetadata.fileIdentifier;
await getCredentialsForCloudProject(authenticatedUser, cloudProjectId);
};

export const generateGetAutoSaveCreationDate = (
authenticatedUser: AuthenticatedUser
) =>
isCacheApiAvailable
? async (
fileMetadata: FileMetadata,
compareLastModified: boolean
): Promise<?number> => {
const { profile } = authenticatedUser;
if (!profile) return null;

const hasCache = await caches.has(CLOUD_PROJECT_AUTOSAVE_CACHE_KEY);
if (!hasCache) return null;

const cloudProjectId = fileMetadata.fileIdentifier;
const cache = await caches.open(CLOUD_PROJECT_AUTOSAVE_CACHE_KEY);
const cacheKey = `${profile.id}/${cloudProjectId}`;
const cachedResponse = await cache.match(cacheKey);
if (!cachedResponse) return null;

const cachedResponseBody = await cachedResponse.text();
const autoSavedTime = JSON.parse(cachedResponseBody).createdAt;
if (!compareLastModified) return autoSavedTime;

const saveTime = fileMetadata.lastModifiedDate;
if (!saveTime) return null;

return autoSavedTime > saveTime + 5000 ? autoSavedTime : null;
}
: undefined;

export const generateOnGetAutoSave = (authenticatedUser: AuthenticatedUser) =>
isCacheApiAvailable
? async (fileMetadata: FileMetadata): Promise<FileMetadata> => {
return {
...fileMetadata,
fileIdentifier:
CLOUD_PROJECT_AUTOSAVE_PREFIX + fileMetadata.fileIdentifier,
};
}
: undefined;

export const burstCloudProjectAutoSaveCache = async () => {
if (!isCacheApiAvailable) return;
await caches.delete(CLOUD_PROJECT_AUTOSAVE_CACHE_KEY);
};
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import {
createZipWithSingleTextFile,
unzipFirstEntryOfBlob,
} from '../../Utils/Zip.js/Utils';
import {
CLOUD_PROJECT_AUTOSAVE_CACHE_KEY,
isCacheApiAvailable,
} from './CloudProjectOpener';

const zipProject = async (project: gdProject): Promise<[Blob, string]> => {
const projectJson = serializeToJSON(project);
Expand Down Expand Up @@ -85,9 +89,12 @@ export const generateOnSaveProject = (
// Do not throw, as this is not a blocking error.
}
}
const newFileMetadata = {
const newFileMetadata: FileMetadata = {
...fileMetadata,
gameId: project.getProjectUuid(),
// lastModifiedDate is not set since it will be set by backend services
// and then frontend will use it to transform the list of cloud project
// items into a list of FileMetadata.
};
const newVersion = await zipProjectAndCommitVersion({
authenticatedUser,
Expand Down Expand Up @@ -274,3 +281,25 @@ export const onRenderNewProjectSaveAsLocationChooser = ({

return null;
};

export const generateOnAutoSaveProject = (
authenticatedUser: AuthenticatedUser
) =>
isCacheApiAvailable
? async (project: gdProject, fileMetadata: FileMetadata): Promise<void> => {
const { profile } = authenticatedUser;
if (!profile) return;
const cloudProjectId = fileMetadata.fileIdentifier;
const cache = await caches.open(CLOUD_PROJECT_AUTOSAVE_CACHE_KEY);
const cacheKey = `${profile.id}/${cloudProjectId}`;
cache.put(
cacheKey,
new Response(
JSON.stringify({
project: serializeToJSON(project),
createdAt: Date.now(),
})
)
);
}
: undefined;
6 changes: 6 additions & 0 deletions newIDE/app/src/ProjectsStorage/CloudStorageProvider/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
generateOnSaveProjectAs,
getWriteErrorMessage,
onRenderNewProjectSaveAsLocationChooser,
generateOnAutoSaveProject,
} from './CloudProjectWriter';
import {
type AppArguments,
Expand All @@ -18,6 +19,8 @@ import { type MessageDescriptor } from '../../Utils/i18n/MessageDescriptor.flow'
import {
generateOnOpen,
generateOnEnsureCanAccessResources,
generateGetAutoSaveCreationDate,
generateOnGetAutoSave,
} from './CloudProjectOpener';
import Cloud from '../../UI/CustomSvgIcons/Cloud';
import { generateGetResourceActions } from './CloudProjectResourcesHandler';
Expand Down Expand Up @@ -66,6 +69,9 @@ export default ({
setDialog,
closeDialog
),
onAutoSaveProject: generateOnAutoSaveProject(authenticatedUser),
getAutoSaveCreationDate: generateGetAutoSaveCreationDate(authenticatedUser),
onGetAutoSave: generateOnGetAutoSave(authenticatedUser),
onChangeProjectProperty: generateOnChangeProjectProperty(authenticatedUser),
getOpenErrorMessage: (error: Error): MessageDescriptor => {
return t`An error occurred when opening the project. Check that your internet connection is working and that your browser allows the use of cookies.`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,33 +65,30 @@ export const onOpen = (
});
};

export const hasAutoSave = async (
export const getAutoSaveCreationDate = async (
fileMetadata: FileMetadata,
compareLastModified: boolean
): Promise<boolean> => {
): Promise<?number> => {
const filePath = fileMetadata.fileIdentifier;
const autoSavePath = filePath + '.autosave';
if (fs.existsSync(autoSavePath)) {
const autoSavedTime = fs.statSync(autoSavePath).mtime.getTime();
if (!compareLastModified) {
return true;
return autoSavedTime;
}
try {
const autoSavedTime = fs.statSync(autoSavePath).mtime.getTime();
const saveTime = fs.statSync(filePath).mtime.getTime();
// When comparing the last modified time, add a 5 seconds margin to avoid
// showing the warning if the user has just saved the project, or if the
// project has been decompressed from a zip file, causing the last modified
// time to be the time of decompression.
if (autoSavedTime > saveTime + 5000) {
return true;
}
return autoSavedTime > saveTime + 5000 ? autoSavedTime : null;
} catch (err) {
console.error('Unable to compare *.autosave to project', err);
return false;
return null;
}
return false;
}
return false;
return null;
};

export const onGetAutoSave = (fileMetadata: FileMetadata) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { type StorageProvider } from '../index';
import {
onOpenWithPicker,
onOpen,
hasAutoSave,
getAutoSaveCreationDate,
onGetAutoSave,
} from './LocalProjectOpener';
import {
Expand Down Expand Up @@ -51,7 +51,7 @@ export default ({
createOperations: () => ({
onOpenWithPicker,
onOpen,
hasAutoSave,
getAutoSaveCreationDate,
onSaveProject,
onChooseSaveProjectAsLocation,
onSaveProjectAs,
Expand Down
4 changes: 2 additions & 2 deletions newIDE/app/src/ProjectsStorage/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,10 @@ export type StorageProviderOperations = {|
project: gdProject,
fileMetadata: FileMetadata
) => Promise<void>,
hasAutoSave?: (
getAutoSaveCreationDate?: (
fileMetadata: FileMetadata,
compareLastModified: boolean
) => Promise<boolean>,
) => Promise<?number>,
onGetAutoSave?: (fileMetadata: FileMetadata) => Promise<FileMetadata>,
|};

Expand Down
2 changes: 1 addition & 1 deletion newIDE/app/src/setupTests.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,4 @@ beforeAll(done => {
});

// We increase the timeout for CIs (the default 5s can be too low sometimes, as a real browser is involved).
jest.setTimeout(10000)
jest.setTimeout(10000);

0 comments on commit fb6e09d

Please sign in to comment.