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/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/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, + };