diff --git a/.eslintrc.js b/.eslintrc.js index 9ed873a92..b8bf36d70 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -55,5 +55,15 @@ module.exports = { "@typescript-eslint/prefer-literal-enum-member": ["off"], }, }, + /** + * TypeScript requires the "var" keyword within "declare global" to correctly merge variables into the global + * namespace. + */ + { + files: ["**/*.d.ts"], + rules: { + "no-var": "off", + }, + }, ], }; diff --git a/electron/global.d.ts b/electron/global.d.ts new file mode 100644 index 000000000..e0ec34aaf --- /dev/null +++ b/electron/global.d.ts @@ -0,0 +1,23 @@ +import type { BrowserWindow } from "electron"; + +declare global { + var steamworksError: Error | undefined; + var app_handlers: { + stopProcess: (window: BrowserWindow) => void; + }; + namespace Electron { + interface BrowserWindow { + gameInfo?: { + player?: { + identifier: string; + playtime: number; + lastSave: number; + }; + game?: { + version: string; + hash: string; + }; + }; + } + } +} diff --git a/electron/menu.js b/electron/menu.js index 99f181128..4b7c15bbd 100644 --- a/electron/menu.js +++ b/electron/menu.js @@ -357,13 +357,12 @@ function getMenu(window) { label: "Delete Steam Cloud Data", enabled: steamworksClient !== undefined, click: () => { - if (steamworksClient.cloud.listFiles().length === 0) { + if (steamworksClient === undefined || steamworksClient.cloud.listFiles().length === 0) { + log.info("There is no Steam cloud file"); return; } try { - if (!storage.deleteCloudFile()) { - log.warn("Cannot delete Steam Cloud data"); - } + storage.deleteCloudFiles(); } catch (error) { log.error(error); } diff --git a/electron/package-lock.json b/electron/package-lock.json index 9a40a3966..324181a4f 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -8,7 +8,7 @@ "name": "bitburner", "version": "3.0.0", "dependencies": { - "@catloversg/steamworks.js": "0.0.2", + "@catloversg/steamworks.js": "0.0.3", "arg": "^5.0.2", "electron-log": "^4.4.8", "electron-store": "^8.1.0", @@ -16,9 +16,9 @@ } }, "node_modules/@catloversg/steamworks.js": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/@catloversg/steamworks.js/-/steamworks.js-0.0.2.tgz", - "integrity": "sha512-EPB7vQFZa0zGw+Ft4SHiHIDZ7UcuM/XUiyzPo5a9Pf+g5XmcvjIEjo8wKwk7Ox0DOjmSJ+s8GmOD/TDDQW5jgg==", + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@catloversg/steamworks.js/-/steamworks.js-0.0.3.tgz", + "integrity": "sha512-fQhWQ0FNuFgO7zC1t009+jiPUTBL0VeH6pxYDMbEaauRw64o6wy4lGG9gJ9Rq+Rp/wXKs/BqNMFPLpdxRXprRQ==", "license": "MIT", "dependencies": { "@types/node": "*" diff --git a/electron/package.json b/electron/package.json index f3bf1d7f7..e2005bd7b 100755 --- a/electron/package.json +++ b/electron/package.json @@ -24,7 +24,7 @@ "buildResources": "public" }, "dependencies": { - "@catloversg/steamworks.js": "0.0.2", + "@catloversg/steamworks.js": "0.0.3", "arg": "^5.0.2", "electron-log": "^4.4.8", "electron-store": "^8.1.0", diff --git a/electron/saveDataBinaryFormat.d.ts b/electron/saveDataBinaryFormat.d.ts index fa7274fd2..2508a3226 100644 --- a/electron/saveDataBinaryFormat.d.ts +++ b/electron/saveDataBinaryFormat.d.ts @@ -1,4 +1,4 @@ -export declare const encodeBytesToBase64String: (bytes: Uint8Array) => string; -export declare const decodeBase64BytesToBytes: (bytes: Uint8Array) => Uint8Array; -export declare const isBinaryFormat: (saveData: string | Uint8Array) => boolean; -export declare const isSteamCloudFormat: (saveData: string | Uint8Array) => boolean; +export declare const encodeBytesToBase64String: (bytes: Uint8Array) => string; +export declare const decodeBase64BytesToBytes: (bytes: Uint8Array) => Uint8Array; +export declare const isBinaryFormat: (saveData: string | Uint8Array) => boolean; +export declare const isSteamCloudFormat: (saveData: string | Uint8Array) => boolean; diff --git a/electron/steamworksUtils.js b/electron/steamworksUtils.js index dfee6eba4..c933b73d7 100644 --- a/electron/steamworksUtils.js +++ b/electron/steamworksUtils.js @@ -2,17 +2,21 @@ const steamworks = require("@catloversg/steamworks.js"); const log = require("electron-log"); -let steamworksClient; +/** @type {ReturnType | undefined} */ +let steamworksClient = undefined; try { // 1812820 is our Steam App ID. steamworksClient = steamworks.init(1812820); } catch (error) { - if (error.message?.includes("Steam is probably not running")) { + if (error instanceof Error) { log.warn(error.message); + global.steamworksError = error; } else { - log.warn(error); + // This should never happen. + log.error("steamworks.js threw an error that is not an instance of Error"); + log.error(error); + global.steamworksError = new Error(typeof error === "string" ? error : String(error), { cause: error }); } - global.steamworksError = error; } module.exports = { diff --git a/electron/storage.js b/electron/storage.js index 47304eefb..33b50ddce 100644 --- a/electron/storage.js +++ b/electron/storage.js @@ -1,3 +1,5 @@ +/** @import { BrowserWindow } from "electron" */ +/** @typedef {string | Uint8Array} SaveData */ /* eslint-disable @typescript-eslint/no-var-requires */ const { app, ipcMain } = require("electron"); const path = require("path"); @@ -10,6 +12,7 @@ const { decodeBase64BytesToBytes, isBinaryFormat, isSteamCloudFormat } = require const store = new Store(); const { steamworksClient } = require("./steamworksUtils"); +/** @param {string} directory */ // https://stackoverflow.com/a/69418940 const dirSize = async (directory) => { const files = await fs.readdir(directory); @@ -17,6 +20,7 @@ const dirSize = async (directory) => { return (await Promise.all(stats)).reduce((accumulator, { size }) => accumulator + size, 0); }; +/** @param {string} directory */ const getDirFileStats = async (directory) => { const files = await fs.readdir(directory); const stats = files.map((f) => { @@ -27,11 +31,13 @@ const getDirFileStats = async (directory) => { return data; }; +/** @param {string} directory */ const getNewestFile = async (directory) => { const data = await getDirFileStats(directory); return data.sort((a, b) => b.stat.mtime.getTime() - a.stat.mtime.getTime())[0]; }; +/** @param {BrowserWindow} window */ const getAllSaves = async (window) => { const rootDirectory = getSaveFolder(window, true); const data = await fs.readdir(rootDirectory, { withFileTypes: true }); @@ -44,6 +50,7 @@ const getAllSaves = async (window) => { return flat; }; +/** @param {BrowserWindow} window */ async function prepareSaveFolders(window) { const rootFolder = getSaveFolder(window, true); const currentFolder = getSaveFolder(window); @@ -51,12 +58,15 @@ async function prepareSaveFolders(window) { await prepareFolders(rootFolder, currentFolder, backupsFolder); } +/** @param {...string} folders */ async function prepareFolders(...folders) { for (const folder of folders) { try { // Making sure the folder exists await fs.stat(folder); } catch (error) { + // @ts-expect-error - Node.js guarantees that errors thrown by its APIs are instances of the standard Error class + // and include an error.code property. if (error.code === "ENOENT") { log.warn(`'${folder}' not found, creating it...`); await fs.mkdir(folder); @@ -67,6 +77,9 @@ async function prepareFolders(...folders) { } } +// TODO: Check callers of this function. They currently don't properly handle the case where this function returns +// undefined due to errors when reading folder stats. +/** @param {string} saveFolder */ async function getFolderSizeInBytes(saveFolder) { try { return await dirSize(saveFolder); @@ -75,6 +88,7 @@ async function getFolderSizeInBytes(saveFolder) { } } +/** @param {boolean} value */ function setAutosaveConfig(value) { store.set("autosave-enabled", value); } @@ -83,6 +97,7 @@ function isAutosaveEnabled() { return store.get("autosave-enabled", true); } +/** @param {boolean} value */ function setCloudEnabledConfig(value) { store.set("cloud-enabled", value); } @@ -91,14 +106,20 @@ function isMenuHideEnabled() { return store.get("autoHideMenuBar", false); } +/** @param {boolean} value */ function setMenuHideConfig(value) { return store.set("autoHideMenuBar", value); } +/** + * @param {BrowserWindow} window + * @param {boolean} [root] + */ function getSaveFolder(window, root = false) { if (root) { return path.join(app.getPath("userData"), "/saves"); } + // TODO: check undefined gameInfo case const identifier = window.gameInfo?.player?.identifier ?? ""; return path.join(app.getPath("userData"), "/saves", `/${identifier}`); } @@ -126,30 +147,93 @@ function isCloudEnabled() { return true; } +/** + * @param {string} name + * @param {string} content + */ function saveCloudFile(name, content) { - steamworksClient.cloud.writeFile(name, content); + if (!steamworksClient) { + return; + } + const result = steamworksClient.cloud.writeFile(name, content); + if (!result) { + log.warn(`Cannot write Steam Cloud save file: ${name}`); + } } -function getFilenameOfFirstCloudFile() { +/** @param {import("@catloversg/steamworks.js/client").cloud.FileInfo[]} files */ +function logCloudFiles(files) { + for (const file of files) { + log.debug( + `Name: ${file.name}. Size: ${file.size}. ` + + `isFilePersisted: ${steamworksClient?.cloud.isFilePersisted(file.name)}. ` + + `timestamp: ${steamworksClient?.cloud.fileTimestamp(file.name)}`, + ); + } +} + +function getFilenameOfMostRecentlyPersistedCloudFile() { + if (!steamworksClient) { + return null; + } const files = steamworksClient.cloud.listFiles(); if (files.length === 0) { - throw new Error("No files in cloud"); + return null; } - const file = files[0]; - log.silly(`Found ${files.length} files.`); - log.silly(`First File: ${file.name} (${file.size} bytes)`); + const filteredFiles = files + // @ts-expect-error - https://github.com/microsoft/TypeScript/issues/9998 + .filter((file) => steamworksClient.cloud.isFilePersisted(file.name)) + .map((file) => ({ + file, + // @ts-expect-error - https://github.com/microsoft/TypeScript/issues/9998 + timestamp: steamworksClient.cloud.fileTimestamp(file.name), + })) + .sort((a, b) => b.timestamp - a.timestamp) + .map((item) => item.file); + if (filteredFiles.length === 0) { + log.warn("Found cloud file(s) but none are persisted"); + logCloudFiles(files); + return null; + } + if (filteredFiles.length > 1) { + log.warn("Found more than 1 persisted cloud file"); + logCloudFiles(files); + } + const file = filteredFiles[0]; + log.debug(`Found ${filteredFiles.length} files.`); + log.debug(`First File: ${file.name} (${file.size} bytes)`); return file.name; } function getCloudFile() { - return steamworksClient.cloud.readFile(getFilenameOfFirstCloudFile()); + if (!steamworksClient) { + return null; + } + const filename = getFilenameOfMostRecentlyPersistedCloudFile(); + if (filename === null) { + return null; + } + return steamworksClient.cloud.readFile(filename); } -function deleteCloudFile() { - return steamworksClient.cloud.deleteFile(getFilenameOfFirstCloudFile()); +function deleteCloudFiles() { + if (!steamworksClient) { + return; + } + for (const file of steamworksClient.cloud.listFiles()) { + if (steamworksClient.cloud.deleteFile(file.name)) { + log.info(`Deleted Steam cloud file: ${file.name}`); + } else { + log.warn(`Cannot delete Steam cloud file: ${file.name}`); + } + } } +/** @param {string} currentPlayerId */ async function backupSteamDataToDisk(currentPlayerId) { + if (!steamworksClient) { + return; + } const files = steamworksClient.cloud.listFiles(); if (files.length === 0) { return; @@ -169,16 +253,24 @@ async function backupSteamDataToDisk(currentPlayerId) { * 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. + * + * @param {SaveData} saveData + * @param {string} currentPlayerId + * @returns */ async function pushSaveDataToSteamCloud(saveData, currentPlayerId) { + // TODO: Check whether we really need to throw an error here or if we can log and return. if (!isCloudEnabled()) { - return Promise.reject("Steam Cloud is not Enabled"); + throw new Error("Steam Cloud is not enabled"); } + // TODO: Refactor this function and backupSteamDataToDisk. backupSteamDataToDisk is not really useful (it tries to + // back up the first cloud file). In the case of having multiple cloud files, calling backupSteamDataToDisk and + // saveCloudFile like this is useless. try { await backupSteamDataToDisk(currentPlayerId); } catch (error) { - log.error(error); + log.error("Cannot back up Steam data to disk", error); } const steamSaveName = `${currentPlayerId}.json.gz`; @@ -202,7 +294,7 @@ async function pushSaveDataToSteamCloud(saveData, currentPlayerId) { try { saveCloudFile(steamSaveName, content); } catch (error) { - log.error(error); + log.error("Cannot save cloud file", error); } } @@ -211,16 +303,25 @@ async function pushSaveDataToSteamCloud(saveData, currentPlayerId) { */ async function getSteamCloudSaveData() { if (!isCloudEnabled()) { - return Promise.reject("Steam Cloud is not Enabled"); + throw new Error("Steam Cloud is not enabled"); } - log.debug(`Fetching Save in Steam Cloud`); + log.debug("Fetching Save in Steam Cloud"); const cloudString = getCloudFile(); + // TODO: Refactor this function and its callers. Not having a cloud file is a valid case. + if (cloudString === null) { + throw new Error("Cannot get cloud file"); + } // Decode cloudString to get save data back. const saveData = Buffer.from(cloudString, "base64"); log.debug(`SaveData: ${saveData.length} bytes`); return saveData; } +/** + * @param {BrowserWindow} window + * @param {{save: SaveData, fileName: string}} electronGameData + * @returns + */ async function saveGameToDisk(window, electronGameData) { const currentFolder = getSaveFolder(window); let saveFolderSizeBytes = await getFolderSizeInBytes(currentFolder); @@ -269,6 +370,7 @@ async function saveGameToDisk(window, electronGameData) { return file; } +/** @param {BrowserWindow} window */ async function loadLastFromDisk(window) { const folder = getSaveFolder(window); const last = await getNewestFile(folder); @@ -276,6 +378,7 @@ async function loadLastFromDisk(window) { return loadFileFromDisk(last.file); } +/** @param {string} path */ async function loadFileFromDisk(path) { const buffer = await fs.readFile(path); let content; @@ -293,47 +396,60 @@ async function loadFileFromDisk(path) { return content; } +/** + * @param {BrowserWindow} window + * @param {SaveData} save + */ function getSaveInformation(window, save) { return new Promise((resolve) => { - ipcMain.once("get-save-info-response", (event, data) => { + ipcMain.once("get-save-info-response", (__event, data) => { resolve(data); }); window.webContents.send("get-save-info-request", save); }); } +/** @param {BrowserWindow} window */ function getCurrentSave(window) { return new Promise((resolve) => { - ipcMain.once("get-save-data-response", (event, data) => { + ipcMain.once("get-save-data-response", (__event, data) => { resolve(data); }); window.webContents.send("get-save-data-request"); }); } +/** + * @param {BrowserWindow} window + * @param {SaveData} save + * @param {boolean} automatic + */ function pushSaveGameForImport(window, save, automatic) { - ipcMain.once("push-import-result", (event, arg) => { + ipcMain.once("push-import-result", (__event, arg) => { log.debug(`Was save imported? ${arg.wasImported ? "Yes" : "No"}`); }); window.webContents.send("push-save-request", { save, automatic }); } +/** @param {BrowserWindow} window */ async function restoreIfNewerExists(window) { const currentSave = await getCurrentSave(window); const currentData = await getSaveInformation(window, currentSave.save); const steam = {}; const disk = {}; - try { - steam.save = await getSteamCloudSaveData(); - steam.data = await getSaveInformation(window, steam.save); - } catch (error) { - log.error("Could not retrieve steam file"); - log.debug(error); + if (isCloudEnabled()) { + // TODO: Check if we can refactor to avoid using a try-catch block. + try { + steam.save = await getSteamCloudSaveData(); + steam.data = await getSaveInformation(window, steam.save); + } catch (error) { + log.error("Could not retrieve Steam cloud file", error); + } } try { - const saves = (await getAllSaves()).sort((a, b) => b.stat.mtime.getTime() - a.stat.mtime.getTime()); + const saves = (await getAllSaves(window)).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); @@ -388,7 +504,7 @@ module.exports = { pushSaveGameForImport, pushSaveDataToSteamCloud, getSteamCloudSaveData, - deleteCloudFile, + deleteCloudFiles, saveGameToDisk, loadLastFromDisk, loadFileFromDisk,