diff --git a/electron/main.js b/electron/main.js index 216170977..34cf7be99 100644 --- a/electron/main.js +++ b/electron/main.js @@ -152,7 +152,7 @@ function setStopProcessHandler(app, window) { log.debug("Saving to Steam Cloud ..."); try { const playerId = window.gameInfo.player.identifier; - await storage.pushGameSaveToSteamCloud(save, playerId); + await storage.pushSaveDataToSteamCloud(save, playerId); log.silly("Saved Game to Steam Cloud"); } catch (error) { log.error(error); diff --git a/electron/menu.js b/electron/menu.js index e82a17796..f0a233218 100644 --- a/electron/menu.js +++ b/electron/menu.js @@ -103,8 +103,8 @@ function getMenu(window) { enabled: storage.isCloudEnabled(), click: async () => { try { - const saveGame = await storage.getSteamCloudSaveString(); - await storage.pushSaveGameForImport(window, saveGame, false); + const saveData = await storage.getSteamCloudSaveData(); + await storage.pushSaveGameForImport(window, saveData, false); } catch (error) { log.error(error); utils.writeToast(window, "Could not load from Steam Cloud", "error", 5000); @@ -114,16 +114,6 @@ function getMenu(window) { { 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", diff --git a/electron/saveDataBinaryFormat.d.ts b/electron/saveDataBinaryFormat.d.ts new file mode 100644 index 000000000..183582233 --- /dev/null +++ b/electron/saveDataBinaryFormat.d.ts @@ -0,0 +1 @@ +export declare const isBinaryFormat: (saveData: string | Uint8Array) => boolean; diff --git a/electron/saveDataBinaryFormat.js b/electron/saveDataBinaryFormat.js new file mode 100644 index 000000000..ea7f3ea4c --- /dev/null +++ b/electron/saveDataBinaryFormat.js @@ -0,0 +1,13 @@ +// The 2 magic bytes of the gzip header plus the mandatory compression type of DEFLATE +const magicBytes = [0x1f, 0x8b, 0x08]; + +function isBinaryFormat(rawData) { + for (let i = 0; i < magicBytes.length; ++i) { + if (magicBytes[i] !== rawData[i]) { + return false; + } + } + return true; +} + +module.exports = { isBinaryFormat }; diff --git a/electron/storage.js b/electron/storage.js index c61a19920..6d97ba2d4 100644 --- a/electron/storage.js +++ b/electron/storage.js @@ -11,6 +11,7 @@ const greenworks = require("./greenworks"); const log = require("electron-log"); const flatten = require("lodash/flatten"); const Store = require("electron-store"); +const { isBinaryFormat } = require("./saveDataBinaryFormat"); const store = new Store(); // https://stackoverflow.com/a/69418940 @@ -86,14 +87,6 @@ function isAutosaveEnabled() { return store.get("autosave-enabled", true); } -function setSaveCompressionConfig(value) { - store.set("save-compression-enabled", value); -} - -function isSaveCompressionEnabled() { - return store.get("save-compression-enabled", true); -} - function setCloudEnabledConfig(value) { store.set("cloud-enabled", value); } @@ -163,17 +156,22 @@ async function backupSteamDataToDisk(currentPlayerId) { const file = greenworks.getFileNameAndSize(0); const previousPlayerId = file.name.replace(".json.gz", ""); if (previousPlayerId !== currentPlayerId) { - const backupSave = await getSteamCloudSaveString(); + const backupSaveData = await getSteamCloudSaveData(); 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"); + await fs.writeFile(backupFile, backupSaveData, "utf8"); log.debug(`Saved backup game to '${backupFile}`); } } -async function pushGameSaveToSteamCloud(base64save, currentPlayerId) { - if (!isCloudEnabled) return Promise.reject("Steam Cloud is not Enabled"); +/** + * The name of save file is `${currentPlayerId}.json.gz`. The content of save file is weird: it's a base64 string of the + * binary data of compressed json save string. It's weird because the extension is .json.gz while the content is a + * base64 string. Check the comments in the implementation to see why it is like that. + */ +async function pushSaveDataToSteamCloud(saveData, currentPlayerId) { + if (!isCloudEnabled()) { + return Promise.reject("Steam Cloud is not Enabled"); + } try { backupSteamDataToDisk(currentPlayerId); @@ -183,12 +181,19 @@ async function pushGameSaveToSteamCloud(base64save, currentPlayerId) { 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`); + /** + * When we push save file to Steam Cloud, we use greenworks.saveTextToFile. It seems that this method expects a string + * as the file content. That is why saveData is encoded in base64 and pushed to Steam Cloud as a text file. + * + * Encoding saveData in UTF-8 (with buffer.toString("utf8")) is not the proper way to convert binary data to string. + * Quote from buffer's documentation: "If encoding is 'utf8' and a byte sequence in the input is not valid UTF-8, then + * each invalid byte is replaced with the replacement character U+FFFD.". The proper way to do it is to use + * String.fromCharCode or String.fromCodePoint. + * + * Instead of implementing it, the old code (encoding in base64) is used here for backward compatibility. + */ + const content = saveData.toString("base64"); + log.debug(`Uncompressed: ${saveData.length} bytes`); log.debug(`Compressed: ${content.length} bytes`); log.debug(`Saving to Steam Cloud as ${steamSaveName}`); @@ -199,19 +204,22 @@ async function pushGameSaveToSteamCloud(base64save, currentPlayerId) { } } -async function getSteamCloudSaveString() { - if (!isCloudEnabled()) return Promise.reject("Steam Cloud is not Enabled"); +/** + * This function processes the save file in Steam Cloud and returns the save data in the binary format. + */ +async function getSteamCloudSaveData() { + 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; + // Decode cloudString to get save data back. + const saveData = Buffer.from(cloudString, "base64"); + log.debug(`SaveData: ${saveData.length} bytes`); + return saveData; } -async function saveGameToDisk(window, saveData) { +async function saveGameToDisk(window, electronGameData) { const currentFolder = await getSaveFolder(window); let saveFolderSizeBytes = await getFolderSizeInBytes(currentFolder); const maxFolderSizeBytes = store.get("autosave-quota", 1e8); // 100Mb per playerIndentifier @@ -221,19 +229,12 @@ async function saveGameToDisk(window, saveData) { 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" : "")); + let saveData = electronGameData.save; + const file = path.join(currentFolder, electronGameData.fileName); 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"); + await fs.writeFile(file, saveData, "utf8"); log.debug(`Saved Game to '${file}'`); - log.debug(`Save Size: ${saveContent.length} bytes`); + log.debug(`Save Size: ${saveData.length} bytes`); } catch (error) { log.error(error); } @@ -276,14 +277,14 @@ async function loadLastFromDisk(window) { 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)`); + if (isBinaryFormat(buffer)) { + // Save file is in the binary format. + content = buffer; } else { + // Save file is in the base64 format. content = buffer.toString("utf8"); - log.debug(`Loaded file with ${content.length} bytes`); } + log.debug(`Loaded file with ${content.length} bytes`); return content; } @@ -319,7 +320,7 @@ async function restoreIfNewerExists(window) { const disk = {}; try { - steam.save = await getSteamCloudSaveString(); + steam.save = await getSteamCloudSaveData(); steam.data = await getSaveInformation(window, steam.save); } catch (error) { log.error("Could not retrieve steam file"); @@ -361,12 +362,12 @@ async function restoreIfNewerExists(window) { // 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); + 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); + pushSaveGameForImport(window, bestMatch.save, true); return true; } else { log.debug("Current save data is the freshest"); @@ -380,8 +381,8 @@ module.exports = { getSaveInformation, restoreIfNewerExists, pushSaveGameForImport, - pushGameSaveToSteamCloud, - getSteamCloudSaveString, + pushSaveDataToSteamCloud, + getSteamCloudSaveData, getSteamCloudQuota, deleteCloudFile, saveGameToDisk, @@ -394,6 +395,4 @@ module.exports = { setCloudEnabledConfig, isAutosaveEnabled, setAutosaveConfig, - isSaveCompressionEnabled, - setSaveCompressionConfig, }; diff --git a/package-lock.json b/package-lock.json index 82f2af85c..cf17e412f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,6 @@ "arg": "^5.0.2", "bcryptjs": "^2.4.3", "better-react-mathjax": "^2.0.3", - "buffer": "^6.0.3", "clsx": "^1.2.1", "date-fns": "^2.30.0", "escodegen": "^2.1.0", @@ -5884,6 +5883,7 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, "funding": [ { "type": "github", @@ -6081,29 +6081,6 @@ "node-int64": "^0.4.0" } }, - "node_modules/buffer": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "dependencies": { - "base64-js": "^1.3.1", - "ieee754": "^1.2.1" - } - }, "node_modules/buffer-crc32": { "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", @@ -9858,25 +9835,6 @@ "postcss": "^8.1.0" } }, - "node_modules/ieee754": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ] - }, "node_modules/ignore": { "version": "5.2.4", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz", diff --git a/package.json b/package.json index be89b1f7d..29e81d3c7 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ "arg": "^5.0.2", "bcryptjs": "^2.4.3", "better-react-mathjax": "^2.0.3", - "buffer": "^6.0.3", "clsx": "^1.2.1", "date-fns": "^2.30.0", "escodegen": "^2.1.0", diff --git a/src/DevMenu/ui/SaveFileDev.tsx b/src/DevMenu/ui/SaveFileDev.tsx index 3ca97c18a..1c3368667 100644 --- a/src/DevMenu/ui/SaveFileDev.tsx +++ b/src/DevMenu/ui/SaveFileDev.tsx @@ -12,6 +12,7 @@ import { ToastVariant } from "@enums"; import { Upload } from "@mui/icons-material"; import { Button } from "@mui/material"; import { OptionSwitch } from "../../ui/React/OptionSwitch"; +import { isBinaryFormat } from "../../../electron/saveDataBinaryFormat"; export function SaveFileDev(): React.ReactElement { const importInput = useRef(null); @@ -22,8 +23,14 @@ export function SaveFileDev(): React.ReactElement { async function onImport(event: React.ChangeEvent): Promise { try { - const base64Save = await saveObject.getImportStringFromFile(event.target.files); - const save = atob(base64Save); + const saveData = await saveObject.getSaveDataFromFile(event.target.files); + // TODO Support binary format. This is low priority because this entire feature (SaveFileDev) is not fully + // implemented. "doRestore" does nothing. + if (isBinaryFormat(saveData)) { + SnackbarEvents.emit("We currently do not support binary format", ToastVariant.ERROR, 5000); + return; + } + const save = decodeURIComponent(escape(atob(saveData as string))); setSaveFile(save); } catch (e: unknown) { SnackbarEvents.emit(String(e), ToastVariant.ERROR, 5000); diff --git a/src/Electron.tsx b/src/Electron.tsx index d45f0cd00..3e40babb9 100644 --- a/src/Electron.tsx +++ b/src/Electron.tsx @@ -4,13 +4,12 @@ import { Page } from "./ui/Router"; import { Terminal } from "./Terminal"; import { SnackbarEvents } from "./ui/React/Snackbar"; import { ToastVariant } from "@enums"; -import { IReturnStatus } from "./types"; +import { IReturnStatus, SaveData } from "./types"; import { GetServer } from "./Server/AllServers"; -import { ImportPlayerData, SaveData, saveObject } from "./SaveObject"; +import { ImportPlayerData, ElectronGameData, saveObject } from "./SaveObject"; import { exportScripts } from "./Terminal/commands/download"; import { CONSTANTS } from "./Constants"; import { hash } from "./hash/hash"; -import { Buffer } from "buffer"; import { resolveFilePath } from "./Paths/FilePath"; import { hasScriptExtension } from "./Paths/ScriptFilePath"; @@ -28,9 +27,9 @@ declare global { triggerSave: () => Promise; triggerGameExport: () => void; triggerScriptsExport: () => void; - getSaveData: () => { save: string; fileName: string }; - getSaveInfo: (base64Save: string) => Promise; - pushSaveData: (base64Save: string, automatic?: boolean) => void; + getSaveData: () => Promise<{ save: SaveData; fileName: string }>; + getSaveInfo: (saveData: SaveData) => Promise; + pushSaveData: (saveData: SaveData, automatic?: boolean) => void; }; electronBridge: { send: (channel: string, data?: unknown) => void; @@ -85,7 +84,7 @@ function initWebserver(): void { if (!path) return { res: false, msg: "Invalid file path." }; if (!hasScriptExtension(path)) return { res: false, msg: "Invalid file extension: must be a script" }; - code = Buffer.from(code, "base64").toString(); + code = decodeURIComponent(escape(atob(code))); const home = GetServer("home"); if (!home) return { res: false, msg: "Home server does not exist." }; @@ -132,23 +131,23 @@ function initSaveFunctions(): void { } }, triggerScriptsExport: (): void => exportScripts("*", Player.getHomeComputer()), - getSaveData: (): { save: string; fileName: string } => { + getSaveData: async (): Promise<{ save: SaveData; fileName: string }> => { return { - save: saveObject.getSaveString(), + save: await saveObject.getSaveData(), fileName: saveObject.getSaveFileName(), }; }, - getSaveInfo: async (base64Save: string): Promise => { + getSaveInfo: async (saveData: SaveData): Promise => { try { - const data = await saveObject.getImportDataFromString(base64Save); - return data.playerData; + const importData = await saveObject.getImportDataFromSaveData(saveData); + return importData.playerData; } catch (error) { console.error(error); return; } }, - pushSaveData: (base64Save: string, automatic = false): void => - Router.toPage(Page.ImportSave, { base64Save, automatic }), + pushSaveData: (saveData: SaveData, automatic = false): void => + Router.toPage(Page.ImportSave, { saveData, automatic }), }; // Will be consumed by the electron wrapper. @@ -159,14 +158,16 @@ function initElectronBridge(): void { const bridge = window.electronBridge; if (!bridge) return; - bridge.receive("get-save-data-request", () => { - const data = window.appSaveFns.getSaveData(); - bridge.send("get-save-data-response", data); + bridge.receive("get-save-data-request", async () => { + const saveData = await window.appSaveFns.getSaveData(); + bridge.send("get-save-data-response", saveData); }); - bridge.receive("get-save-info-request", async (save: unknown) => { - if (typeof save !== "string") throw new Error("Error while trying to get save info"); - const data = await window.appSaveFns.getSaveInfo(save); - bridge.send("get-save-info-response", data); + bridge.receive("get-save-info-request", async (saveData: unknown) => { + if (typeof saveData !== "string" && !(saveData instanceof Uint8Array)) { + throw new Error("Error while trying to get save info"); + } + const saveInfo = await window.appSaveFns.getSaveInfo(saveData); + bridge.send("get-save-info-response", saveInfo); }); bridge.receive("push-save-request", (params: unknown) => { if (typeof params !== "object") throw new Error("Error trying to push save request"); @@ -202,7 +203,7 @@ function initElectronBridge(): void { }); } -export function pushGameSaved(data: SaveData): void { +export function pushGameSaved(data: ElectronGameData): void { const bridge = window.electronBridge; if (!bridge) return; diff --git a/src/GameOptions/ui/GameOptionsSidebar.tsx b/src/GameOptions/ui/GameOptionsSidebar.tsx index 2a5301d3b..df0517d16 100644 --- a/src/GameOptions/ui/GameOptionsSidebar.tsx +++ b/src/GameOptions/ui/GameOptionsSidebar.tsx @@ -71,8 +71,8 @@ export const GameOptionsSidebar = (props: IProps): React.ReactElement => { async function onImport(event: React.ChangeEvent): Promise { try { - const base64Save = await saveObject.getImportStringFromFile(event.target.files); - const data = await saveObject.getImportDataFromString(base64Save); + const saveData = await saveObject.getSaveDataFromFile(event.target.files); + const data = await saveObject.getImportDataFromSaveData(saveData); setImportData(data); setImportSaveOpen(true); } catch (e: unknown) { @@ -88,7 +88,7 @@ export const GameOptionsSidebar = (props: IProps): React.ReactElement => { if (!importData) return; try { - await saveObject.importGame(importData.base64); + await saveObject.importGame(importData.saveData); } catch (e: unknown) { SnackbarEvents.emit(String(e), ToastVariant.ERROR, 5000); } @@ -99,7 +99,7 @@ export const GameOptionsSidebar = (props: IProps): React.ReactElement => { function compareSaveGame(): void { if (!importData) return; - Router.toPage(Page.ImportSave, { base64Save: importData.base64 }); + Router.toPage(Page.ImportSave, { saveData: importData.saveData }); setImportSaveOpen(false); setImportData(null); } diff --git a/src/SaveObject.ts b/src/SaveObject.ts index 918b13a52..e8c9fd883 100644 --- a/src/SaveObject.ts +++ b/src/SaveObject.ts @@ -38,20 +38,26 @@ import { Terminal } from "./Terminal"; import { getRecordValues } from "./Types/Record"; import { ExportMaterial } from "./Corporation/Actions"; import { getGoSave, loadGo } from "./Go/SaveLoad"; +import { SaveData } from "./types"; +import { SaveDataError, canUseBinaryFormat, decodeSaveData, encodeJsonSaveString } from "./utils/SaveDataUtils"; +import { isBinaryFormat } from "../electron/saveDataBinaryFormat"; /* SaveObject.js * Defines the object used to save/load games */ -export interface SaveData { +/** + * This interface is only for transferring game data to electron-related code. + */ +export interface ElectronGameData { playerIdentifier: string; fileName: string; - save: string; + save: SaveData; savedOn: number; } export interface ImportData { - base64: string; + saveData: SaveData; playerData?: ImportPlayerData; } @@ -88,7 +94,7 @@ class BitburnerSaveObject { StaneksGiftSave = ""; GoSave = ""; - getSaveString(forceExcludeRunningScripts = false): string { + async getSaveData(forceExcludeRunningScripts = false): Promise { this.PlayerSave = JSON.stringify(Player); // For the servers save, overwrite the ExcludeRunningScripts setting if forced @@ -110,115 +116,113 @@ class BitburnerSaveObject { if (Player.gang) this.AllGangsSave = JSON.stringify(AllGangs); - const saveString = btoa(unescape(encodeURIComponent(JSON.stringify(this)))); - return saveString; + return await encodeJsonSaveString(JSON.stringify(this)); } - saveGame(emitToastEvent = true): Promise { + async saveGame(emitToastEvent = true): Promise { const savedOn = new Date().getTime(); Player.lastSave = savedOn; - const saveString = this.getSaveString(); - return new Promise((resolve, reject) => { - save(saveString) - .then(() => { - const saveData: SaveData = { - playerIdentifier: Player.identifier, - fileName: this.getSaveFileName(), - save: saveString, - savedOn, - }; - pushGameSaved(saveData); + const saveData = await this.getSaveData(); + try { + await save(saveData); + } catch (error) { + console.error(error); + dialogBoxCreate(`Cannot save game: ${error}`); + return; + } + const electronGameData: ElectronGameData = { + playerIdentifier: Player.identifier, + fileName: this.getSaveFileName(), + save: saveData, + savedOn, + }; + pushGameSaved(electronGameData); - if (emitToastEvent) { - SnackbarEvents.emit("Game Saved!", ToastVariant.INFO, 2000); - } - return resolve(); - }) - .catch((err) => { - console.error(err); - return reject(); - }); - }); + if (emitToastEvent) { + SnackbarEvents.emit("Game Saved!", ToastVariant.INFO, 2000); + } } - getSaveFileName(isRecovery = false): string { + getSaveFileName(): 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${Player.sourceFileLvl(bn) + 1}.json`; - if (isRecovery) filename = "RECOVERY" + filename; - return filename; + /** + * - Binary format: save file uses .json.gz extension. Save data is the compressed json save string. + * - Base64 format: save file uses .json extension. Save data is the base64-encoded json save string. + */ + const extension = canUseBinaryFormat() ? "json.gz" : "json"; + return `bitburnerSave_${epochTime}_BN${bn}x${Player.sourceFileLvl(bn) + 1}.${extension}`; } - exportGame(): void { - const saveString = this.getSaveString(); + async exportGame(): Promise { + const saveData = await this.getSaveData(); const filename = this.getSaveFileName(); - download(filename, saveString); + download(filename, saveData); } - 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(); - }); + async importGame(saveData: SaveData, reload = true): Promise { + if (!saveData || saveData.length === 0) { + throw new Error("Invalid import string"); + } + try { + await save(saveData); + } catch (error) { + console.error(error); + dialogBoxCreate(`Cannot import save data: ${error}`); + return; + } + if (reload) { + setTimeout(() => location.reload(), 1000); + } } - getImportStringFromFile(files: FileList | null): Promise { + async getSaveDataFromFile(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 = 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") { - return reject(new Error("FileReader event was not type string")); - } - const contents = result; - resolve(contents); - }; - }); - reader.readAsText(file); - return promise; + const rawData = new Uint8Array(await file.arrayBuffer()); + if (isBinaryFormat(rawData)) { + return rawData; + } else { + return new TextDecoder().decode(rawData); + } } - async getImportDataFromString(base64Save: string): Promise { - if (!base64Save || base64Save === "") throw new Error("Invalid import string"); + async getImportDataFromSaveData(saveData: SaveData): Promise { + if (!saveData || saveData.length === 0) throw new Error("Invalid save data"); - let newSave; + let decodedSaveData; try { - newSave = window.atob(base64Save); - newSave = newSave.trim(); + decodedSaveData = await decodeSaveData(saveData); + } catch (error) { + console.error(error); + if (error instanceof SaveDataError) { + return Promise.reject(error); + } + } + + if (!decodedSaveData || decodedSaveData === "") { + return Promise.reject(new Error("Save game is invalid")); + } + + let parsedSaveData; + try { + parsedSaveData = JSON.parse(decodedSaveData); } 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.error(error); // We'll handle below - } - - if (!parsedSave || parsedSave.ctor !== "BitburnerSaveObject" || !parsedSave.data) { + if (!parsedSaveData || parsedSaveData.ctor !== "BitburnerSaveObject" || !parsedSaveData.data) { return Promise.reject(new Error("Save game did not seem valid")); } const data: ImportData = { - base64: base64Save, + saveData: saveData, }; - const importedPlayer = loadPlayer(parsedSave.data.PlayerSave); + const importedPlayer = loadPlayer(parsedSaveData.data.PlayerSave); const playerData: ImportPlayerData = { identifier: importedPlayer.identifier, @@ -719,12 +723,12 @@ Error: ${e}`); } } -function loadGame(saveString: string): boolean { +async function loadGame(saveData: SaveData): Promise { createScamUpdateText(); - if (!saveString) return false; - saveString = decodeURIComponent(escape(atob(saveString))); + if (!saveData) return false; + const jsonSaveString = await decodeSaveData(saveData); - const saveObj = JSON.parse(saveString, Reviver); + const saveObj = JSON.parse(jsonSaveString, Reviver); setPlayer(loadPlayer(saveObj.PlayerSave)); loadAllServers(saveObj.AllServersSave); @@ -849,7 +853,7 @@ function createBetaUpdateText() { ); } -function download(filename: string, content: string): void { +function download(filename: string, content: SaveData): void { const file = new Blob([content], { type: "text/plain" }); const a = document.createElement("a"), diff --git a/src/db.tsx b/src/db.tsx index 6793f821b..0e7953d7e 100644 --- a/src/db.tsx +++ b/src/db.tsx @@ -1,3 +1,5 @@ +import { SaveData } from "./types"; + function getDB(): Promise { return new Promise((resolve, reject) => { if (!window.indexedDB) { @@ -32,30 +34,30 @@ function getDB(): Promise { }); } -export function load(): Promise { +export function load(): Promise { return new Promise((resolve, reject) => { getDB() .then((db) => { - return new Promise((resolve, reject) => { - const request: IDBRequest = db.get("save"); - request.onerror = function (this: IDBRequest, ev: Event) { - reject("Error in Database request to get savestring: " + ev); + return new Promise((resolve, reject) => { + const request: IDBRequest = db.get("save"); + request.onerror = function (this: IDBRequest, ev: Event) { + reject("Error in Database request to get save data: " + ev); }; - request.onsuccess = function (this: IDBRequest) { + request.onsuccess = function (this: IDBRequest) { resolve(this.result); }; - }).then((saveString) => resolve(saveString)); + }).then((saveData) => resolve(saveData)); }) .catch((r) => reject(r)); }); } -export function save(saveString: string): Promise { +export function save(saveData: SaveData): Promise { return getDB().then((db) => { return new Promise((resolve, reject) => { - // We'll save to both localstorage and indexedDb - const request = db.put(saveString, "save"); + // We'll save to IndexedDB + const request = db.put(saveData, "save"); request.onerror = function (e) { reject("Error saving game to IndexedDB: " + e); diff --git a/src/engine.tsx b/src/engine.tsx index ac67c018c..7195cd6eb 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -43,6 +43,7 @@ import React from "react"; import { setupUncaughtPromiseHandler } from "./UncaughtPromiseHandler"; import { Button, Typography } from "@mui/material"; import { SnackbarEvents } from "./ui/React/Snackbar"; +import { SaveData } from "./types"; /** Game engine. Handles the main game loop. */ const Engine: { @@ -66,7 +67,7 @@ const Engine: { }; decrementAllCounters: (numCycles?: number) => void; checkCounters: () => void; - load: (saveString: string) => void; + load: (saveData: SaveData) => Promise; start: () => void; } = { // Time variables (milliseconds unix epoch time) @@ -218,7 +219,7 @@ const Engine: { } }, - load: function (saveString) { + load: async function (saveData) { startExploits(); setupUncaughtPromiseHandler(); // Source files must be initialized early because save-game translation in @@ -226,7 +227,7 @@ const Engine: { initSourceFiles(); // Load game from save or create new game - if (loadGame(saveString)) { + if (await loadGame(saveData)) { FormatsNeedToChange.emit(); initBitNodeMultipliers(); if (Player.hasWseAccount) { diff --git a/src/types.ts b/src/types.ts index e6d60d170..384bbdd8d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -44,3 +44,6 @@ export interface IMinMaxRange { /** The minimum bound of the range. */ min: number; } + +// Type of save data. The base64 format is string, the binary format is Uint8Array. +export type SaveData = string | Uint8Array; diff --git a/src/ui/GameRoot.tsx b/src/ui/GameRoot.tsx index c6c2b8ce7..2e32dd2fa 100644 --- a/src/ui/GameRoot.tsx +++ b/src/ui/GameRoot.tsx @@ -369,7 +369,7 @@ export function GameRoot(): React.ReactElement { break; } case Page.ImportSave: { - mainPage = ; + mainPage = ; withSidebar = false; withPopups = false; bypassGame = true; diff --git a/src/ui/LoadingScreen.tsx b/src/ui/LoadingScreen.tsx index 75c73cf40..aac89848b 100644 --- a/src/ui/LoadingScreen.tsx +++ b/src/ui/LoadingScreen.tsx @@ -31,27 +31,20 @@ export function LoadingScreen(): React.ReactElement { }); useEffect(() => { - async function doLoad(): Promise { - await load() - .then((saveString) => { - try { - Engine.load(saveString); - } catch (err: unknown) { - ActivateRecoveryMode(); - setLoaded(true); - throw err; - } + load().then(async (saveData) => { + try { + await Engine.load(saveData); + } catch (error) { + console.error(error); + ActivateRecoveryMode(error); + await Engine.load(""); + setLoaded(true); + return; + } - pushGameReady(); - setLoaded(true); - }) - .catch((reason) => { - console.error(reason); - Engine.load(""); - setLoaded(true); - }); - } - doLoad(); + pushGameReady(); + setLoaded(true); + }); }, []); return loaded ? ( diff --git a/src/ui/React/ImportSave/ImportSave.tsx b/src/ui/React/ImportSave/ImportSave.tsx index b71e0cef8..38ea599dd 100644 --- a/src/ui/React/ImportSave/ImportSave.tsx +++ b/src/ui/React/ImportSave/ImportSave.tsx @@ -38,6 +38,7 @@ import { Page } from "../../Router"; import { useBoolean } from "../hooks"; import { ComparisonIcon } from "./ComparisonIcon"; +import { SaveData } from "../../../types"; const useStyles = makeStyles((theme: Theme) => createStyles({ @@ -89,7 +90,7 @@ const playerSkills: (keyof Skills)[] = ["hacking", "strength", "defense", "dexte let initialAutosave = 0; -export const ImportSave = (props: { importString: string; automatic: boolean }): JSX.Element => { +export const ImportSave = (props: { saveData: SaveData; automatic: boolean }): JSX.Element => { const classes = useStyles(); const [importData, setImportData] = useState(); const [currentData, setCurrentData] = useState(); @@ -105,7 +106,7 @@ export const ImportSave = (props: { importString: string; automatic: boolean }): }; const handleImport = async (): Promise => { - await saveObject.importGame(props.importString, true); + await saveObject.importGame(props.saveData, true); pushImportResult(true); }; @@ -122,16 +123,16 @@ export const ImportSave = (props: { importString: string; automatic: boolean }): useEffect(() => { async function fetchData(): Promise { - const dataBeingImported = await saveObject.getImportDataFromString(props.importString); - const dataCurrentlyInGame = await saveObject.getImportDataFromString(saveObject.getSaveString(true)); + const dataBeingImported = await saveObject.getImportDataFromSaveData(props.saveData); + const dataCurrentlyInGame = await saveObject.getImportDataFromSaveData(await saveObject.getSaveData(true)); setImportData(dataBeingImported); setCurrentData(dataCurrentlyInGame); return Promise.resolve(); } - if (props.importString) fetchData(); - }, [props.importString]); + if (props.saveData) fetchData(); + }, [props.saveData]); if (!importData || !currentData) return <>; diff --git a/src/ui/React/RecoveryRoot.tsx b/src/ui/React/RecoveryRoot.tsx index 90c5c1159..e06e46f14 100644 --- a/src/ui/React/RecoveryRoot.tsx +++ b/src/ui/React/RecoveryRoot.tsx @@ -12,11 +12,15 @@ import { SoftResetButton } from "./SoftResetButton"; import DirectionsRunIcon from "@mui/icons-material/DirectionsRun"; import GitHubIcon from "@mui/icons-material/GitHub"; +import { isBinaryFormat } from "../../../electron/saveDataBinaryFormat"; +import { InvalidSaveData, UnsupportedSaveData } from "../../utils/SaveDataUtils"; export let RecoveryMode = false; +let sourceError: unknown; -export function ActivateRecoveryMode(): void { +export function ActivateRecoveryMode(error: unknown): void { RecoveryMode = true; + sourceError = error; } interface IProps { @@ -29,6 +33,7 @@ export function RecoveryRoot({ softReset, errorData, resetError }: IProps): Reac function recover(): void { if (resetError) resetError(); RecoveryMode = false; + sourceError = undefined; Router.toPage(Page.Terminal); } Settings.AutosaveInterval = 0; @@ -37,41 +42,65 @@ export function RecoveryRoot({ softReset, errorData, resetError }: IProps): Reac load() .then((content) => { const epochTime = Math.round(Date.now() / 1000); - const filename = `RECOVERY_BITBURNER_${epochTime}.json`; + const extension = isBinaryFormat(content) ? "json.gz" : "json"; + const filename = `RECOVERY_BITBURNER_${epochTime}.${extension}`; download(filename, content); }) .catch((err) => console.error(err)); }, []); + let instructions; + if (sourceError instanceof UnsupportedSaveData) { + instructions = Please update your browser.; + } else if (sourceError instanceof InvalidSaveData) { + instructions = ( + Your save data is invalid. Please import a valid backup save file. + ); + } else { + instructions = ( + + It is recommended to alert a developer. + + + File an issue on github + + + + + Make a reddit post + + + + + Post in the #bug-report channel on Discord. + + + Please include your save file. + + ); + } + return ( RECOVERY MODE ACTIVATED - There was an error with your save file and the game went into recovery mode. In this mode saving is disabled and - the game will automatically export your save file (to prevent corruption). + There was an error with your save file and the game went into recovery mode. In this mode, saving is disabled + and the game will automatically export your save file to prevent corruption. - At this point it is recommended to alert a developer. - - - File an issue on github - - - - - Make a reddit post - - - - - Post in the #bug-report channel on Discord. - - - Please include your save file.
+ {sourceError && ( + + + Error: {sourceError.toString()} + +
+
+ )} + {instructions}
- You can disable recovery mode now. But chances are the game will not work correctly. + You can disable the recovery mode, but the game may not work correctly. - + @@ -96,7 +125,7 @@ export function RecoveryRoot({ softReset, errorData, resetError }: IProps): Reac sx={{ "& .MuiOutlinedInput-root": { color: Settings.theme.secondary } }} />
- +