diff --git a/electron/package-lock.json b/electron/package-lock.json index 9faab32c..820d1431 100644 --- a/electron/package-lock.json +++ b/electron/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "MIT", "dependencies": { + "@msgpack/msgpack": "^3.1.1", "semver": "^7.7.1", "zod": "^3.24.2" }, @@ -50,6 +51,15 @@ "semver": "bin/semver.js" } }, + "node_modules/@msgpack/msgpack": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-3.1.1.tgz", + "integrity": "sha512-DnBpqkMOUGayNVKyTLlkM6ILmU/m/+VUxGkuQlPQVAcvreLz5jn1OlQnWd8uHKL/ZSiljpM12rjRhr51VtvJUQ==", + "license": "ISC", + "engines": { + "node": ">= 18" + } + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", diff --git a/electron/package.json b/electron/package.json index 13cfffa3..86debdb5 100644 --- a/electron/package.json +++ b/electron/package.json @@ -9,6 +9,7 @@ "start": "tsc && electron ." }, "dependencies": { + "@msgpack/msgpack": "^3.1.1", "semver": "^7.7.1", "zod": "^3.24.2" }, diff --git a/electron/src/fsjob.ts b/electron/src/fsjob.ts index 0e85ea69..9cc3c866 100644 --- a/electron/src/fsjob.ts +++ b/electron/src/fsjob.ts @@ -1,68 +1,129 @@ +import { BrowserWindow, dialog, FileFilter } from "electron"; import fs from "fs/promises"; import path from "path"; import { userData } from "./config.js"; +import { StorageInterface } from "./storage/interface.js"; interface GenericFsJob { - filename: string; + id: string; } -type ListFsJob = GenericFsJob & { type: "list" }; -type ReadFsJob = GenericFsJob & { type: "read" }; -type WriteFsJob = GenericFsJob & { type: "write"; contents: string }; -type DeleteFsJob = GenericFsJob & { type: "delete" }; +export type InitializeFsJob = GenericFsJob & { type: "initialize" }; +type ListFsJob = GenericFsJob & { type: "list"; filename: string }; +type ReadFsJob = GenericFsJob & { type: "read"; filename: string }; +type WriteFsJob = GenericFsJob & { type: "write"; filename: string; contents: T }; +type DeleteFsJob = GenericFsJob & { type: "delete"; filename: string }; -export type FsJob = ListFsJob | ReadFsJob | WriteFsJob | DeleteFsJob; -type FsJobResult = string | string[] | void; +type OpenExternalFsJob = GenericFsJob & { type: "open-external"; extension: string }; +type SaveExternalFsJob = GenericFsJob & { type: "save-external"; filename: string; contents: T }; -export class FsJobHandler { +export type FsJob = + | InitializeFsJob + | ListFsJob + | ReadFsJob + | WriteFsJob + | DeleteFsJob + | OpenExternalFsJob + | SaveExternalFsJob; +type FsJobResult = T | string[] | void; + +export class FsJobHandler { readonly rootDir: string; + private readonly storage: StorageInterface; + private initialized = false; - constructor(subDir: string) { + constructor(subDir: string, storage: StorageInterface) { this.rootDir = path.join(userData, subDir); + this.storage = storage; } - handleJob(job: FsJob): Promise { + async initialize(): Promise { + if (this.initialized) { + return; + } + + // Create the directory so that users know where to put files + await fs.mkdir(this.rootDir, { recursive: true }); + this.initialized = true; + } + + handleJob(job: FsJob): Promise> { + switch (job.type) { + case "initialize": + return this.initialize(); + case "open-external": + return this.openExternal(job.extension); + case "save-external": + return this.saveExternal(job.filename, job.contents); + } + const filename = this.safeFileName(job.filename); switch (job.type) { case "list": return this.list(filename); case "read": - return this.read(filename); + return this.storage.read(filename); case "write": return this.write(filename, job.contents); case "delete": - return this.delete(filename); + return this.storage.delete(filename); } // @ts-expect-error this method can actually receive garbage throw new Error(`Unknown FS job type: ${job.type}`); } + private async openExternal(extension: string): Promise { + const filters = this.getFileDialogFilters(extension === "*" ? undefined : extension); + const window = BrowserWindow.getAllWindows()[0]!; + + const result = await dialog.showOpenDialog(window, { filters, properties: ["openFile"] }); + if (result.canceled) { + return undefined; + } + + return await this.storage.read(result.filePaths[0]); + } + + private async saveExternal(filename: string, contents: T): Promise { + // Try to guess extension + const ext = filename.indexOf(".") < 1 ? filename.split(".").at(-1)! : undefined; + const filters = this.getFileDialogFilters(ext); + const window = BrowserWindow.getAllWindows()[0]!; + + const result = await dialog.showSaveDialog(window, { defaultPath: filename, filters }); + if (result.canceled) { + return; + } + + return await this.storage.write(result.filePath, contents); + } + + private getFileDialogFilters(extension?: string): FileFilter[] { + const filters: FileFilter[] = [{ name: "All files", extensions: ["*"] }]; + + if (extension !== undefined) { + filters.unshift({ + name: `${extension.toUpperCase()} files`, + extensions: [extension], + }); + } + + return filters; + } + private list(subdir: string): Promise { // Bare-bones implementation return fs.readdir(subdir); } - private read(file: string): Promise { - return fs.readFile(file, "utf-8"); - } - - private async write(file: string, contents: string): Promise { + private async write(file: string, contents: T): Promise { // The target directory might not exist, ensure it does const parentDir = path.dirname(file); await fs.mkdir(parentDir, { recursive: true }); - // Backups not implemented yet. - await fs.writeFile(file, contents, { - encoding: "utf-8", - flush: true, - }); - return contents; - } - - private delete(file: string): Promise { - return fs.unlink(file); + await this.storage.write(file, contents); } private safeFileName(name: string) { diff --git a/electron/src/index.ts b/electron/src/index.ts index 8522b4f0..884ed847 100644 --- a/electron/src/index.ts +++ b/electron/src/index.ts @@ -1,7 +1,6 @@ import { BrowserWindow, app, shell } from "electron"; import path from "path"; import { defaultWindowTitle, pageUrl, switches } from "./config.js"; -import { FsJobHandler } from "./fsjob.js"; import { IpcHandler } from "./ipc.js"; import { ModLoader } from "./mods/loader.js"; import { ModProtocolHandler } from "./mods/protocol_handler.js"; @@ -20,15 +19,9 @@ if (!app.requestSingleInstanceLock()) { }); } -// TODO: Implement a redirector/advanced storage system -// Let mods have own data directories with easy access and -// split savegames/configs - only implement backups and gzip -// files if requested. Perhaps, use streaming to make large -// transfers less "blocking" -const fsJob = new FsJobHandler("saves"); const modLoader = new ModLoader(); const modProtocol = new ModProtocolHandler(modLoader); -const ipc = new IpcHandler(fsJob, modLoader); +const ipc = new IpcHandler(modLoader); function createWindow() { // The protocol can only be handled after "ready" event diff --git a/electron/src/ipc.ts b/electron/src/ipc.ts index 43a6b0d4..d44c972e 100644 --- a/electron/src/ipc.ts +++ b/electron/src/ipc.ts @@ -1,13 +1,13 @@ import { BrowserWindow, IpcMainInvokeEvent, ipcMain } from "electron"; import { FsJob, FsJobHandler } from "./fsjob.js"; import { ModLoader } from "./mods/loader.js"; +import { SavesStorage } from "./storage/saves.js"; export class IpcHandler { - private readonly fsJob: FsJobHandler; + private readonly savesHandler = new FsJobHandler("saves", new SavesStorage()); private readonly modLoader: ModLoader; - constructor(fsJob: FsJobHandler, modLoader: ModLoader) { - this.fsJob = fsJob; + constructor(modLoader: ModLoader) { this.modLoader = modLoader; } @@ -20,8 +20,12 @@ export class IpcHandler { // ipcMain.handle("open-mods-folder", ...) } - private handleFsJob(_event: IpcMainInvokeEvent, job: FsJob) { - return this.fsJob.handleJob(job); + private handleFsJob(_event: IpcMainInvokeEvent, job: FsJob) { + if (job.id !== "saves") { + throw new Error("Storages other than saves/ are not implemented yet"); + } + + return this.savesHandler.handleJob(job); } private async getMods() { diff --git a/electron/src/storage/interface.ts b/electron/src/storage/interface.ts new file mode 100644 index 00000000..f0e59804 --- /dev/null +++ b/electron/src/storage/interface.ts @@ -0,0 +1,5 @@ +export interface StorageInterface { + read(file: string): Promise; + write(file: string, contents: T): Promise; + delete(file: string): Promise; +} diff --git a/electron/src/storage/raw.ts b/electron/src/storage/raw.ts new file mode 100644 index 00000000..0d6a1a56 --- /dev/null +++ b/electron/src/storage/raw.ts @@ -0,0 +1,16 @@ +import fs from "node:fs/promises"; +import { StorageInterface } from "./interface.js"; + +export class RawStorage implements StorageInterface { + read(file: string): Promise { + return fs.readFile(file, "utf-8"); + } + + write(file: string, contents: string): Promise { + return fs.writeFile(file, contents, "utf-8"); + } + + delete(file: string): Promise { + return fs.unlink(file); + } +} diff --git a/electron/src/storage/saves.ts b/electron/src/storage/saves.ts new file mode 100644 index 00000000..750bd571 --- /dev/null +++ b/electron/src/storage/saves.ts @@ -0,0 +1,54 @@ +import { decodeAsync, encode } from "@msgpack/msgpack"; +import fs from "node:fs"; +import { pipeline } from "node:stream/promises"; +import { createGunzip, createGzip } from "node:zlib"; +import { StorageInterface } from "./interface.js"; + +/** + * This storage implementation is used for savegame files and other + * ReadWriteProxy objects. It uses gzipped MessagePack as the file format. + */ +export class SavesStorage implements StorageInterface { + async read(file: string): Promise { + const stream = fs.createReadStream(file); + const gunzip = createGunzip(); + + try { + // Any filesystem errors will be uncovered here. This code ensures we return the most + // relevant rejection, or resolve with the decoded data + const [readResult, decodeResult] = await Promise.allSettled([ + pipeline(stream, gunzip), + decodeAsync(gunzip), + ]); + + if (decodeResult.status === "fulfilled") { + return decodeResult.value; + } + + // Return the most relevant error + throw readResult.status === "rejected" ? readResult.reason : decodeResult.reason; + } finally { + stream.close(); + gunzip.close(); + } + } + + async write(file: string, contents: unknown): Promise { + const stream = fs.createWriteStream(file); + const gzip = createGzip(); + + try { + const encoded = encode(contents); + const blob = new Blob([encoded]); + + return await pipeline(blob.stream(), gzip, stream); + } finally { + gzip.close(); + stream.close(); + } + } + + delete(file: string): Promise { + return fs.promises.unlink(file); + } +} diff --git a/src/js/application.js b/src/js/application.js index b61eb923..4a0bc895 100644 --- a/src/js/application.js +++ b/src/js/application.js @@ -13,7 +13,7 @@ import { MOD_SIGNALS } from "./mods/mod_signals"; import { MODS } from "./mods/modloader"; import { ClientAPI } from "./platform/api"; import { Sound } from "./platform/sound"; -import { Storage } from "./platform/storage"; +import { Storage, STORAGE_SAVES } from "./platform/storage"; import { PlatformWrapperImplElectron } from "./platform/wrapper"; import { ApplicationSettings } from "./profile/application_settings"; import { SavegameManager } from "./savegame/savegame_manager"; @@ -55,7 +55,9 @@ export class Application { this.unloaded = false; // Platform stuff - this.storage = new Storage(this); + this.storage = new Storage(this, STORAGE_SAVES); + await this.storage.initialize(); + this.platformWrapper = new PlatformWrapperImplElectron(this); // Global stuff diff --git a/src/js/core/async_compression.js b/src/js/core/async_compression.js deleted file mode 100644 index ea5177e5..00000000 --- a/src/js/core/async_compression.js +++ /dev/null @@ -1,101 +0,0 @@ -// @ts-expect-error FIXME: missing typings -import CompressionWorker from "../webworkers/compression.worker"; - -import { createLogger } from "./logging"; -import { round2Digits } from "./utils"; - -const logger = createLogger("async_compression"); - -export const compressionPrefix = String.fromCodePoint(1); - -/** - * @typedef {{ - * errorHandler: function(any) : void, - * resolver: function(any) : void, - * startTime: number - * }} JobEntry - */ - -class AsynCompression { - constructor() { - this.worker = new CompressionWorker(); - - this.currentJobId = 1000; - - /** @type {Object.} */ - this.currentJobs = {}; - - this.worker.addEventListener("message", event => { - const { jobId, result } = event.data; - const jobData = this.currentJobs[jobId]; - if (!jobData) { - logger.error("Failed to resolve job result, job id", jobId, "is not known"); - return; - } - - const duration = performance.now() - jobData.startTime; - logger.log( - "Got job", - jobId, - "response within", - round2Digits(duration), - "ms: ", - result.length, - "bytes" - ); - const resolver = jobData.resolver; - delete this.currentJobs[jobId]; - resolver(result); - }); - - this.worker.addEventListener("error", err => { - logger.error("Got error from webworker:", err, "aborting all jobs"); - const failureCalls = []; - for (const jobId in this.currentJobs) { - failureCalls.push(this.currentJobs[jobId].errorHandler); - } - this.currentJobs = {}; - for (let i = 0; i < failureCalls.length; ++i) { - failureCalls[i](err); - } - }); - } - - /** - * Compresses any object - * @param {any} obj - */ - compressObjectAsync(obj) { - logger.log("Compressing object async (optimized)"); - return this.internalQueueJob("compressObject", { - obj, - compressionPrefix, - }); - } - - /** - * Queues a new job - * @param {string} job - * @param {any} data - * @returns {Promise} - */ - internalQueueJob(job, data) { - const jobId = ++this.currentJobId; - return new Promise((resolve, reject) => { - const errorHandler = err => { - logger.error("Failed to compress job", jobId, ":", err); - reject(err); - }; - this.currentJobs[jobId] = { - errorHandler, - resolver: resolve, - startTime: performance.now(), - }; - - logger.log("Posting job", job, "/", jobId); - this.worker.postMessage({ jobId, job, data }); - }); - } -} - -export const asyncCompressor = new AsynCompression(); diff --git a/src/js/core/read_write_proxy.js b/src/js/core/read_write_proxy.js index a7f03d52..23770f4d 100644 --- a/src/js/core/read_write_proxy.js +++ b/src/js/core/read_write_proxy.js @@ -3,12 +3,9 @@ import { Storage } from "@/platform/storage"; /* typehints:end */ import { FsError } from "@/platform/fs_error"; -import { asyncCompressor, compressionPrefix } from "./async_compression"; import { IS_DEBUG, globalConfig } from "./config"; import { ExplainedResult } from "./explained_result"; import { createLogger } from "./logging"; -import { compressX64, decompressX64 } from "./lzstring"; -import { computeCrc } from "./sensitive_utils.encrypt"; import debounce from "debounce-promise"; @@ -84,9 +81,8 @@ export class ReadWriteProxy { * @param {object} obj */ static serializeObject(obj) { - const jsonString = JSON.stringify(obj); - const checksum = computeCrc(jsonString + salt); - return compressionPrefix + compressX64(checksum + jsonString); + // TODO: Remove redundant method + return obj; } /** @@ -94,29 +90,8 @@ export class ReadWriteProxy { * @param {object} text */ static deserializeObject(text) { - const decompressed = decompressX64(text.substr(compressionPrefix.length)); - if (!decompressed) { - // LZ string decompression failure - throw new Error("bad-content / decompression-failed"); - } - if (decompressed.length < 40) { - // String too short - throw new Error("bad-content / payload-too-small"); - } - - // Compare stored checksum with actual checksum - const checksum = decompressed.substring(0, 40); - const jsonString = decompressed.substr(40); - - const desiredChecksum = computeCrc(jsonString + salt); - - if (desiredChecksum !== checksum) { - // Checksum mismatch - throw new Error("bad-content / checksum-mismatch"); - } - - const parsed = JSON.parse(jsonString); - return parsed; + // TODO: Remove redundant method + return text; } /** @@ -140,11 +115,8 @@ export class ReadWriteProxy { * @returns {Promise} */ doWriteAsync() { - return asyncCompressor - .compressObjectAsync(this.currentData) - .then(compressed => { - return this.storage.writeFileAsync(this.filename, compressed); - }) + return this.storage + .writeFileAsync(this.filename, this.currentData) .then(() => { logger.log("📄 Wrote", this.filename); }) @@ -178,59 +150,12 @@ export class ReadWriteProxy { .then(rawData => { if (rawData == null) { // So, the file has not been found, use default data - return JSON.stringify(this.getDefaultData()); + return this.getDefaultData(); } - if (rawData.startsWith(compressionPrefix)) { - const decompressed = decompressX64(rawData.substr(compressionPrefix.length)); - if (!decompressed) { - // LZ string decompression failure - return Promise.reject("bad-content / decompression-failed"); - } - if (decompressed.length < 40) { - // String too short - return Promise.reject("bad-content / payload-too-small"); - } - - // Compare stored checksum with actual checksum - const checksum = decompressed.substring(0, 40); - const jsonString = decompressed.slice(40); - - const desiredChecksum = computeCrc(jsonString + salt); - - if (desiredChecksum !== checksum) { - // Checksum mismatch - return Promise.reject( - "bad-content / checksum-mismatch: " + desiredChecksum + " vs " + checksum - ); - } - return jsonString; - } else { - if (!G_IS_DEV) { - return Promise.reject("bad-content / missing-compression"); - } - } return rawData; }) - // Parse JSON, this could throw but that's fine - .then(res => { - try { - return JSON.parse(res); - } catch (ex) { - logger.error( - "Failed to parse file content of", - this.filename, - ":", - ex, - "(content was:", - res, - ")" - ); - throw new Error("invalid-serialized-data"); - } - }) - // Verify basic structure .then(contents => { const result = this.internalVerifyBasicStructure(contents); diff --git a/src/js/core/utils.js b/src/js/core/utils.js index 291b5a42..a0e740bd 100644 --- a/src/js/core/utils.js +++ b/src/js/core/utils.js @@ -599,38 +599,6 @@ export function fillInLinkIntoTranslation(translation, link) { .replace("", ""); } -/** - * Generates a file download - * @param {string} filename - * @param {string} text - */ -export function generateFileDownload(filename, text) { - const element = document.createElement("a"); - element.setAttribute("href", "data:text/plain;charset=utf-8," + encodeURIComponent(text)); - element.setAttribute("download", filename); - - element.style.display = "none"; - document.body.appendChild(element); - - element.click(); - document.body.removeChild(element); -} - -/** - * Starts a file chooser - * @param {string} acceptedType - */ -export function startFileChoose(acceptedType = ".bin") { - const input = document.createElement("input"); - input.type = "file"; - input.accept = acceptedType; - - return new Promise(resolve => { - input.onchange = _ => resolve(input.files[0]); - input.click(); - }); -} - const MAX_ROMAN_NUMBER = 49; const romanLiteralsCache = ["0"]; diff --git a/src/js/platform/storage.ts b/src/js/platform/storage.ts index 250a0ee2..d947dc33 100644 --- a/src/js/platform/storage.ts +++ b/src/js/platform/storage.ts @@ -1,53 +1,68 @@ import { Application } from "@/application"; import { FsError } from "./fs_error"; +export const STORAGE_SAVES = "saves"; +export const STORAGE_MOD_PREFIX = "mod/"; + export class Storage { readonly app: Application; + readonly id: string; - constructor(app: Application) { + constructor(app: Application, id: string) { this.app = app; + this.id = id; } /** * Initializes the storage */ initialize(): Promise { - return Promise.resolve(); + return this.invokeFsJob({ type: "initialize" }); } /** * Writes a string to a file asynchronously */ - writeFileAsync(filename: string, contents: string): Promise { - return ipcRenderer - .invoke("fs-job", { - type: "write", - filename, - contents, - }) - .catch(e => this.wrapError(e)); + writeFileAsync(filename: string, contents: unknown): Promise { + return this.invokeFsJob({ type: "write", filename, contents }); } /** * Reads a string asynchronously */ - readFileAsync(filename: string): Promise { - return ipcRenderer - .invoke("fs-job", { - type: "read", - filename, - }) - .catch(e => this.wrapError(e)); + readFileAsync(filename: string): Promise { + return this.invokeFsJob({ type: "read", filename }); } /** * Tries to delete a file */ deleteFileAsync(filename: string): Promise { + return this.invokeFsJob({ type: "delete", filename }); + } + + /** + * Displays the "Open File" dialog to let user pick a file. Returns the + * decompressed file contents, or undefined if the operation was canceled + */ + requestOpenFile(extension: string): Promise { + return this.invokeFsJob({ type: "open-external", extension }); + } + + /** + * Displays the "Save File" dialog to let user pick a file. If the user + * picks a file, the passed contents will be compressed and written to + * that file. + */ + requestSaveFile(filename: string, contents: unknown): Promise { + return this.invokeFsJob({ type: "save-external", filename, contents }); + } + + private invokeFsJob(data: object) { return ipcRenderer .invoke("fs-job", { - type: "delete", - filename, + id: this.id, + ...data, }) .catch(e => this.wrapError(e)); } diff --git a/src/js/savegame/savegame.js b/src/js/savegame/savegame.js index b4054553..37dc4772 100644 --- a/src/js/savegame/savegame.js +++ b/src/js/savegame/savegame.js @@ -6,16 +6,6 @@ import { MODS } from "../mods/modloader"; import { BaseSavegameInterface } from "./savegame_interface"; import { getSavegameInterface, savegameInterfaces } from "./savegame_interface_registry"; import { SavegameSerializer } from "./savegame_serializer"; -import { SavegameInterface_V1001 } from "./schemas/1001"; -import { SavegameInterface_V1002 } from "./schemas/1002"; -import { SavegameInterface_V1003 } from "./schemas/1003"; -import { SavegameInterface_V1004 } from "./schemas/1004"; -import { SavegameInterface_V1005 } from "./schemas/1005"; -import { SavegameInterface_V1006 } from "./schemas/1006"; -import { SavegameInterface_V1007 } from "./schemas/1007"; -import { SavegameInterface_V1008 } from "./schemas/1008"; -import { SavegameInterface_V1009 } from "./schemas/1009"; -import { SavegameInterface_V1010 } from "./schemas/1010"; const logger = createLogger("savegame"); @@ -112,58 +102,8 @@ export class Savegame extends ReadWriteProxy { * @param {SavegameData} data */ migrate(data) { - if (data.version < 1000) { - return ExplainedResult.bad("Can not migrate savegame, too old"); - } - - if (data.version === 1000) { - SavegameInterface_V1001.migrate1000to1001(data); - data.version = 1001; - } - - if (data.version === 1001) { - SavegameInterface_V1002.migrate1001to1002(data); - data.version = 1002; - } - - if (data.version === 1002) { - SavegameInterface_V1003.migrate1002to1003(data); - data.version = 1003; - } - - if (data.version === 1003) { - SavegameInterface_V1004.migrate1003to1004(data); - data.version = 1004; - } - - if (data.version === 1004) { - SavegameInterface_V1005.migrate1004to1005(data); - data.version = 1005; - } - - if (data.version === 1005) { - SavegameInterface_V1006.migrate1005to1006(data); - data.version = 1006; - } - - if (data.version === 1006) { - SavegameInterface_V1007.migrate1006to1007(data); - data.version = 1007; - } - - if (data.version === 1007) { - SavegameInterface_V1008.migrate1007to1008(data); - data.version = 1008; - } - - if (data.version === 1008) { - SavegameInterface_V1009.migrate1008to1009(data); - data.version = 1009; - } - - if (data.version === 1009) { - SavegameInterface_V1010.migrate1009to1010(data); - data.version = 1010; + if (data.version !== this.getCurrentVersion()) { + return ExplainedResult.bad("Savegame upgrade is not supported"); } return ExplainedResult.good(); diff --git a/src/js/states/main_menu.js b/src/js/states/main_menu.js index e34c59e4..4d26c069 100644 --- a/src/js/states/main_menu.js +++ b/src/js/states/main_menu.js @@ -2,16 +2,13 @@ import { globalConfig, THIRDPARTY_URLS } from "../core/config"; import { GameState } from "../core/game_state"; import { DialogWithForm } from "../core/modal_dialog_elements"; import { FormElementInput } from "../core/modal_dialog_forms"; -import { ReadWriteProxy } from "../core/read_write_proxy"; import { formatSecondsToTimeAgo, - generateFileDownload, getLogoSprite, makeButton, makeDiv, makeDivElement, removeAllChildren, - startFileChoose, waitNextFrame, } from "../core/utils"; import { HUDModalDialogs } from "../game/hud/parts/modal_dialogs"; @@ -137,58 +134,34 @@ export class MainMenuState extends GameState { /** * Asks the user to import a savegame */ - requestImportSavegame() { - // Create a 'fake' file-input to accept savegames - startFileChoose(".bin").then(file => { - if (file) { - const closeLoader = this.dialogs.showLoadingDialog(); - waitNextFrame().then(() => { - const reader = new FileReader(); - reader.addEventListener("load", event => { - const contents = event.target.result; - let realContent; + async requestImportSavegame() { + const closeLoader = this.dialogs.showLoadingDialog(); + await waitNextFrame(); - try { - realContent = ReadWriteProxy.deserializeObject(contents); - } catch (err) { - closeLoader(); - this.dialogs.showWarning( - T.dialogs.importSavegameError.title, - T.dialogs.importSavegameError.text + "

" + err - ); - return; - } + const data = await this.app.storage.requestOpenFile("bin"); + if (data === undefined) { + // User canceled the request + closeLoader(); + return; + } - this.app.savegameMgr.importSavegame(realContent).then( - () => { - closeLoader(); - this.dialogs.showWarning( - T.dialogs.importSavegameSuccess.title, - T.dialogs.importSavegameSuccess.text - ); + try { + this.app.savegameMgr.importSavegame(data); + closeLoader(); + this.dialogs.showWarning( + T.dialogs.importSavegameSuccess.title, + T.dialogs.importSavegameSuccess.text + ); - this.renderMainMenu(); - this.renderSavegames(); - }, - err => { - closeLoader(); - this.dialogs.showWarning( - T.dialogs.importSavegameError.title, - T.dialogs.importSavegameError.text + ":

" + err - ); - } - ); - }); - reader.addEventListener("error", error => { - this.dialogs.showWarning( - T.dialogs.importSavegameError.title, - T.dialogs.importSavegameError.text + ":

" + error - ); - }); - reader.readAsText(file, "utf-8"); - }); - } - }); + this.renderMainMenu(); + this.renderSavegames(); + } catch (err) { + closeLoader(); + this.dialogs.showWarning( + T.dialogs.importSavegameError.title, + T.dialogs.importSavegameError.text + ":

" + err + ); + } } onBackButton() { @@ -602,9 +575,8 @@ export class MainMenuState extends GameState { downloadGame(game) { const savegame = this.app.savegameMgr.getSavegameById(game.internalId); savegame.readAsync().then(() => { - const data = ReadWriteProxy.serializeObject(savegame.currentData); const filename = (game.name || "unnamed") + ".bin"; - generateFileDownload(filename, data); + savegame.storage.requestSaveFile(filename, savegame.currentData); }); } diff --git a/src/js/webworkers/compression.worker.js b/src/js/webworkers/compression.worker.js deleted file mode 100644 index 83862d10..00000000 --- a/src/js/webworkers/compression.worker.js +++ /dev/null @@ -1,29 +0,0 @@ -import { globalConfig } from "../core/config"; -import { compressX64 } from "../core/lzstring"; -import { computeCrc } from "../core/sensitive_utils.encrypt"; - -self.addEventListener("message", event => { - // @ts-ignore - const { jobId, job, data } = event.data; - const result = performJob(job, data); - - // @ts-ignore - self.postMessage({ jobId, result }); -}); - -function performJob(job, data) { - switch (job) { - case "compressX64": { - return compressX64(data); - } - - case "compressObject": { - const stringified = JSON.stringify(data.obj); - - const checksum = computeCrc(stringified + globalConfig.info.file); - return data.compressionPrefix + compressX64(checksum + stringified); - } - default: - throw new Error("Webworker: Unknown job: " + job); - } -}