diff --git a/electron/achievements.js b/electron/achievements.js index ad2fb8dcc..26bfc7a6c 100644 --- a/electron/achievements.js +++ b/electron/achievements.js @@ -9,7 +9,7 @@ async function enableAchievementsInterval(window) { // This is backward but the game fills in an array called `document.achievements` and we retrieve it from // here. Hey if it works it works. const steamAchievements = greenworks.getAchievementNames(); - log.debug(`All Steam achievements ${JSON.stringify(steamAchievements)}`); + log.silly(`All Steam achievements ${JSON.stringify(steamAchievements)}`); const playerAchieved = (await Promise.all(steamAchievements.map(checkSteamAchievement))).filter(name => !!name); log.debug(`Player has Steam achievements ${JSON.stringify(playerAchieved)}`); const intervalID = setInterval(async () => { diff --git a/electron/gameWindow.js b/electron/gameWindow.js index fb14326d4..b73735509 100644 --- a/electron/gameWindow.js +++ b/electron/gameWindow.js @@ -27,6 +27,7 @@ async function createWindow(killall) { backgroundColor: "#000000", webPreferences: { nativeWindowOpen: true, + preload: path.join(__dirname, 'preload.js'), }, }); diff --git a/electron/main.js b/electron/main.js index b7135c38b..0fb9eae67 100644 --- a/electron/main.js +++ b/electron/main.js @@ -1,12 +1,19 @@ /* eslint-disable no-process-exit */ /* eslint-disable @typescript-eslint/no-var-requires */ -const { app, dialog, BrowserWindow } = require("electron"); +const { app, dialog, BrowserWindow, ipcMain } = require("electron"); const log = require("electron-log"); const greenworks = require("./greenworks"); const api = require("./api-server"); const gameWindow = require("./gameWindow"); const achievements = require("./achievements"); const utils = require("./utils"); +const storage = require("./storage"); +const debounce = require("lodash/debounce"); +const Config = require("electron-config"); +const config = new Config(); + +log.transports.file.level = config.get("file-log-level", "info"); +log.transports.console.level = config.get("console-log-level", "debug"); log.catchErrors(); log.info(`Started app: ${JSON.stringify(process.argv)}`); @@ -30,6 +37,8 @@ try { global.greenworksError = ex.message; } +let isRestoreDisabled = false; + function setStopProcessHandler(app, window, enabled) { const closingWindowHandler = async (e) => { // We need to prevent the default closing event to add custom logic @@ -41,6 +50,18 @@ function setStopProcessHandler(app, window, enabled) { // Shutdown the http server api.disable(); + // Trigger debounced saves right now before closing + try { + await saveToDisk.flush(); + } catch (error) { + log.error(error); + } + try { + await saveToCloud.flush(); + } catch (error) { + log.error(error); + } + // Because of a steam limitation, if the player has launched an external browser, // steam will keep displaying the game as "Running" in their UI as long as the browser is up. // So we'll alert the player to close their browser. @@ -87,13 +108,98 @@ function setStopProcessHandler(app, window, enabled) { process.exit(0); }; + const receivedGameReadyHandler = async (event, arg) => { + if (!window) { + log.warn("Window was undefined in game info handler"); + return; + } + + log.debug("Received game information", arg); + window.gameInfo = { ...arg }; + await storage.prepareSaveFolders(window); + + const restoreNewest = config.get("onload-restore-newest", true); + if (restoreNewest && !isRestoreDisabled) { + try { + await storage.restoreIfNewerExists(window) + } catch (error) { + log.error("Could not restore newer file", error); + } + } + } + + const receivedDisableRestoreHandler = async (event, arg) => { + if (!window) { + log.warn("Window was undefined in disable import handler"); + return; + } + + log.debug(`Disabling auto-restore for ${arg.duration}ms.`); + isRestoreDisabled = true; + setTimeout(() => { + isRestoreDisabled = false; + log.debug("Re-enabling auto-restore"); + }, arg.duration); + } + + const receivedGameSavedHandler = async (event, arg) => { + if (!window) { + log.warn("Window was undefined in game saved handler"); + return; + } + + const { save, ...other } = arg; + log.silly("Received game saved info", {...other, save: `${save.length} bytes`}); + + if (storage.isAutosaveEnabled()) { + saveToDisk(save, arg.fileName); + } + if (storage.isCloudEnabled()) { + const minimumPlaytime = 1000 * 60 * 15; + const playtime = window.gameInfo.player.playtime; + log.silly(window.gameInfo); + if (playtime > minimumPlaytime) { + saveToCloud(save); + } else { + log.debug(`Auto-save to cloud disabled for save game under ${minimumPlaytime}ms (${playtime}ms)`); + } + } + } + + const saveToCloud = debounce(async (save) => { + log.debug("Saving to Steam Cloud ...") + try { + const playerId = window.gameInfo.player.identifier; + await storage.pushGameSaveToSteamCloud(save, playerId); + log.silly("Saved Game to Steam Cloud"); + } catch (error) { + log.error(error); + utils.writeToast(window, "Could not save to Steam Cloud.", "error", 5000); + } + }, config.get("cloud-save-min-time", 1000 * 60 * 15), { leading: true }); + + const saveToDisk = debounce(async (save, fileName) => { + log.debug("Saving to Disk ...") + try { + const file = await storage.saveGameToDisk(window, { save, fileName }); + log.silly(`Saved Game to '${file.replaceAll('\\', '\\\\')}'`); + } catch (error) { + log.error(error); + utils.writeToast(window, "Could not save to disk", "error", 5000); + } + }, config.get("disk-save-min-time", 1000 * 60 * 5), { leading: true }); + if (enabled) { - log.debug('Adding closing handlers'); + log.debug("Adding closing handlers"); + ipcMain.on("push-game-ready", receivedGameReadyHandler); + ipcMain.on("push-game-saved", receivedGameSavedHandler); + ipcMain.on("push-disable-restore", receivedDisableRestoreHandler) window.on("closed", clearWindowHandler); window.on("close", closingWindowHandler) app.on("window-all-closed", stopProcessHandler); } else { - log.debug('Removing closing handlers'); + log.debug("Removing closing handlers"); + ipcMain.removeAllListeners(); window.removeListener("closed", clearWindowHandler); window.removeListener("close", closingWindowHandler); app.removeListener("window-all-closed", stopProcessHandler); @@ -110,7 +216,7 @@ global.app_handlers = { } app.whenReady().then(async () => { - log.info('Application is ready!'); + log.info("Application is ready!"); if (process.argv.includes("--export-save")) { const window = new BrowserWindow({ show: false }); diff --git a/electron/menu.js b/electron/menu.js index e51fd29bf..637f197e5 100644 --- a/electron/menu.js +++ b/electron/menu.js @@ -1,11 +1,169 @@ /* eslint-disable @typescript-eslint/no-var-requires */ -const { Menu, clipboard, dialog } = require("electron"); +const { app, Menu, clipboard, dialog, shell } = require("electron"); const log = require("electron-log"); +const Config = require("electron-config"); const api = require("./api-server"); const utils = require("./utils"); +const storage = require("./storage"); +const config = new Config(); function getMenu(window) { return Menu.buildFromTemplate([ + { + label: "File", + submenu: [ + { + label: "Save Game", + click: () => window.webContents.send("trigger-save"), + }, + { + label: "Export Save", + click: () => window.webContents.send("trigger-game-export"), + }, + { + label: "Export Scripts", + click: async () => window.webContents.send("trigger-scripts-export"), + }, + { + type: "separator", + }, + { + label: "Load Last Save", + click: async () => { + try { + const saveGame = await storage.loadLastFromDisk(window); + window.webContents.send("push-save-request", { save: saveGame }); + } catch (error) { + log.error(error); + utils.writeToast(window, "Could not load last save from disk", "error", 5000); + } + } + }, + { + label: "Load From File", + click: async () => { + const defaultPath = await storage.getSaveFolder(window); + const result = await dialog.showOpenDialog(window, { + title: "Load From File", + defaultPath: defaultPath, + buttonLabel: "Load", + filters: [ + { name: "Game Saves", extensions: ["json", "json.gz", "txt"] }, + { name: "All", extensions: ["*"] }, + ], + properties: [ + "openFile", "dontAddToRecent", + ] + }); + if (result.canceled) return; + const file = result.filePaths[0]; + + try { + const saveGame = await storage.loadFileFromDisk(file); + window.webContents.send("push-save-request", { save: saveGame }); + } catch (error) { + log.error(error); + utils.writeToast(window, "Could not load save from disk", "error", 5000); + } + } + }, + { + label: "Load From Steam Cloud", + enabled: storage.isCloudEnabled(), + click: async () => { + try { + const saveGame = await storage.getSteamCloudSaveString(); + await storage.pushSaveGameForImport(window, saveGame, false); + } catch (error) { + log.error(error); + utils.writeToast(window, "Could not load from Steam Cloud", "error", 5000); + } + } + }, + { + type: "separator", + }, + { + label: "Compress Disk Saves (.gz)", + type: "checkbox", + checked: storage.isSaveCompressionEnabled(), + click: (menuItem) => { + storage.setSaveCompressionConfig(menuItem.checked); + utils.writeToast(window, + `${menuItem.checked ? "Enabled" : "Disabled"} Save Compression`, "info", 5000); + refreshMenu(window); + }, + }, + { + label: "Auto-Save to Disk", + type: "checkbox", + checked: storage.isAutosaveEnabled(), + click: (menuItem) => { + storage.setAutosaveConfig(menuItem.checked); + utils.writeToast(window, + `${menuItem.checked ? "Enabled" : "Disabled"} Auto-Save to Disk`, "info", 5000); + refreshMenu(window); + }, + }, + { + label: "Auto-Save to Steam Cloud", + type: "checkbox", + enabled: !global.greenworksError, + checked: storage.isCloudEnabled(), + click: (menuItem) => { + storage.setCloudEnabledConfig(menuItem.checked); + utils.writeToast(window, + `${menuItem.checked ? "Enabled" : "Disabled"} Auto-Save to Steam Cloud`, "info", 5000); + refreshMenu(window); + }, + }, + { + label: "Restore Newest on Load", + type: "checkbox", + checked: config.get("onload-restore-newest", true), + click: (menuItem) => { + config.set("onload-restore-newest", menuItem.checked); + utils.writeToast(window, + `${menuItem.checked ? "Enabled" : "Disabled"} Restore Newest on Load`, "info", 5000); + refreshMenu(window); + }, + }, + { + type: "separator", + }, + { + label: "Open Directory", + submenu: [ + { + label: "Open Game Directory", + click: () => shell.openPath(app.getAppPath()), + }, + { + label: "Open Saves Directory", + click: async () => { + const path = await storage.getSaveFolder(window); + shell.openPath(path); + }, + }, + { + label: "Open Logs Directory", + click: () => shell.openPath(app.getPath("logs")), + }, + { + label: "Open Data Directory", + click: () => shell.openPath(app.getPath("userData")), + }, + ] + }, + { + type: "separator", + }, + { + label: "Quit", + click: () => app.quit(), + }, + ] + }, { label: "Edit", submenu: [ @@ -163,6 +321,17 @@ function getMenu(window) { label: "Activate", click: () => window.webContents.openDevTools(), }, + { + label: "Delete Steam Cloud Data", + enabled: !global.greenworksError, + click: async () => { + try { + await storage.deleteCloudFile(); + } catch (error) { + log.error(error); + } + } + } ], }, ]); diff --git a/electron/package-lock.json b/electron/package-lock.json index 00b2d1d7e..b512a9036 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.0", "dependencies": { "electron-config": "^2.0.0", - "electron-log": "^4.4.4" + "electron-log": "^4.4.4", + "lodash": "^4.17.21" } }, "node_modules/conf": { @@ -104,6 +105,11 @@ "node": ">=4" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "node_modules/make-dir": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", @@ -259,6 +265,11 @@ "path-exists": "^3.0.0" } }, + "lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "make-dir": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", diff --git a/electron/package.json b/electron/package.json index f3697e2c1..ddf366f92 100755 --- a/electron/package.json +++ b/electron/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "electron-config": "^2.0.0", - "electron-log": "^4.4.4" + "electron-log": "^4.4.4", + "lodash": "^4.17.21" } } diff --git a/electron/preload.js b/electron/preload.js new file mode 100644 index 000000000..707a00009 --- /dev/null +++ b/electron/preload.js @@ -0,0 +1,38 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const { ipcRenderer, contextBridge } = require('electron') +const log = require("electron-log"); + +contextBridge.exposeInMainWorld( + "electronBridge", { + send: (channel, data) => { + log.log("Send on channel " + channel) + // whitelist channels + let validChannels = [ + "get-save-data-response", + "get-save-info-response", + "push-game-saved", + "push-game-ready", + "push-import-result", + "push-disable-restore", + ]; + if (validChannels.includes(channel)) { + ipcRenderer.send(channel, data); + } + }, + receive: (channel, func) => { + log.log("Receive on channel " + channel) + let validChannels = [ + "get-save-data-request", + "get-save-info-request", + "push-save-request", + "trigger-save", + "trigger-game-export", + "trigger-scripts-export", + ]; + if (validChannels.includes(channel)) { + // Deliberately strip event as it includes `sender` + ipcRenderer.on(channel, (event, ...args) => func(...args)); + } + } + } +); diff --git a/electron/storage.js b/electron/storage.js new file mode 100644 index 000000000..076200d3f --- /dev/null +++ b/electron/storage.js @@ -0,0 +1,386 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +const { app, ipcMain } = require("electron"); +const zlib = require("zlib"); +const path = require("path"); +const fs = require("fs/promises"); +const { promisify } = require("util"); +const gzip = promisify(zlib.gzip); +const gunzip = promisify(zlib.gunzip); + +const greenworks = require("./greenworks"); +const log = require("electron-log"); +const flatten = require("lodash/flatten"); +const Config = require("electron-config"); +const config = new Config(); + +// https://stackoverflow.com/a/69418940 +const dirSize = async (directory) => { + const files = await fs.readdir(directory); + const stats = files.map(file => fs.stat(path.join(directory, file))); + return (await Promise.all(stats)).reduce((accumulator, { size }) => accumulator + size, 0); +} + +const getDirFileStats = async (directory) => { + const files = await fs.readdir(directory); + const stats = files.map((f) => { + const file = path.join(directory, f); + return fs.stat(file).then((stat) => ({ file, stat })); + }); + const data = (await Promise.all(stats)); + return data; +}; + +const getNewestFile = async (directory) => { + const data = await getDirFileStats(directory) + return data.sort((a, b) => b.stat.mtime.getTime() - a.stat.mtime.getTime())[0]; +}; + +const getAllSaves = async (window) => { + const rootDirectory = await getSaveFolder(window, true); + const data = await fs.readdir(rootDirectory, { withFileTypes: true}); + const savesPromises = data.filter((e) => e.isDirectory()). + map((dir) => path.join(rootDirectory, dir.name)). + map((dir) => getDirFileStats(dir)); + const saves = await Promise.all(savesPromises); + const flat = flatten(saves); + return flat; +} + +async function prepareSaveFolders(window) { + const rootFolder = await getSaveFolder(window, true); + const currentFolder = await getSaveFolder(window); + const backupsFolder = path.join(rootFolder, "/_backups") + await prepareFolders(rootFolder, currentFolder, backupsFolder); +} + +async function prepareFolders(...folders) { + for (const folder of folders) { + try { + // Making sure the folder exists + // eslint-disable-next-line no-await-in-loop + await fs.stat(folder); + } catch (error) { + if (error.code === 'ENOENT') { + log.warn(`'${folder}' not found, creating it...`); + // eslint-disable-next-line no-await-in-loop + await fs.mkdir(folder); + } else { + log.error(error); + } + } + } +} + +async function getFolderSizeInBytes(saveFolder) { + try { + return await dirSize(saveFolder); + } catch (error) { + log.error(error); + } +} + +function setAutosaveConfig(value) { + config.set("autosave-enabled", value); +} + +function isAutosaveEnabled() { + return config.get("autosave-enabled", true); +} + +function setSaveCompressionConfig(value) { + config.set("save-compression-enabled", value); +} + +function isSaveCompressionEnabled() { + return config.get("save-compression-enabled", true); +} + +function setCloudEnabledConfig(value) { + config.set("cloud-enabled", value); +} + +async function getSaveFolder(window, root = false) { + if (root) return path.join(app.getPath("userData"), "/saves"); + const identifier = window.gameInfo?.player?.identifier ?? ""; + return path.join(app.getPath("userData"), "/saves", `/${identifier}`); +} + +function isCloudEnabled() { + // If the Steam API could not be initialized on game start, we'll abort this. + if (global.greenworksError) return false; + + // If the user disables it in Steam there's nothing we can do + if (!greenworks.isCloudEnabledForUser()) return false; + + // Let's check the config file to see if it's been overriden + const enabledInConf = config.get("cloud-enabled", true); + if (!enabledInConf) return false; + + const isAppEnabled = greenworks.isCloudEnabled(); + if (!isAppEnabled) greenworks.enableCloud(true); + + return true; +} + +function saveCloudFile(name, content) { + return new Promise((resolve, reject) => { + greenworks.saveTextToFile(name, content, resolve, reject); + }) +} + +function getFirstCloudFile() { + const nbFiles = greenworks.getFileCount(); + if (nbFiles === 0) throw new Error('No files in cloud'); + const file = greenworks.getFileNameAndSize(0); + log.silly(`Found ${nbFiles} files.`) + log.silly(`First File: ${file.name} (${file.size} bytes)`); + return file.name; +} + +function getCloudFile() { + const file = getFirstCloudFile(); + return new Promise((resolve, reject) => { + greenworks.readTextFromFile(file, resolve, reject); + }); +} + +function deleteCloudFile() { + const file = getFirstCloudFile(); + return new Promise((resolve, reject) => { + greenworks.deleteFile(file, resolve, reject); + }); +} + +async function getSteamCloudQuota() { + return new Promise((resolve, reject) => { + greenworks.getCloudQuota(resolve, reject) + }); +} + +async function backupSteamDataToDisk(currentPlayerId) { + const nbFiles = greenworks.getFileCount(); + if (nbFiles === 0) return; + + const file = greenworks.getFileNameAndSize(0); + const previousPlayerId = file.name.replace(".json.gz", ""); + if (previousPlayerId !== currentPlayerId) { + const backupSave = await getSteamCloudSaveString(); + const backupFile = path.join(app.getPath("userData"), "/saves/_backups", `${previousPlayerId}.json.gz`); + const buffer = Buffer.from(backupSave, 'base64').toString('utf8'); + saveContent = await gzip(buffer); + await fs.writeFile(backupFile, saveContent, 'utf8'); + log.debug(`Saved backup game to '${backupFile}`); + } +} + +async function pushGameSaveToSteamCloud(base64save, currentPlayerId) { + if (!isCloudEnabled) return Promise.reject("Steam Cloud is not Enabled"); + + try { + backupSteamDataToDisk(currentPlayerId); + } catch (error) { + log.error(error); + } + + const steamSaveName = `${currentPlayerId}.json.gz`; + + // Let's decode the base64 string so GZIP is more efficient. + const buffer = Buffer.from(base64save, "base64"); + const compressedBuffer = await gzip(buffer); + // We can't use utf8 for some reason, steamworks is unhappy. + const content = compressedBuffer.toString("base64"); + log.debug(`Uncompressed: ${base64save.length} bytes`); + log.debug(`Compressed: ${content.length} bytes`); + log.debug(`Saving to Steam Cloud as ${steamSaveName}`); + + try { + await saveCloudFile(steamSaveName, content); + } catch (error) { + log.error(error); + } +} + +async function getSteamCloudSaveString() { + if (!isCloudEnabled()) return Promise.reject("Steam Cloud is not Enabled"); + log.debug(`Fetching Save in Steam Cloud`); + const cloudString = await getCloudFile(); + const gzippedBase64Buffer = Buffer.from(cloudString, "base64"); + const uncompressedBuffer = await gunzip(gzippedBase64Buffer); + const content = uncompressedBuffer.toString("base64"); + log.debug(`Compressed: ${cloudString.length} bytes`); + log.debug(`Uncompressed: ${content.length} bytes`); + return content; +} + +async function saveGameToDisk(window, saveData) { + const currentFolder = await getSaveFolder(window); + let saveFolderSizeBytes = await getFolderSizeInBytes(currentFolder); + const maxFolderSizeBytes = config.get("autosave-quota", 1e8); // 100Mb per playerIndentifier + const remainingSpaceBytes = maxFolderSizeBytes - saveFolderSizeBytes; + log.debug(`Folder Usage: ${saveFolderSizeBytes} bytes`); + log.debug(`Folder Capacity: ${maxFolderSizeBytes} bytes`); + log.debug(`Remaining: ${remainingSpaceBytes} bytes (${(saveFolderSizeBytes / maxFolderSizeBytes * 100).toFixed(2)}% used)`) + const shouldCompress = isSaveCompressionEnabled(); + const fileName = saveData.fileName; + const file = path.join(currentFolder, fileName + (shouldCompress ? ".gz" : "")); + try { + let saveContent = saveData.save; + if (shouldCompress) { + // Let's decode the base64 string so GZIP is more efficient. + const buffer = Buffer.from(saveContent, 'base64').toString('utf8'); + saveContent = await gzip(buffer); + } + await fs.writeFile(file, saveContent, 'utf8'); + log.debug(`Saved Game to '${file}'`); + log.debug(`Save Size: ${saveContent.length} bytes`); + } catch (error) { + log.error(error); + } + + const fileStats = await getDirFileStats(currentFolder); + const oldestFiles = fileStats + .sort((a, b) => a.stat.mtime.getTime() - b.stat.mtime.getTime()) + .map(f => f.file).filter(f => f !== file); + + while (saveFolderSizeBytes > maxFolderSizeBytes && oldestFiles.length > 0) { + const fileToRemove = oldestFiles.shift(); + log.debug(`Over Quota -> Removing "${fileToRemove}"`); + try { + // eslint-disable-next-line no-await-in-loop + await fs.unlink(fileToRemove); + } catch (error) { + log.error(error); + } + + // eslint-disable-next-line no-await-in-loop + saveFolderSizeBytes = await getFolderSizeInBytes(currentFolder); + log.debug(`Save Folder: ${saveFolderSizeBytes} bytes`); + log.debug(`Remaining: ${maxFolderSizeBytes - saveFolderSizeBytes} bytes (${(saveFolderSizeBytes / maxFolderSizeBytes * 100).toFixed(2)}% used)`) + } + + return file; +} + +async function loadLastFromDisk(window) { + const folder = await getSaveFolder(window); + const last = await getNewestFile(folder); + log.debug(`Last modified file: "${last.file}" (${last.stat.mtime.toLocaleString()})`); + return loadFileFromDisk(last.file); +} + +async function loadFileFromDisk(path) { + const buffer = await fs.readFile(path); + let content; + if (path.endsWith('.gz')) { + const uncompressedBuffer = await gunzip(buffer); + content = uncompressedBuffer.toString('base64'); + log.debug(`Uncompressed file content (new size: ${content.length} bytes)`); + } else { + content = buffer.toString('utf8'); + log.debug(`Loaded file with ${content.length} bytes`) + } + return content; +} + +function getSaveInformation(window, save) { + return new Promise((resolve) => { + ipcMain.once("get-save-info-response", async (event, data) => { + resolve(data); + }); + window.webContents.send("get-save-info-request", save); + }); +} + +function getCurrentSave(window) { + return new Promise((resolve) => { + ipcMain.once('get-save-data-response', (event, data) => { + resolve(data); + }); + window.webContents.send('get-save-data-request'); + }); +} + +function pushSaveGameForImport(window, save, automatic) { + ipcMain.once("push-import-result", async (event, arg) => { + log.debug(`Was save imported? ${arg.wasImported ? "Yes" : "No"}`); + }); + window.webContents.send("push-save-request", { save, automatic }); +} + +async function restoreIfNewerExists(window) { + const currentSave = await getCurrentSave(window); + const currentData = await getSaveInformation(window, currentSave.save); + const steam = {}; + const disk = {}; + + try { + steam.save = await getSteamCloudSaveString(); + steam.data = await getSaveInformation(window, steam.save); + } catch (error) { + log.error("Could not retrieve steam file"); + log.debug(error); + } + + try { + const saves = (await getAllSaves()). + sort((a, b) => b.stat.mtime.getTime() - a.stat.mtime.getTime()); + if (saves.length > 0) { + disk.save = await loadFileFromDisk(saves[0].file); + disk.data = await getSaveInformation(window, disk.save); + } + } catch(error) { + log.error("Could not retrieve disk file"); + log.debug(error); + } + + const lowPlaytime = 1000 * 60 * 15; + let bestMatch; + if (!steam.data && !disk.data) { + log.info("No data to import"); + } else { + // We'll just compare using the lastSave field for now. + if (!steam.data) { + log.debug('Best potential save match: Disk'); + bestMatch = disk; + } else if (!disk.data) { + log.debug('Best potential save match: Steam Cloud'); + bestMatch = steam; + } else if ((steam.data.lastSave >= disk.data.lastSave) + || (steam.data.playtime + lowPlaytime > disk.data.playtime)) { + // We want to prioritze steam data if the playtime is very close + log.debug('Best potential save match: Steam Cloud'); + bestMatch = steam; + } else { + log.debug('Best potential save match: disk'); + bestMatch = disk; + } + } + if (bestMatch) { + if (bestMatch.data.lastSave > currentData.lastSave + 5000) { + // We add a few seconds to the currentSave's lastSave to prioritize it + log.info("Found newer data than the current's save file"); + log.silly(bestMatch.data); + await pushSaveGameForImport(window, bestMatch.save, true); + return true; + } else if(bestMatch.data.playtime > currentData.playtime && currentData.playtime < lowPlaytime) { + log.info("Found older save, but with more playtime, and current less than 15 mins played"); + log.silly(bestMatch.data); + await pushSaveGameForImport(window, bestMatch.save, true); + return true; + } else { + log.debug("Current save data is the freshest"); + return false; + } + } +} + +module.exports = { + getCurrentSave, getSaveInformation, + restoreIfNewerExists, pushSaveGameForImport, + pushGameSaveToSteamCloud, getSteamCloudSaveString, getSteamCloudQuota, deleteCloudFile, + saveGameToDisk, loadLastFromDisk, loadFileFromDisk, + getSaveFolder, prepareSaveFolders, getAllSaves, + isCloudEnabled, setCloudEnabledConfig, + isAutosaveEnabled, setAutosaveConfig, + isSaveCompressionEnabled, setSaveCompressionConfig, + }; diff --git a/src/Electron.tsx b/src/Electron.tsx index 9fa98afc5..befa41549 100644 --- a/src/Electron.tsx +++ b/src/Electron.tsx @@ -1,10 +1,18 @@ import { Player } from "./Player"; +import { Router } from "./ui/GameRoot"; +import { isScriptFilename } from "./Script/isScriptFilename"; +import { Script } from "./Script/Script"; import { removeLeadingSlash } from "./Terminal/DirectoryHelpers"; import { Terminal } from "./Terminal"; import { SnackbarEvents } from "./ui/React/Snackbar"; import { IMap, IReturnStatus } from "./types"; import { GetServer } from "./Server/AllServers"; import { resolve } from "cypress/types/bluebird"; +import { ImportPlayerData, SaveData, saveObject } from "./SaveObject"; +import { Settings } from "./Settings/Settings"; +import { exportScripts } from "./Terminal/commands/download"; +import { CONSTANTS } from "./Constants"; +import { hash } from "./hash/hash"; export function initElectron(): void { const userAgent = navigator.userAgent.toLowerCase(); @@ -13,6 +21,8 @@ export function initElectron(): void { (document as any).achievements = []; initWebserver(); initAppNotifier(); + initSaveFunctions(); + initElectronBridge(); } } @@ -109,6 +119,123 @@ function initAppNotifier(): void { }; // Will be consumud by the electron wrapper. - // @ts-ignore - window.appNotifier = funcs; + (window as any).appNotifier = funcs; +} + +function initSaveFunctions(): void { + const funcs = { + triggerSave: (): Promise => saveObject.saveGame(true), + triggerGameExport: (): void => { + try { + saveObject.exportGame(); + } catch (error) { + console.log(error); + SnackbarEvents.emit("Could not export game.", "error", 2000); + } + }, + triggerScriptsExport: (): void => exportScripts("*", Player.getHomeComputer()), + getSaveData: (): { save: string; fileName: string } => { + return { + save: saveObject.getSaveString(Settings.ExcludeRunningScriptsFromSave), + fileName: saveObject.getSaveFileName(), + }; + }, + getSaveInfo: async (base64save: string): Promise => { + try { + const data = await saveObject.getImportDataFromString(base64save); + return data.playerData; + } catch (error) { + console.error(error); + return; + } + }, + pushSaveData: (base64save: string, automatic = false): void => Router.toImportSave(base64save, automatic), + }; + + // Will be consumud by the electron wrapper. + (window as any).appSaveFns = funcs; +} + +function initElectronBridge(): void { + const bridge = (window as any).electronBridge as any; + if (!bridge) return; + + bridge.receive("get-save-data-request", () => { + const data = (window as any).appSaveFns.getSaveData(); + bridge.send("get-save-data-response", data); + }); + bridge.receive("get-save-info-request", async (save: string) => { + const data = await (window as any).appSaveFns.getSaveInfo(save); + bridge.send("get-save-info-response", data); + }); + bridge.receive("push-save-request", ({ save, automatic = false }: { save: string; automatic: boolean }) => { + (window as any).appSaveFns.pushSaveData(save, automatic); + }); + bridge.receive("trigger-save", () => { + return (window as any).appSaveFns + .triggerSave() + .then(() => { + bridge.send("save-completed"); + }) + .catch((error: any) => { + console.log(error); + SnackbarEvents.emit("Could not save game.", "error", 2000); + }); + }); + bridge.receive("trigger-game-export", () => { + try { + (window as any).appSaveFns.triggerGameExport(); + } catch (error) { + console.log(error); + SnackbarEvents.emit("Could not export game.", "error", 2000); + } + }); + bridge.receive("trigger-scripts-export", () => { + try { + (window as any).appSaveFns.triggerScriptsExport(); + } catch (error) { + console.log(error); + SnackbarEvents.emit("Could not export scripts.", "error", 2000); + } + }); +} + +export function pushGameSaved(data: SaveData): void { + const bridge = (window as any).electronBridge as any; + if (!bridge) return; + + bridge.send("push-game-saved", data); +} + +export function pushGameReady(): void { + const bridge = (window as any).electronBridge as any; + if (!bridge) return; + + // Send basic information to the electron wrapper + bridge.send("push-game-ready", { + player: { + identifier: Player.identifier, + playtime: Player.totalPlaytime, + lastSave: Player.lastSave, + }, + game: { + version: CONSTANTS.VersionString, + hash: hash(), + }, + }); +} + +export function pushImportResult(wasImported: boolean): void { + const bridge = (window as any).electronBridge as any; + if (!bridge) return; + + bridge.send("push-import-result", { wasImported }); + pushDisableRestore(); +} + +export function pushDisableRestore(): void { + const bridge = (window as any).electronBridge as any; + if (!bridge) return; + + bridge.send("push-disable-restore", { duration: 1000 * 60 }); } diff --git a/src/PersonObjects/Player/PlayerObject.ts b/src/PersonObjects/Player/PlayerObject.ts index c03569225..3a50f68a3 100644 --- a/src/PersonObjects/Player/PlayerObject.ts +++ b/src/PersonObjects/Player/PlayerObject.ts @@ -35,7 +35,9 @@ import { CityName } from "../../Locations/data/CityNames"; import { MoneySourceTracker } from "../../utils/MoneySourceTracker"; import { Reviver, Generic_toJSON, Generic_fromJSON } from "../../utils/JSONReviver"; import { ISkillProgress } from "../formulas/skill"; -import { PlayerAchievement } from '../../Achievements/Achievements'; +import { PlayerAchievement } from "../../Achievements/Achievements"; +import { cyrb53 } from "../../utils/StringHelperFunctions"; +import { getRandomInt } from "../../utils/helpers/getRandomInt"; export class PlayerObject implements IPlayer { // Class members @@ -78,7 +80,9 @@ export class PlayerObject implements IPlayer { exploits: Exploit[]; achievements: PlayerAchievement[]; terminalCommandHistory: string[]; + identifier: string; lastUpdate: number; + lastSave: number; totalPlaytime: number; // Stats @@ -460,7 +464,9 @@ export class PlayerObject implements IPlayer { //Used to store the last update time. this.lastUpdate = 0; + this.lastSave = 0; this.totalPlaytime = 0; + this.playtimeSinceLastAug = 0; this.playtimeSinceLastBitnode = 0; @@ -474,6 +480,16 @@ export class PlayerObject implements IPlayer { this.achievements = []; this.terminalCommandHistory = []; + // Let's get a hash of some semi-random stuff so we have something unique. + this.identifier = cyrb53( + "I-" + + new Date().getTime() + + navigator.userAgent + + window.innerWidth + + window.innerHeight + + getRandomInt(100, 999), + ); + this.init = generalMethods.init; this.prestigeAugmentation = generalMethods.prestigeAugmentation; this.prestigeSourceFile = generalMethods.prestigeSourceFile; diff --git a/src/SaveObject.tsx b/src/SaveObject.tsx index eaa997269..2d15b89dd 100755 --- a/src/SaveObject.tsx +++ b/src/SaveObject.tsx @@ -23,11 +23,43 @@ import { AugmentationNames } from "./Augmentation/data/AugmentationNames"; import { PlayerOwnedAugmentation } from "./Augmentation/PlayerOwnedAugmentation"; import { LocationName } from "./Locations/data/LocationNames"; import { SxProps } from "@mui/system"; +import { PlayerObject } from "./PersonObjects/Player/PlayerObject"; +import { pushGameSaved } from "./Electron"; /* SaveObject.js * Defines the object used to save/load games */ +export interface SaveData { + playerIdentifier: string; + fileName: string; + save: string; + savedOn: number; +} + +export interface ImportData { + base64: string; + parsed: any; + playerData?: ImportPlayerData; +} + +export interface ImportPlayerData { + identifier: string; + lastSave: number; + totalPlaytime: number; + + money: number; + hacking: number; + + augmentations: number; + factions: number; + achievements: number; + + bitNode: number; + bitNodeLevel: number; + sourceFiles: number; +} + class BitburnerSaveObject { PlayerSave = ""; AllServersSave = ""; @@ -42,7 +74,6 @@ class BitburnerSaveObject { AllGangsSave = ""; LastExportBonus = ""; StaneksGiftSave = ""; - SaveTimestamp = ""; getSaveString(excludeRunningScripts = false): string { this.PlayerSave = JSON.stringify(Player); @@ -58,7 +89,6 @@ class BitburnerSaveObject { this.VersionSave = JSON.stringify(CONSTANTS.VersionNumber); this.LastExportBonus = JSON.stringify(ExportBonus.LastExportBonus); this.StaneksGiftSave = JSON.stringify(staneksGift); - this.SaveTimestamp = new Date().getTime().toString(); if (Player.inGang()) { this.AllGangsSave = JSON.stringify(AllGangs); @@ -68,28 +98,134 @@ class BitburnerSaveObject { return saveString; } - saveGame(emitToastEvent = true): void { + saveGame(emitToastEvent = true): Promise { + const savedOn = new Date().getTime(); + Player.lastSave = savedOn; const saveString = this.getSaveString(Settings.ExcludeRunningScriptsFromSave); + return new Promise((resolve, reject) => { + save(saveString) + .then(() => { + const saveData: SaveData = { + playerIdentifier: Player.identifier, + fileName: this.getSaveFileName(), + save: saveString, + savedOn, + }; + pushGameSaved(saveData); - save(saveString) - .then(() => { - if (emitToastEvent) { - SnackbarEvents.emit("Game Saved!", "info", 2000); - } - }) - .catch((err) => console.error(err)); + if (emitToastEvent) { + SnackbarEvents.emit("Game Saved!", "info", 2000); + } + return resolve(); + }) + .catch((err) => { + console.error(err); + return reject(); + }); + }); + } + + getSaveFileName(isRecovery = false): string { + // Save file name is based on current timestamp and BitNode + const epochTime = Math.round(Date.now() / 1000); + const bn = Player.bitNodeN; + let filename = `bitburnerSave_${epochTime}_BN${bn}x${SourceFileFlags[bn]}.json`; + if (isRecovery) filename = "RECOVERY" + filename; + return filename; } exportGame(): void { const saveString = this.getSaveString(Settings.ExcludeRunningScriptsFromSave); - - // Save file name is based on current timestamp and BitNode - const epochTime = Math.round(Date.now() / 1000); - const bn = Player.bitNodeN; - const filename = `bitburnerSave_${epochTime}_BN${bn}x${SourceFileFlags[bn]}.json`; + const filename = this.getSaveFileName(); download(filename, saveString); } + importGame(base64Save: string, reload = true): Promise { + if (!base64Save || base64Save === "") throw new Error("Invalid import string"); + return save(base64Save).then(() => { + if (reload) setTimeout(() => location.reload(), 1000); + return Promise.resolve(); + }); + } + + getImportStringFromFile(files: FileList | null): Promise { + if (files === null) return Promise.reject(new Error("No file selected")); + const file = files[0]; + if (!file) return Promise.reject(new Error("Invalid file selected")); + + const reader = new FileReader(); + const promise: Promise = new Promise((resolve, reject) => { + reader.onload = function (this: FileReader, e: ProgressEvent) { + const target = e.target; + if (target === null) { + return reject(new Error("Error importing file")); + } + const result = target.result; + if (typeof result !== "string" || result === null) { + return reject(new Error("FileReader event was not type string")); + } + const contents = result; + resolve(contents); + }; + }); + reader.readAsText(file); + return promise; + } + + async getImportDataFromString(base64Save: string): Promise { + if (!base64Save || base64Save === "") throw new Error("Invalid import string"); + + let newSave; + try { + newSave = window.atob(base64Save); + newSave = newSave.trim(); + } catch (error) { + console.error(error); // We'll handle below + } + + if (!newSave || newSave === "") { + return Promise.reject(new Error("Save game had not content or was not base64 encoded")); + } + + let parsedSave; + try { + parsedSave = JSON.parse(newSave); + } catch (error) { + console.log(error); // We'll handle below + } + + if (!parsedSave || parsedSave.ctor !== "BitburnerSaveObject" || !parsedSave.data) { + return Promise.reject(new Error("Save game did not seem valid")); + } + + const data: ImportData = { + base64: base64Save, + parsed: parsedSave, + }; + + const importedPlayer = PlayerObject.fromJSON(JSON.parse(parsedSave.data.PlayerSave)); + + const playerData: ImportPlayerData = { + identifier: importedPlayer.identifier, + lastSave: importedPlayer.lastSave, + totalPlaytime: importedPlayer.totalPlaytime, + + money: importedPlayer.money, + hacking: importedPlayer.hacking, + + augmentations: importedPlayer.augmentations?.reduce((total, current) => (total += current.level), 0) ?? 0, + factions: importedPlayer.factions?.length ?? 0, + achievements: importedPlayer.achievements?.length ?? 0, + + bitNode: importedPlayer.bitNodeN, + bitNodeLevel: importedPlayer.sourceFileLvl(Player.bitNodeN) + 1, + sourceFiles: importedPlayer.sourceFiles?.reduce((total, current) => (total += current.lvl), 0) ?? 0, + }; + + data.playerData = playerData; + return Promise.resolve(data); + } + toJSON(): any { return Generic_toJSON("BitburnerSaveObject", this); } diff --git a/src/Terminal/commands/download.ts b/src/Terminal/commands/download.ts index fa392d444..5d79f2dba 100644 --- a/src/Terminal/commands/download.ts +++ b/src/Terminal/commands/download.ts @@ -6,6 +6,37 @@ import { isScriptFilename } from "../../Script/isScriptFilename"; import FileSaver from "file-saver"; import JSZip from "jszip"; +export function exportScripts(pattern: string, server: BaseServer): void { + const matchEnding = pattern.length == 1 || pattern === "*.*" ? null : pattern.slice(1); // Treat *.* the same as * + const zip = new JSZip(); + // Helper function to zip any file contents whose name matches the pattern + const zipFiles = (fileNames: string[], fileContents: string[]): void => { + for (let i = 0; i < fileContents.length; ++i) { + let name = fileNames[i]; + if (name.startsWith("/")) name = name.slice(1); + if (!matchEnding || name.endsWith(matchEnding)) + zip.file(name, new Blob([fileContents[i]], { type: "text/plain" })); + } + }; + // In the case of script files, we pull from the server.scripts array + if (!matchEnding || isScriptFilename(matchEnding)) + zipFiles( + server.scripts.map((s) => s.filename), + server.scripts.map((s) => s.code), + ); + // In the case of text files, we pull from the server.scripts array + if (!matchEnding || matchEnding.endsWith(".txt")) + zipFiles( + server.textFiles.map((s) => s.fn), + server.textFiles.map((s) => s.text), + ); + + // Return an error if no files matched, rather than an empty zip folder + if (Object.keys(zip.files).length == 0) throw new Error(`No files match the pattern ${pattern}`); + const zipFn = `bitburner${isScriptFilename(pattern) ? "Scripts" : pattern === "*.txt" ? "Texts" : "Files"}.zip`; + zip.generateAsync({ type: "blob" }).then((content: any) => FileSaver.saveAs(content, zipFn)); +} + export function download( terminal: ITerminal, router: IRouter, @@ -21,34 +52,12 @@ export function download( const fn = args[0] + ""; // If the parameter starts with *, download all files that match the wildcard pattern if (fn.startsWith("*")) { - const matchEnding = fn.length == 1 || fn === "*.*" ? null : fn.slice(1); // Treat *.* the same as * - const zip = new JSZip(); - // Helper function to zip any file contents whose name matches the pattern - const zipFiles = (fileNames: string[], fileContents: string[]): void => { - for (let i = 0; i < fileContents.length; ++i) { - let name = fileNames[i]; - if (name.startsWith("/")) name = name.slice(1); - if (!matchEnding || name.endsWith(matchEnding)) - zip.file(name, new Blob([fileContents[i]], { type: "text/plain" })); - } - }; - // In the case of script files, we pull from the server.scripts array - if (!matchEnding || isScriptFilename(matchEnding)) - zipFiles( - server.scripts.map((s) => s.filename), - server.scripts.map((s) => s.code), - ); - // In the case of text files, we pull from the server.scripts array - if (!matchEnding || matchEnding.endsWith(".txt")) - zipFiles( - server.textFiles.map((s) => s.fn), - server.textFiles.map((s) => s.text), - ); - // Return an error if no files matched, rather than an empty zip folder - if (Object.keys(zip.files).length == 0) return terminal.error(`No files match the pattern ${fn}`); - const zipFn = `bitburner${isScriptFilename(fn) ? "Scripts" : fn === "*.txt" ? "Texts" : "Files"}.zip`; - zip.generateAsync({ type: "blob" }).then((content: any) => FileSaver.saveAs(content, zipFn)); - return; + try { + exportScripts(fn, server); + return; + } catch (error: any) { + return terminal.error(error.message); + } } else if (isScriptFilename(fn)) { // Download a single script const script = terminal.getScript(player, fn); diff --git a/src/ui/GameRoot.tsx b/src/ui/GameRoot.tsx index 64450d234..5dc290211 100644 --- a/src/ui/GameRoot.tsx +++ b/src/ui/GameRoot.tsx @@ -81,6 +81,11 @@ import { AchievementsRoot } from "../Achievements/AchievementsRoot"; import { ErrorBoundary } from "./ErrorBoundary"; import { Settings } from "../Settings/Settings"; import { ThemeBrowser } from "../Themes/ui/ThemeBrowser"; +import { ImportSaveRoot } from "./React/ImportSaveRoot"; +import { BypassWrapper } from "./React/BypassWrapper"; + +import _wrap from "lodash/wrap"; +import _functions from "lodash/functions"; const htmlLocation = location; @@ -109,6 +114,9 @@ export let Router: IRouter = { page: () => { throw new Error("Router called before initialization"); }, + allowRouting: () => { + throw new Error("Router called before initialization"); + }, toActiveScripts: () => { throw new Error("Router called before initialization"); }, @@ -199,6 +207,9 @@ export let Router: IRouter = { toThemeBrowser: () => { throw new Error("Router called before initialization"); }, + toImportSave: () => { + throw new Error("Router called before initialization"); + }, }; function determineStartPage(player: IPlayer): Page { @@ -228,6 +239,13 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme const [errorBoundaryKey, setErrorBoundaryKey] = useState(0); const [sidebarOpened, setSideBarOpened] = useState(Settings.IsSidebarOpened); + const [importString, setImportString] = useState(undefined as unknown as string); + const [importAutomatic, setImportAutomatic] = useState(false); + if (importString === undefined && page === Page.ImportSave) + throw new Error("Trying to go to a page without the proper setup"); + + const [allowRoutingCalls, setAllowRoutingCalls] = useState(true); + function resetErrorBoundary(): void { setErrorBoundaryKey(errorBoundaryKey + 1); } @@ -249,6 +267,7 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme Router = { page: () => page, + allowRouting: (value: boolean) => setAllowRoutingCalls(value), toActiveScripts: () => setPage(Page.ActiveScripts), toAugmentations: () => setPage(Page.Augmentations), toBladeburner: () => setPage(Page.Bladeburner), @@ -315,9 +334,34 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme }, toThemeBrowser: () => { setPage(Page.ThemeBrowser); - } + }, + toImportSave: (base64save: string, automatic = false) => { + setImportString(base64save); + setImportAutomatic(automatic); + setPage(Page.ImportSave); + }, }; + + useEffect(() => { + // Wrap Router navigate functions to be able to disable the execution + _functions(Router). + filter((fnName) => fnName.startsWith('to')). + forEach((fnName) => { + // @ts-ignore - tslint does not like this, couldn't find a way to make it cooperate + Router[fnName] = _wrap(Router[fnName], (func, ...args) => { + if (!allowRoutingCalls) { + // Let's just log to console. + console.log(`Routing is currently disabled - Attempted router.${fnName}()`); + return; + } + + // Call the function normally + return func(...args); + }); + }); + }); + useEffect(() => { if (page !== Page.Terminal) window.scrollTo(0, 0); }); @@ -332,11 +376,13 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme let mainPage = Cannot load; let withSidebar = true; let withPopups = true; + let bypassGame = false; switch (page) { case Page.Recovery: { mainPage = ; withSidebar = false; withPopups = false; + bypassGame = true; break; } case Page.BitVerse: { @@ -517,44 +563,62 @@ export function GameRoot({ player, engine, terminal }: IProps): React.ReactEleme mainPage = ; break; } + case Page.ImportSave: { + mainPage = ( + + ); + withSidebar = false; + withPopups = false; + bypassGame = true; + } } return ( - - - {!ITutorial.isRunning ? ( - saveObject.saveGame()} killScripts={killAllScripts} /> + + + + {!ITutorial.isRunning ? ( + saveObject.saveGame()} killScripts={killAllScripts} /> + ) : ( + + )} + + {withSidebar ? ( + + { + setSideBarOpened(isOpened); + Settings.IsSidebarOpened = isOpened; + }} + /> + {mainPage} + ) : ( - - )} - - {withSidebar ? ( - - { - setSideBarOpened(isOpened); - Settings.IsSidebarOpened = isOpened; - }} /> {mainPage} - - ) : ( - {mainPage} - )} - - {withPopups && ( - <> - - - - - - - )} - + )} + + {withPopups && ( + <> + + + + + + + )} + + diff --git a/src/ui/LoadingScreen.tsx b/src/ui/LoadingScreen.tsx index 2b73fb734..71a2d6bc1 100644 --- a/src/ui/LoadingScreen.tsx +++ b/src/ui/LoadingScreen.tsx @@ -16,6 +16,7 @@ import { GameRoot } from "./GameRoot"; import { CONSTANTS } from "../Constants"; import { ActivateRecoveryMode } from "./React/RecoveryRoot"; import { hash } from "../hash/hash"; +import { pushGameReady } from "../Electron"; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -56,6 +57,7 @@ export function LoadingScreen(): React.ReactElement { throw err; } + pushGameReady(); setLoaded(true); }) .catch((reason) => { diff --git a/src/ui/React/BypassWrapper.tsx b/src/ui/React/BypassWrapper.tsx new file mode 100644 index 000000000..db793f225 --- /dev/null +++ b/src/ui/React/BypassWrapper.tsx @@ -0,0 +1,11 @@ +import React from "react"; + +interface IProps { + children: React.ReactNode; + content: React.ReactNode; +} + +export function BypassWrapper(props: IProps): React.ReactElement { + if (!props.content) return <>{props.children}; + return <>{props.content}; +} diff --git a/src/ui/React/ConfirmationModal.tsx b/src/ui/React/ConfirmationModal.tsx index e58b2fa57..b4de623d3 100644 --- a/src/ui/React/ConfirmationModal.tsx +++ b/src/ui/React/ConfirmationModal.tsx @@ -9,6 +9,7 @@ interface IProps { onClose: () => void; onConfirm: () => void; confirmationText: string | React.ReactNode; + additionalButton?: React.ReactNode; } export function ConfirmationModal(props: IProps): React.ReactElement { @@ -23,6 +24,7 @@ export function ConfirmationModal(props: IProps): React.ReactElement { > Confirm + {props.additionalButton && <>{props.additionalButton}} ); diff --git a/src/ui/React/DeleteGameButton.tsx b/src/ui/React/DeleteGameButton.tsx index a2d2c2d15..6f88a2a3b 100644 --- a/src/ui/React/DeleteGameButton.tsx +++ b/src/ui/React/DeleteGameButton.tsx @@ -5,6 +5,7 @@ import Button from "@mui/material/Button"; import { Tooltip } from '@mui/material'; import DeleteIcon from '@mui/icons-material/Delete'; +import { pushDisableRestore } from '../../Electron'; interface IProps { color?: "primary" | "warning" | "error"; @@ -21,7 +22,10 @@ export function DeleteGameButton({ color = "primary" }: IProps): React.ReactElem onConfirm={() => { setModalOpened(false); deleteGame() - .then(() => setTimeout(() => location.reload(), 1000)) + .then(() => { + pushDisableRestore(); + setTimeout(() => location.reload(), 1000); + }) .catch((r) => console.error(`Could not delete game: ${r}`)); }} open={modalOpened} diff --git a/src/ui/React/GameOptionsRoot.tsx b/src/ui/React/GameOptionsRoot.tsx index 03b75d060..d327ade58 100644 --- a/src/ui/React/GameOptionsRoot.tsx +++ b/src/ui/React/GameOptionsRoot.tsx @@ -25,20 +25,20 @@ import SaveIcon from "@mui/icons-material/Save"; import PaletteIcon from '@mui/icons-material/Palette'; import { FileDiagnosticModal } from "../../Diagnostic/FileDiagnosticModal"; -import { dialogBoxCreate } from "./DialogBox"; import { ConfirmationModal } from "./ConfirmationModal"; import { SnackbarEvents } from "./Snackbar"; import { Settings } from "../../Settings/Settings"; -import { save } from "../../db"; -import { formatTime } from "../../utils/helpers/formatTime"; -import { OptionSwitch } from "./OptionSwitch"; import { DeleteGameButton } from "./DeleteGameButton"; import { SoftResetButton } from "./SoftResetButton"; import { IRouter } from "../Router"; import { ThemeEditorButton } from "../../Themes/ui/ThemeEditorButton"; import { StyleEditorButton } from "../../Themes/ui/StyleEditorButton"; +import { formatTime } from "../../utils/helpers/formatTime"; +import { OptionSwitch } from "./OptionSwitch"; +import { ImportData, saveObject } from "../../SaveObject"; +import { convertTimeMsToTimeElapsedString } from "../../utils/StringHelperFunctions"; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -59,12 +59,6 @@ interface IProps { softReset: () => void; } -interface ImportData { - base64: string; - parsed: any; - exportDate?: Date; -} - export function GameOptionsRoot(props: IProps): React.ReactElement { const classes = useStyles(); const importInput = useRef(null); @@ -122,78 +116,35 @@ export function GameOptionsRoot(props: IProps): React.ReactElement { ii.click(); } - function onImport(event: React.ChangeEvent): void { - const files = event.target.files; - if (files === null) return; - const file = files[0]; - if (!file) { - dialogBoxCreate("Invalid file selected"); - return; - } - - const reader = new FileReader(); - reader.onload = function (this: FileReader, e: ProgressEvent) { - const target = e.target; - if (target === null) { - console.error("error importing file"); - return; - } - const result = target.result; - if (typeof result !== "string" || result === null) { - console.error("FileReader event was not type string"); - return; - } - const contents = result; - - let newSave; - try { - newSave = window.atob(contents); - newSave = newSave.trim(); - } catch (error) { - console.log(error); // We'll handle below - } - - if (!newSave || newSave === "") { - SnackbarEvents.emit("Save game had not content or was not base64 encoded", "error", 5000); - return; - } - - let parsedSave; - try { - parsedSave = JSON.parse(newSave); - } catch (error) { - console.log(error); // We'll handle below - } - - if (!parsedSave || parsedSave.ctor !== "BitburnerSaveObject" || !parsedSave.data) { - SnackbarEvents.emit("Save game did not seem valid", "error", 5000); - return; - } - - const data: ImportData = { - base64: contents, - parsed: parsedSave, - }; - - const timestamp = parsedSave.data.SaveTimestamp; - if (timestamp && timestamp !== "0") { - data.exportDate = new Date(parseInt(timestamp, 10)); - } - + async function onImport(event: React.ChangeEvent): Promise { + try { + const base64Save = await saveObject.getImportStringFromFile(event.target.files); + const data = await saveObject.getImportDataFromString(base64Save); setImportData(data); setImportSaveOpen(true); - }; - reader.readAsText(file); + } catch (ex: any) { + SnackbarEvents.emit(ex.toString(), "error", 5000); + } } - function confirmedImportGame(): void { + async function confirmedImportGame(): Promise { if (!importData) return; + try { + await saveObject.importGame(importData.base64); + } catch (ex: any) { + SnackbarEvents.emit(ex.toString(), "error", 5000); + } + setImportSaveOpen(false); - save(importData.base64).then(() => { - setImportData(null); - setTimeout(() => location.reload(), 1000); - }); + setImportData(null); + } + + function compareSaveGame(): void { + if (!importData) return; + props.router.toImportSave(importData.base64); + setImportSaveOpen(false); + setImportData(null); } return ( @@ -585,6 +536,7 @@ export function GameOptionsRoot(props: IProps): React.ReactElement { open={importSaveOpen} onClose={() => setImportSaveOpen(false)} onConfirm={() => confirmedImportGame()} + additionalButton={} confirmationText={ <> Importing a new game will completely wipe the current data! @@ -593,15 +545,24 @@ export function GameOptionsRoot(props: IProps): React.ReactElement { Make sure to have a backup of your current save file before importing.
The file you are attempting to import seems valid. -
-
- {importData?.exportDate && ( + {(importData?.playerData?.lastSave ?? 0) > 0 && ( <> - The export date of the save file is {importData?.exportDate.toString()}

+ The export date of the save file is{" "} + {new Date(importData?.playerData?.lastSave ?? 0).toLocaleString()} )} + {(importData?.playerData?.totalPlaytime ?? 0) > 0 && ( + <> +
+
+ Total play time of imported game:{" "} + {convertTimeMsToTimeElapsedString(importData?.playerData?.totalPlaytime ?? 0)} + + )} +
+
} /> diff --git a/src/ui/React/ImportSaveRoot.tsx b/src/ui/React/ImportSaveRoot.tsx new file mode 100644 index 000000000..64ca82a6c --- /dev/null +++ b/src/ui/React/ImportSaveRoot.tsx @@ -0,0 +1,351 @@ +import React, { useEffect, useState } from "react"; + +import { + Paper, + Table, + TableHead, + TableRow, + TableBody, + TableContainer, + TableCell, + Typography, + Tooltip, + Box, + Button, + ButtonGroup, +} from "@mui/material"; + +import makeStyles from "@mui/styles/makeStyles"; +import createStyles from "@mui/styles/createStyles"; +import { Theme } from "@mui/material/styles"; + +import ThumbUpAlt from "@mui/icons-material/ThumbUpAlt"; +import ThumbDownAlt from "@mui/icons-material/ThumbDownAlt"; +import DirectionsRunIcon from "@mui/icons-material/DirectionsRun"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; +import WarningIcon from "@mui/icons-material/Warning"; + +import { ImportData, saveObject } from "../../SaveObject"; +import { Settings } from "../../Settings/Settings"; +import { convertTimeMsToTimeElapsedString } from "../../utils/StringHelperFunctions"; +import { numeralWrapper } from "../numeralFormat"; +import { ConfirmationModal } from "./ConfirmationModal"; +import { pushImportResult } from "../../Electron"; +import { IRouter } from "../Router"; + +const useStyles = makeStyles((theme: Theme) => + createStyles({ + root: { + padding: theme.spacing(2), + maxWidth: "1000px", + + "& .MuiTable-root": { + "& .MuiTableCell-root": { + borderBottom: `1px solid ${Settings.theme.welllight}`, + }, + + "& .MuiTableHead-root .MuiTableRow-root": { + backgroundColor: Settings.theme.backgroundsecondary, + + "& .MuiTableCell-root": { + color: Settings.theme.primary, + fontWeight: "bold", + }, + }, + + "& .MuiTableBody-root": { + "& .MuiTableRow-root:nth-of-type(odd)": { + backgroundColor: Settings.theme.well, + + "& .MuiTableCell-root": { + color: Settings.theme.primarylight, + }, + }, + "& .MuiTableRow-root:nth-of-type(even)": { + backgroundColor: Settings.theme.backgroundsecondary, + + "& .MuiTableCell-root": { + color: Settings.theme.primarylight, + }, + }, + }, + }, + }, + }), +); + +function ComparisonIcon({ isBetter }: { isBetter: boolean }): JSX.Element { + if (isBetter) { + return ( + + Imported value is larger! + + } + > + + + ); + } else { + return ( + + Imported value is smaller! + + } + > + + + ); + } +} + +export interface IProps { + importString: string; + automatic: boolean; + router: IRouter; +} + +let initialAutosave = 0; + +export function ImportSaveRoot(props: IProps): JSX.Element { + const classes = useStyles(); + const [importData, setImportData] = useState(); + const [currentData, setCurrentData] = useState(); + const [importModalOpen, setImportModalOpen] = useState(false); + const [headback, setHeadback] = useState(false); + + function handleGoBack(): void { + Settings.AutosaveInterval = initialAutosave; + pushImportResult(false); + props.router.allowRouting(true); + setHeadback(true) + } + + async function handleImport(): Promise { + await saveObject.importGame(props.importString, true); + pushImportResult(true); + } + + useEffect(() => { + // We want to disable autosave while we're in this mode + initialAutosave = Settings.AutosaveInterval; + Settings.AutosaveInterval = 0; + props.router.allowRouting(false); + }, []); + + useEffect(() => { + if (headback) props.router.toTerminal(); + }, [headback]); + + useEffect(() => { + async function fetchData(): Promise { + const dataBeingImported = await saveObject.getImportDataFromString(props.importString); + const dataCurrentlyInGame = await saveObject.getImportDataFromString(saveObject.getSaveString(true)); + + setImportData(dataBeingImported); + setCurrentData(dataCurrentlyInGame); + + return Promise.resolve(); + } + if (props.importString) fetchData(); + }, [props.importString]); + + if (!importData || !currentData) return <>; + return ( + + + Import Save Comparison + + {props.automatic && ( + + We've found a NEWER save that you may want to use instead. + + )} + + Your current game's data is on the left and the data that will be imported is on the right. +
+ Please double check everything is fine before proceeding! +
+ + + + + + Current Game + Being Imported + + + + + + + Game Identifier + {currentData.playerData?.identifier ?? "n/a"} + {importData.playerData?.identifier ?? "n/a"} + + {importData.playerData?.identifier !== currentData.playerData?.identifier && ( + + + + )} + + + + Playtime + {convertTimeMsToTimeElapsedString(currentData.playerData?.totalPlaytime ?? 0)} + {convertTimeMsToTimeElapsedString(importData.playerData?.totalPlaytime ?? 0)} + + {importData.playerData?.totalPlaytime !== currentData.playerData?.totalPlaytime && ( + (currentData.playerData?.totalPlaytime ?? 0) + } + /> + )} + + + + + Saved On + + {(currentData.playerData?.lastSave ?? 0) > 0 ? + new Date(currentData.playerData?.lastSave ?? 0).toLocaleString() : 'n/a'} + + + {(importData.playerData?.lastSave ?? 0) > 0 ? + new Date(importData.playerData?.lastSave ?? 0).toLocaleString() : 'n/a'} + + + {importData.playerData?.lastSave !== currentData.playerData?.lastSave && ( + (currentData.playerData?.lastSave ?? 0)} + /> + )} + + + + + Money + {numeralWrapper.formatMoney(currentData.playerData?.money ?? 0)} + {numeralWrapper.formatMoney(importData.playerData?.money ?? 0)} + + {importData.playerData?.money !== currentData.playerData?.money && ( + (currentData.playerData?.money ?? 0)} + /> + )} + + + + + Hacking + {numeralWrapper.formatSkill(currentData.playerData?.hacking ?? 0)} + {numeralWrapper.formatSkill(importData.playerData?.hacking ?? 0)} + + {importData.playerData?.hacking !== currentData.playerData?.hacking && ( + (currentData.playerData?.hacking ?? 0)} + /> + )} + + + + + Augmentations + {currentData.playerData?.augmentations} + {importData.playerData?.augmentations} + + {importData.playerData?.augmentations !== currentData.playerData?.augmentations && ( + (currentData.playerData?.augmentations ?? 0) + } + /> + )} + + + + + Factions + {currentData.playerData?.factions} + {importData.playerData?.factions} + + {importData.playerData?.factions !== currentData.playerData?.factions && ( + (currentData.playerData?.factions ?? 0)} + /> + )} + + + + Achievements + {currentData.playerData?.achievements} + {importData.playerData?.achievements} + + {importData.playerData?.achievements !== currentData.playerData?.achievements && ( + (currentData.playerData?.achievements ?? 0)} + /> + )} + + + + + Source Files + {currentData.playerData?.sourceFiles} + {importData.playerData?.sourceFiles} + + {importData.playerData?.sourceFiles !== currentData.playerData?.sourceFiles && ( + (currentData.playerData?.sourceFiles ?? 0)} + /> + )} + + + + + BitNode + + {currentData.playerData?.bitNode}-{currentData.playerData?.bitNodeLevel} + + + {importData.playerData?.bitNode}-{importData.playerData?.bitNodeLevel} + + + + +
+
+ + + + + + + setImportModalOpen(false)} + onConfirm={handleImport} + confirmationText={ + <> + Importing new save game data will completely wipe the current game data! +
+ + } + /> +
+
+ ); +} diff --git a/src/ui/Router.ts b/src/ui/Router.ts index 7ebeb31d8..2e89f6f3d 100644 --- a/src/ui/Router.ts +++ b/src/ui/Router.ts @@ -38,6 +38,7 @@ export enum Page { Recovery, Achievements, ThemeBrowser, + ImportSave, } export interface ScriptEditorRouteOptions { @@ -54,6 +55,7 @@ export interface IRouter { // toRedPill(): void; // toworkInProgress(): void; page(): Page; + allowRouting(value: boolean): void; toActiveScripts(): void; toAugmentations(): void; toBitVerse(flume: boolean, quick: boolean): void; @@ -84,4 +86,5 @@ export interface IRouter { toStaneksGift(): void; toAchievements(): void; toThemeBrowser(): void; + toImportSave(base64Save: string, automatic?: boolean): void; } diff --git a/src/utils/StringHelperFunctions.ts b/src/utils/StringHelperFunctions.ts index 44a3cb276..58c685871 100644 --- a/src/utils/StringHelperFunctions.ts +++ b/src/utils/StringHelperFunctions.ts @@ -97,4 +97,31 @@ function generateRandomString(n: number): string { return str; } -export { convertTimeMsToTimeElapsedString, longestCommonStart, containsAllStrings, formatNumber, generateRandomString }; +/** + * Hashes the input string. This is a fast hash, so NOT good for cryptography. + * This has been ripped off here: https://stackoverflow.com/a/52171480 + * @param str The string that is to be hashed + * @param seed A seed to randomize the result + * @returns An hexadecimal string representation of the hashed input + */ +function cyrb53(str: string, seed = 0): string { + let h1 = 0xdeadbeef ^ seed; + let h2 = 0x41c6ce57 ^ seed; + for (let i = 0, ch; i < str.length; i++) { + ch = str.charCodeAt(i); + h1 = Math.imul(h1 ^ ch, 2654435761); + h2 = Math.imul(h2 ^ ch, 1597334677); + } + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909); + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909); + return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(16); +} + +export { + convertTimeMsToTimeElapsedString, + longestCommonStart, + containsAllStrings, + formatNumber, + generateRandomString, + cyrb53, +};