diff --git a/src/SaveObject.ts b/src/SaveObject.ts index 1945d697b..10772e65c 100644 --- a/src/SaveObject.ts +++ b/src/SaveObject.ts @@ -453,6 +453,9 @@ class BitburnerSaveObject implements BitburnerSaveObjectType { async function loadGame(saveData: SaveData): Promise { createScamUpdateText(); if (!saveData) { + console.error( + `Invalid save data. typeof saveData: ${typeof saveData}. saveData is an empty string: ${saveData === ""}`, + ); return false; } const jsonSaveString = await decodeSaveData(saveData); diff --git a/src/db.ts b/src/db.ts index 56c77e641..ad688f803 100644 --- a/src/db.ts +++ b/src/db.ts @@ -1,4 +1,5 @@ import type { SaveData } from "./types"; +import { isSaveData } from "./utils/TypeAssertion"; export class IndexedDBVersionError extends Error { constructor(message: string, options: ErrorOptions) { @@ -7,10 +8,10 @@ export class IndexedDBVersionError extends Error { } } -function getDB(): Promise { +function getDB(): Promise { return new Promise((resolve, reject) => { if (!window.indexedDB) { - reject("Indexed DB does not exists"); + reject(new Error("This browser does not support IndexedDB")); } /** * DB is called bitburnerSave @@ -38,52 +39,96 @@ function getDB(): Promise { if (this.error?.name === "VersionError") { reject(new IndexedDBVersionError(this.error.message, { cause: this.error })); } - reject(this.error ?? new Error("Failed to get IDB")); + reject(this.error ?? new Error("Cannot open a connection to IndexedDB")); + }; + indexedDbRequest.onblocked = function (this: IDBRequest) { + reject( + new Error("Database in use by another tab. Please close all other Bitburner tabs.", { cause: this.error }), + ); }; indexedDbRequest.onsuccess = function (this: IDBRequest) { const db = this.result; + // This should never happen unless the browser is buggy. if (!db) { - reject(new Error("database loading result was undefined")); + reject(new Error("Database opened successfully, but the result is somehow falsy.")); return; } - resolve(db.transaction(["savestring"], "readwrite").objectStore("savestring")); + resolve(db); }; }); } -export function load(): Promise { - return getDB().then((db) => { - return new Promise((resolve, reject) => { - const request = db.get("save") as IDBRequest; - request.onerror = function (this: IDBRequest) { - reject(new Error("Error in Database request to get save data", { cause: this.error })); - }; +/** + * Load the save data from IndexedDB. + * + * Note that if skipCheckingLoadedData is true, there is no guarantee the resolved data is valid SaveData. Only pass + * true to skipCheckingLoadedData if you don't care if the loaded data is valid SaveData (e.g., download a backup of the + * save data, regardless of what it is). + * + * @param skipCheckingLoadedData + * @returns + */ +export async function load(skipCheckingLoadedData = false): Promise { + const db = await getDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(["savestring"], "readonly"); + const rejectHandler = () => { + reject(transaction.error ?? new Error("Cannot load game data. Transaction aborted or encountered an error.")); + }; + transaction.onerror = rejectHandler; + transaction.onabort = rejectHandler; - request.onsuccess = function (this: IDBRequest) { - resolve(this.result); - }; - }); + const objectStore = transaction.objectStore("savestring"); + const request = objectStore.get("save"); + + request.onsuccess = function () { + const result: unknown = request.result; + // In some cases, we don't care if the loaded data is valid SaveData. + if (skipCheckingLoadedData) { + resolve(result as SaveData | undefined); + return; + } + if (result !== undefined && !isSaveData(result)) { + console.error("Invalid save data in IndexedDB:", result); + reject(new Error("Save data exists, but its type is invalid.")); + return; + } + resolve(result); + }; }); } -export function save(saveData: SaveData): Promise { - return getDB().then((db) => { - return new Promise((resolve, reject) => { - // We'll save to IndexedDB - const request = db.put(saveData, "save"); +export async function save(saveData: SaveData): Promise { + const db = await getDB(); + return new Promise((resolve, reject) => { + const transaction = db.transaction(["savestring"], "readwrite"); + const rejectHandler = () => { + reject(transaction.error ?? new Error("Cannot save game data. Transaction aborted or encountered an error.")); + }; + transaction.onerror = rejectHandler; + transaction.onabort = rejectHandler; - request.onerror = function (this: IDBRequest) { - reject(new Error("Error saving game to IndexedDB", { cause: this.error })); - }; + const objectStore = transaction.objectStore("savestring"); + objectStore.put(saveData, "save"); - request.onsuccess = () => resolve(); - }); + // transaction.oncomplete is used instead of request.onsuccess to ensure durability. + // A request can succeed in memory, but the transaction may still fail to commit due to disk I/O errors, quota + // limits, or system interruptions. + transaction.oncomplete = () => resolve(); }); } export function deleteGame(): Promise { - return getDB().then((db) => { - db.delete("save"); + const request = window.indexedDB.deleteDatabase("bitburnerSave"); + return new Promise((resolve, reject) => { + request.onerror = function () { + reject(request.error ?? new Error("Cannot delete save data")); + }; + request.onblocked = function () { + reject(new Error("Database in use by another tab. Please close all other Bitburner tabs.")); + }; + + request.onsuccess = () => resolve(); }); } diff --git a/src/engine.tsx b/src/engine.tsx index 7701bd60c..adec226e6 100644 --- a/src/engine.tsx +++ b/src/engine.tsx @@ -237,7 +237,7 @@ const Engine = { } }, - load: async function (saveData: SaveData) { + load: async function (saveData?: SaveData) { startExploits(); setupUncaughtPromiseHandler(); // Source files must be initialized early because save-game translation in @@ -245,7 +245,7 @@ const Engine = { initSourceFiles(); // Load game from save or create new game - if (await loadGame(saveData)) { + if (saveData !== undefined && (await loadGame(saveData))) { FormatsNeedToChange.emit(); initBitNodeMultipliers(); if (canAccessStockMarket()) { diff --git a/src/ui/React/RecoveryRoot.tsx b/src/ui/React/RecoveryRoot.tsx index d0b33a144..43ad3b06d 100644 --- a/src/ui/React/RecoveryRoot.tsx +++ b/src/ui/React/RecoveryRoot.tsx @@ -35,11 +35,15 @@ interface IProps { } function exportSaveFile(): void { - load() - .then((content) => { - const extension = isBinaryFormat(content) ? "json.gz" : "json"; + load(true) + .then((saveData) => { + if (saveData === undefined) { + console.error("There is no save data, but the recovery mode was activated."); + return; + } + const extension = isBinaryFormat(saveData) ? "json.gz" : "json"; const filename = `RECOVERY_BITBURNER_${Date.now()}.${extension}`; - downloadContentAsFile(content, filename); + downloadContentAsFile(saveData, filename); }) .catch((err) => { console.error(err); diff --git a/src/utils/SaveDataMigrationUtils.ts b/src/utils/SaveDataMigrationUtils.ts index 5cebb704c..c8706aada 100644 --- a/src/utils/SaveDataMigrationUtils.ts +++ b/src/utils/SaveDataMigrationUtils.ts @@ -537,8 +537,12 @@ Error: ${e}`, * Backup pre-v3 save data. We must use the data in IndexedDB instead of calling saveObject.getSaveData(). * getSaveData() returns data in v3 format, so the exported data will not be importable in pre-v3. */ - const saveData = await load(); - downloadContentAsFile(saveData, `bitburnerSave_backup_2.8.1_${Math.round(Player.lastUpdate / 1000)}.json.gz`); + const saveData = await load(true); + if (saveData !== undefined) { + downloadContentAsFile(saveData, `bitburnerSave_backup_2.8.1_${Math.round(Player.lastUpdate / 1000)}.json.gz`); + } else { + console.error("Cannot back up save data before migrating to v3. The save data is somehow undefined."); + } } catch (error) { console.error("Cannot export pre-v3 save data", error); } diff --git a/src/utils/TypeAssertion.ts b/src/utils/TypeAssertion.ts index 3032c8400..997df2c24 100644 --- a/src/utils/TypeAssertion.ts +++ b/src/utils/TypeAssertion.ts @@ -90,6 +90,14 @@ export function assertNumberArray(unknownData: unknown, assertFinite = false): a } } +export function isSaveData(unknownData: unknown): unknownData is SaveData { + if (typeof unknownData === "string") { + return true; + } + + return unknownData instanceof Uint8Array && unknownData.buffer instanceof ArrayBuffer; +} + export function assertSaveData(unknownData: unknown): asserts unknownData is SaveData { if (typeof unknownData !== "string" && !(unknownData instanceof Uint8Array)) { console.error(unknownData);