ELECTRON: Import correct cloud file when multiple exist (#2599)

This commit is contained in:
catloversg
2026-03-27 08:23:09 +07:00
committed by GitHub
parent eacdc081df
commit 92a8e619b8
8 changed files with 194 additions and 42 deletions

View File

@@ -55,5 +55,15 @@ module.exports = {
"@typescript-eslint/prefer-literal-enum-member": ["off"], "@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",
},
},
], ],
}; };

23
electron/global.d.ts vendored Normal file
View File

@@ -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;
};
};
}
}
}

View File

@@ -357,13 +357,12 @@ function getMenu(window) {
label: "Delete Steam Cloud Data", label: "Delete Steam Cloud Data",
enabled: steamworksClient !== undefined, enabled: steamworksClient !== undefined,
click: () => { click: () => {
if (steamworksClient.cloud.listFiles().length === 0) { if (steamworksClient === undefined || steamworksClient.cloud.listFiles().length === 0) {
log.info("There is no Steam cloud file");
return; return;
} }
try { try {
if (!storage.deleteCloudFile()) { storage.deleteCloudFiles();
log.warn("Cannot delete Steam Cloud data");
}
} catch (error) { } catch (error) {
log.error(error); log.error(error);
} }

View File

@@ -8,7 +8,7 @@
"name": "bitburner", "name": "bitburner",
"version": "3.0.0", "version": "3.0.0",
"dependencies": { "dependencies": {
"@catloversg/steamworks.js": "0.0.2", "@catloversg/steamworks.js": "0.0.3",
"arg": "^5.0.2", "arg": "^5.0.2",
"electron-log": "^4.4.8", "electron-log": "^4.4.8",
"electron-store": "^8.1.0", "electron-store": "^8.1.0",
@@ -16,9 +16,9 @@
} }
}, },
"node_modules/@catloversg/steamworks.js": { "node_modules/@catloversg/steamworks.js": {
"version": "0.0.2", "version": "0.0.3",
"resolved": "https://registry.npmjs.org/@catloversg/steamworks.js/-/steamworks.js-0.0.2.tgz", "resolved": "https://registry.npmjs.org/@catloversg/steamworks.js/-/steamworks.js-0.0.3.tgz",
"integrity": "sha512-EPB7vQFZa0zGw+Ft4SHiHIDZ7UcuM/XUiyzPo5a9Pf+g5XmcvjIEjo8wKwk7Ox0DOjmSJ+s8GmOD/TDDQW5jgg==", "integrity": "sha512-fQhWQ0FNuFgO7zC1t009+jiPUTBL0VeH6pxYDMbEaauRw64o6wy4lGG9gJ9Rq+Rp/wXKs/BqNMFPLpdxRXprRQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "*" "@types/node": "*"

View File

@@ -24,7 +24,7 @@
"buildResources": "public" "buildResources": "public"
}, },
"dependencies": { "dependencies": {
"@catloversg/steamworks.js": "0.0.2", "@catloversg/steamworks.js": "0.0.3",
"arg": "^5.0.2", "arg": "^5.0.2",
"electron-log": "^4.4.8", "electron-log": "^4.4.8",
"electron-store": "^8.1.0", "electron-store": "^8.1.0",

View File

@@ -1,4 +1,4 @@
export declare const encodeBytesToBase64String: (bytes: Uint8Array<ArrayBuffer>) => string; export declare const encodeBytesToBase64String: (bytes: Uint8Array<ArrayBufferLike>) => string;
export declare const decodeBase64BytesToBytes: (bytes: Uint8Array<ArrayBuffer>) => Uint8Array<ArrayBuffer>; export declare const decodeBase64BytesToBytes: (bytes: Uint8Array<ArrayBufferLike>) => Uint8Array<ArrayBuffer>;
export declare const isBinaryFormat: (saveData: string | Uint8Array<ArrayBuffer>) => boolean; export declare const isBinaryFormat: (saveData: string | Uint8Array<ArrayBufferLike>) => boolean;
export declare const isSteamCloudFormat: (saveData: string | Uint8Array<ArrayBuffer>) => boolean; export declare const isSteamCloudFormat: (saveData: string | Uint8Array<ArrayBufferLike>) => boolean;

View File

@@ -2,17 +2,21 @@
const steamworks = require("@catloversg/steamworks.js"); const steamworks = require("@catloversg/steamworks.js");
const log = require("electron-log"); const log = require("electron-log");
let steamworksClient; /** @type {ReturnType<typeof import("@catloversg/steamworks.js").init> | undefined} */
let steamworksClient = undefined;
try { try {
// 1812820 is our Steam App ID. // 1812820 is our Steam App ID.
steamworksClient = steamworks.init(1812820); steamworksClient = steamworks.init(1812820);
} catch (error) { } catch (error) {
if (error.message?.includes("Steam is probably not running")) { if (error instanceof Error) {
log.warn(error.message); log.warn(error.message);
global.steamworksError = error;
} else { } 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 = { module.exports = {

View File

@@ -1,3 +1,5 @@
/** @import { BrowserWindow } from "electron" */
/** @typedef {string | Uint8Array} SaveData */
/* eslint-disable @typescript-eslint/no-var-requires */ /* eslint-disable @typescript-eslint/no-var-requires */
const { app, ipcMain } = require("electron"); const { app, ipcMain } = require("electron");
const path = require("path"); const path = require("path");
@@ -10,6 +12,7 @@ const { decodeBase64BytesToBytes, isBinaryFormat, isSteamCloudFormat } = require
const store = new Store(); const store = new Store();
const { steamworksClient } = require("./steamworksUtils"); const { steamworksClient } = require("./steamworksUtils");
/** @param {string} directory */
// https://stackoverflow.com/a/69418940 // https://stackoverflow.com/a/69418940
const dirSize = async (directory) => { const dirSize = async (directory) => {
const files = await fs.readdir(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); return (await Promise.all(stats)).reduce((accumulator, { size }) => accumulator + size, 0);
}; };
/** @param {string} directory */
const getDirFileStats = async (directory) => { const getDirFileStats = async (directory) => {
const files = await fs.readdir(directory); const files = await fs.readdir(directory);
const stats = files.map((f) => { const stats = files.map((f) => {
@@ -27,11 +31,13 @@ const getDirFileStats = async (directory) => {
return data; return data;
}; };
/** @param {string} directory */
const getNewestFile = async (directory) => { const getNewestFile = async (directory) => {
const data = await getDirFileStats(directory); const data = await getDirFileStats(directory);
return data.sort((a, b) => b.stat.mtime.getTime() - a.stat.mtime.getTime())[0]; return data.sort((a, b) => b.stat.mtime.getTime() - a.stat.mtime.getTime())[0];
}; };
/** @param {BrowserWindow} window */
const getAllSaves = async (window) => { const getAllSaves = async (window) => {
const rootDirectory = getSaveFolder(window, true); const rootDirectory = getSaveFolder(window, true);
const data = await fs.readdir(rootDirectory, { withFileTypes: true }); const data = await fs.readdir(rootDirectory, { withFileTypes: true });
@@ -44,6 +50,7 @@ const getAllSaves = async (window) => {
return flat; return flat;
}; };
/** @param {BrowserWindow} window */
async function prepareSaveFolders(window) { async function prepareSaveFolders(window) {
const rootFolder = getSaveFolder(window, true); const rootFolder = getSaveFolder(window, true);
const currentFolder = getSaveFolder(window); const currentFolder = getSaveFolder(window);
@@ -51,12 +58,15 @@ async function prepareSaveFolders(window) {
await prepareFolders(rootFolder, currentFolder, backupsFolder); await prepareFolders(rootFolder, currentFolder, backupsFolder);
} }
/** @param {...string} folders */
async function prepareFolders(...folders) { async function prepareFolders(...folders) {
for (const folder of folders) { for (const folder of folders) {
try { try {
// Making sure the folder exists // Making sure the folder exists
await fs.stat(folder); await fs.stat(folder);
} catch (error) { } 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") { if (error.code === "ENOENT") {
log.warn(`'${folder}' not found, creating it...`); log.warn(`'${folder}' not found, creating it...`);
await fs.mkdir(folder); 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) { async function getFolderSizeInBytes(saveFolder) {
try { try {
return await dirSize(saveFolder); return await dirSize(saveFolder);
@@ -75,6 +88,7 @@ async function getFolderSizeInBytes(saveFolder) {
} }
} }
/** @param {boolean} value */
function setAutosaveConfig(value) { function setAutosaveConfig(value) {
store.set("autosave-enabled", value); store.set("autosave-enabled", value);
} }
@@ -83,6 +97,7 @@ function isAutosaveEnabled() {
return store.get("autosave-enabled", true); return store.get("autosave-enabled", true);
} }
/** @param {boolean} value */
function setCloudEnabledConfig(value) { function setCloudEnabledConfig(value) {
store.set("cloud-enabled", value); store.set("cloud-enabled", value);
} }
@@ -91,14 +106,20 @@ function isMenuHideEnabled() {
return store.get("autoHideMenuBar", false); return store.get("autoHideMenuBar", false);
} }
/** @param {boolean} value */
function setMenuHideConfig(value) { function setMenuHideConfig(value) {
return store.set("autoHideMenuBar", value); return store.set("autoHideMenuBar", value);
} }
/**
* @param {BrowserWindow} window
* @param {boolean} [root]
*/
function getSaveFolder(window, root = false) { function getSaveFolder(window, root = false) {
if (root) { if (root) {
return path.join(app.getPath("userData"), "/saves"); return path.join(app.getPath("userData"), "/saves");
} }
// TODO: check undefined gameInfo case
const identifier = window.gameInfo?.player?.identifier ?? ""; const identifier = window.gameInfo?.player?.identifier ?? "";
return path.join(app.getPath("userData"), "/saves", `/${identifier}`); return path.join(app.getPath("userData"), "/saves", `/${identifier}`);
} }
@@ -126,30 +147,93 @@ function isCloudEnabled() {
return true; return true;
} }
/**
* @param {string} name
* @param {string} content
*/
function saveCloudFile(name, 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(); const files = steamworksClient.cloud.listFiles();
if (files.length === 0) { if (files.length === 0) {
throw new Error("No files in cloud"); return null;
} }
const file = files[0]; const filteredFiles = files
log.silly(`Found ${files.length} files.`); // @ts-expect-error - https://github.com/microsoft/TypeScript/issues/9998
log.silly(`First File: ${file.name} (${file.size} bytes)`); .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; return file.name;
} }
function getCloudFile() { 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() { function deleteCloudFiles() {
return steamworksClient.cloud.deleteFile(getFilenameOfFirstCloudFile()); 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) { async function backupSteamDataToDisk(currentPlayerId) {
if (!steamworksClient) {
return;
}
const files = steamworksClient.cloud.listFiles(); const files = steamworksClient.cloud.listFiles();
if (files.length === 0) { if (files.length === 0) {
return; 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 * 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 * 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. * 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) { 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()) { 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 { try {
await backupSteamDataToDisk(currentPlayerId); await backupSteamDataToDisk(currentPlayerId);
} catch (error) { } catch (error) {
log.error(error); log.error("Cannot back up Steam data to disk", error);
} }
const steamSaveName = `${currentPlayerId}.json.gz`; const steamSaveName = `${currentPlayerId}.json.gz`;
@@ -202,7 +294,7 @@ async function pushSaveDataToSteamCloud(saveData, currentPlayerId) {
try { try {
saveCloudFile(steamSaveName, content); saveCloudFile(steamSaveName, content);
} catch (error) { } catch (error) {
log.error(error); log.error("Cannot save cloud file", error);
} }
} }
@@ -211,16 +303,25 @@ async function pushSaveDataToSteamCloud(saveData, currentPlayerId) {
*/ */
async function getSteamCloudSaveData() { async function getSteamCloudSaveData() {
if (!isCloudEnabled()) { 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(); 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. // Decode cloudString to get save data back.
const saveData = Buffer.from(cloudString, "base64"); const saveData = Buffer.from(cloudString, "base64");
log.debug(`SaveData: ${saveData.length} bytes`); log.debug(`SaveData: ${saveData.length} bytes`);
return saveData; return saveData;
} }
/**
* @param {BrowserWindow} window
* @param {{save: SaveData, fileName: string}} electronGameData
* @returns
*/
async function saveGameToDisk(window, electronGameData) { async function saveGameToDisk(window, electronGameData) {
const currentFolder = getSaveFolder(window); const currentFolder = getSaveFolder(window);
let saveFolderSizeBytes = await getFolderSizeInBytes(currentFolder); let saveFolderSizeBytes = await getFolderSizeInBytes(currentFolder);
@@ -269,6 +370,7 @@ async function saveGameToDisk(window, electronGameData) {
return file; return file;
} }
/** @param {BrowserWindow} window */
async function loadLastFromDisk(window) { async function loadLastFromDisk(window) {
const folder = getSaveFolder(window); const folder = getSaveFolder(window);
const last = await getNewestFile(folder); const last = await getNewestFile(folder);
@@ -276,6 +378,7 @@ async function loadLastFromDisk(window) {
return loadFileFromDisk(last.file); return loadFileFromDisk(last.file);
} }
/** @param {string} path */
async function loadFileFromDisk(path) { async function loadFileFromDisk(path) {
const buffer = await fs.readFile(path); const buffer = await fs.readFile(path);
let content; let content;
@@ -293,47 +396,60 @@ async function loadFileFromDisk(path) {
return content; return content;
} }
/**
* @param {BrowserWindow} window
* @param {SaveData} save
*/
function getSaveInformation(window, save) { function getSaveInformation(window, save) {
return new Promise((resolve) => { return new Promise((resolve) => {
ipcMain.once("get-save-info-response", (event, data) => { ipcMain.once("get-save-info-response", (__event, data) => {
resolve(data); resolve(data);
}); });
window.webContents.send("get-save-info-request", save); window.webContents.send("get-save-info-request", save);
}); });
} }
/** @param {BrowserWindow} window */
function getCurrentSave(window) { function getCurrentSave(window) {
return new Promise((resolve) => { return new Promise((resolve) => {
ipcMain.once("get-save-data-response", (event, data) => { ipcMain.once("get-save-data-response", (__event, data) => {
resolve(data); resolve(data);
}); });
window.webContents.send("get-save-data-request"); window.webContents.send("get-save-data-request");
}); });
} }
/**
* @param {BrowserWindow} window
* @param {SaveData} save
* @param {boolean} automatic
*/
function pushSaveGameForImport(window, save, 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"}`); log.debug(`Was save imported? ${arg.wasImported ? "Yes" : "No"}`);
}); });
window.webContents.send("push-save-request", { save, automatic }); window.webContents.send("push-save-request", { save, automatic });
} }
/** @param {BrowserWindow} window */
async function restoreIfNewerExists(window) { async function restoreIfNewerExists(window) {
const currentSave = await getCurrentSave(window); const currentSave = await getCurrentSave(window);
const currentData = await getSaveInformation(window, currentSave.save); const currentData = await getSaveInformation(window, currentSave.save);
const steam = {}; const steam = {};
const disk = {}; const disk = {};
try { if (isCloudEnabled()) {
steam.save = await getSteamCloudSaveData(); // TODO: Check if we can refactor to avoid using a try-catch block.
steam.data = await getSaveInformation(window, steam.save); try {
} catch (error) { steam.save = await getSteamCloudSaveData();
log.error("Could not retrieve steam file"); steam.data = await getSaveInformation(window, steam.save);
log.debug(error); } catch (error) {
log.error("Could not retrieve Steam cloud file", error);
}
} }
try { 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) { if (saves.length > 0) {
disk.save = await loadFileFromDisk(saves[0].file); disk.save = await loadFileFromDisk(saves[0].file);
disk.data = await getSaveInformation(window, disk.save); disk.data = await getSaveInformation(window, disk.save);
@@ -388,7 +504,7 @@ module.exports = {
pushSaveGameForImport, pushSaveGameForImport,
pushSaveDataToSteamCloud, pushSaveDataToSteamCloud,
getSteamCloudSaveData, getSteamCloudSaveData,
deleteCloudFile, deleteCloudFiles,
saveGameToDisk, saveGameToDisk,
loadLastFromDisk, loadLastFromDisk,
loadFileFromDisk, loadFileFromDisk,