1
0
mirror of https://github.com/tobspr/shapez.io.git synced 2025-12-09 16:21:51 +00:00

Merge pull request #69 from tobspr-games/dengr1065/new-savegame-storage

Implement gzipped MessagePack savegame storage
This commit is contained in:
Даниїл Григор'єв 2025-05-03 00:47:29 +03:00 committed by GitHub
commit 2b890466b0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 297 additions and 661 deletions

View File

@ -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",

View File

@ -9,6 +9,7 @@
"start": "tsc && electron ."
},
"dependencies": {
"@msgpack/msgpack": "^3.1.1",
"semver": "^7.7.1",
"zod": "^3.24.2"
},

View File

@ -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<T> = 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<T> = GenericFsJob & { type: "save-external"; filename: string; contents: T };
export class FsJobHandler {
export type FsJob<T> =
| InitializeFsJob
| ListFsJob
| ReadFsJob
| WriteFsJob<T>
| DeleteFsJob
| OpenExternalFsJob
| SaveExternalFsJob<T>;
type FsJobResult<T> = T | string[] | void;
export class FsJobHandler<T> {
readonly rootDir: string;
private readonly storage: StorageInterface<T>;
private initialized = false;
constructor(subDir: string) {
constructor(subDir: string, storage: StorageInterface<T>) {
this.rootDir = path.join(userData, subDir);
this.storage = storage;
}
handleJob(job: FsJob): Promise<FsJobResult> {
async initialize(): Promise<void> {
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<T>): Promise<FsJobResult<T>> {
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<T | undefined> {
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<void> {
// 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<string[]> {
// Bare-bones implementation
return fs.readdir(subdir);
}
private read(file: string): Promise<string> {
return fs.readFile(file, "utf-8");
}
private async write(file: string, contents: string): Promise<string> {
private async write(file: string, contents: T): Promise<void> {
// 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<void> {
return fs.unlink(file);
await this.storage.write(file, contents);
}
private safeFileName(name: string) {

View File

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

View File

@ -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<unknown>) {
if (job.id !== "saves") {
throw new Error("Storages other than saves/ are not implemented yet");
}
return this.savesHandler.handleJob(job);
}
private async getMods() {

View File

@ -0,0 +1,5 @@
export interface StorageInterface<T> {
read(file: string): Promise<T>;
write(file: string, contents: T): Promise<void>;
delete(file: string): Promise<void>;
}

View File

@ -0,0 +1,16 @@
import fs from "node:fs/promises";
import { StorageInterface } from "./interface.js";
export class RawStorage implements StorageInterface<string> {
read(file: string): Promise<string> {
return fs.readFile(file, "utf-8");
}
write(file: string, contents: string): Promise<void> {
return fs.writeFile(file, contents, "utf-8");
}
delete(file: string): Promise<void> {
return fs.unlink(file);
}
}

View File

@ -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<unknown> {
async read(file: string): Promise<unknown> {
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<void> {
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<void> {
return fs.promises.unlink(file);
}
}

13
package-lock.json generated
View File

@ -12,7 +12,6 @@
"ajv": "^6.10.2",
"circular-json": "^0.5.9",
"clipboard-copy": "^3.1.0",
"crc": "^3.8.0",
"debounce-promise": "^3.1.2",
"howler": "^2.1.2",
"lz-string": "^1.4.4"
@ -2434,6 +2433,7 @@
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"dev": true,
"funding": [
{
"type": "github",
@ -3253,6 +3253,7 @@
"version": "5.7.1",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"dev": true,
"funding": [
{
"type": "github",
@ -4363,15 +4364,6 @@
"node": ">=4"
}
},
"node_modules/crc": {
"version": "3.8.0",
"resolved": "https://registry.npmjs.org/crc/-/crc-3.8.0.tgz",
"integrity": "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ==",
"license": "MIT",
"dependencies": {
"buffer": "^5.1.0"
}
},
"node_modules/cross-spawn": {
"version": "6.0.5",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz",
@ -9937,6 +9929,7 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
"dev": true,
"funding": [
{
"type": "github",

View File

@ -24,7 +24,6 @@
"ajv": "^6.10.2",
"circular-json": "^0.5.9",
"clipboard-copy": "^3.1.0",
"crc": "^3.8.0",
"debounce-promise": "^3.1.2",
"howler": "^2.1.2",
"lz-string": "^1.4.4"

View File

@ -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";
@ -54,21 +54,23 @@ export class Application {
this.unloaded = false;
// Platform stuff
this.storage = new Storage(this, STORAGE_SAVES);
await this.storage.initialize();
this.platformWrapper = new PlatformWrapperImplElectron(this);
// Global stuff
this.settings = new ApplicationSettings(this);
this.settings = new ApplicationSettings(this, this.storage);
this.ticker = new AnimationFrame();
this.stateMgr = new StateManager(this);
this.savegameMgr = new SavegameManager(this);
// NOTE: SavegameManager uses the passed storage, but savegames always
// use Application#storage
this.savegameMgr = new SavegameManager(this, this.storage);
this.inputMgr = new InputDistributor(this);
this.backgroundResourceLoader = new BackgroundResourcesLoader(this);
this.clientApi = new ClientAPI(this);
// Platform dependent stuff
this.storage = new Storage(this);
this.platformWrapper = new PlatformWrapperImplElectron(this);
this.sound = new Sound(this);
// Track if the window is focused (only relevant for browser)

View File

@ -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.<number, JobEntry>} */
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<any>}
*/
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();

View File

@ -110,12 +110,6 @@ export const globalConfig = {
rendering: {},
debug,
// Secret vars
info: {
// Binary file salt
file: "Ec'])@^+*9zMevK3uMV4432x9%iK'=",
},
};
export const IS_MOBILE = /iPhone|iPad|iPod|Android/i.test(navigator.userAgent);

View File

@ -1,27 +1,21 @@
/* typehints:start */
import { Application } from "../application";
import { Storage } from "@/platform/storage";
/* typehints:end */
import { FsError } from "@/platform/fs_error";
import { compressObject, decompressObject } from "../savegame/savegame_compressor";
import { asyncCompressor, compressionPrefix } from "./async_compression";
import { IS_DEBUG, globalConfig } from "./config";
import { IS_DEBUG } 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";
const logger = createLogger("read_write_proxy");
const salt = globalConfig.info.file;
// Helper which only writes / reads if verify() works. Also performs migration
export class ReadWriteProxy {
constructor(app, filename) {
/** @type {Application} */
this.app = app;
constructor(storage, filename) {
/** @type {Storage} */
this.storage = storage;
this.filename = filename;
@ -85,9 +79,8 @@ export class ReadWriteProxy {
* @param {object} obj
*/
static serializeObject(obj) {
const jsonString = JSON.stringify(compressObject(obj));
const checksum = computeCrc(jsonString + salt);
return compressionPrefix + compressX64(checksum + jsonString);
// TODO: Remove redundant method
return obj;
}
/**
@ -95,30 +88,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);
const decoded = decompressObject(parsed);
return decoded;
// TODO: Remove redundant method
return text;
}
/**
@ -142,11 +113,8 @@ export class ReadWriteProxy {
* @returns {Promise<void>}
*/
doWriteAsync() {
return asyncCompressor
.compressObjectAsync(this.currentData)
.then(compressed => {
return this.app.storage.writeFileAsync(this.filename, compressed);
})
return this.storage
.writeFileAsync(this.filename, this.currentData)
.then(() => {
logger.log("📄 Wrote", this.filename);
})
@ -160,7 +128,7 @@ export class ReadWriteProxy {
readAsync() {
// Start read request
return (
this.app.storage
this.storage
.readFileAsync(this.filename)
// Check for errors during read
@ -169,73 +137,12 @@ export class ReadWriteProxy {
logger.log("File not found, using default data");
// File not found or unreadable, assume default file
return Promise.resolve(null);
return Promise.resolve(this.getDefaultData());
}
return Promise.reject("file-error: " + err);
})
// Decrypt data (if its encrypted)
// @ts-ignore
.then(rawData => {
if (rawData == null) {
// So, the file has not been found, use default data
return JSON.stringify(compressObject(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");
}
})
// Decompress
.then(compressed => decompressObject(compressed))
// Verify basic structure
.then(contents => {
const result = this.internalVerifyBasicStructure(contents);
@ -302,7 +209,7 @@ export class ReadWriteProxy {
* @returns {Promise<void>}
*/
deleteAsync() {
return this.app.storage.deleteFileAsync(this.filename);
return this.storage.deleteFileAsync(this.filename);
}
// Internal

View File

@ -1,12 +0,0 @@
import crc32 from "crc/crc32";
// Distinguish legacy crc prefixes
export const CRC_PREFIX = "crc32".padEnd(32, "-");
/**
* Computes the crc for a given string
* @param {string} str
*/
export function computeCrc(str) {
return CRC_PREFIX + crc32(str).toString(16).padStart(8, "0");
}

View File

@ -599,38 +599,6 @@ export function fillInLinkIntoTranslation(translation, link) {
.replace("</link>", "</a>");
}
/**
* 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"];

View File

@ -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<void> {
return Promise.resolve();
return this.invokeFsJob({ type: "initialize" });
}
/**
* Writes a string to a file asynchronously
*/
writeFileAsync(filename: string, contents: string): Promise<void> {
return ipcRenderer
.invoke("fs-job", {
type: "write",
filename,
contents,
})
.catch(e => this.wrapError(e));
writeFileAsync(filename: string, contents: unknown): Promise<void> {
return this.invokeFsJob({ type: "write", filename, contents });
}
/**
* Reads a string asynchronously
*/
readFileAsync(filename: string): Promise<string> {
return ipcRenderer
.invoke("fs-job", {
type: "read",
filename,
})
.catch(e => this.wrapError(e));
readFileAsync(filename: string): Promise<unknown> {
return this.invokeFsJob({ type: "read", filename });
}
/**
* Tries to delete a file
*/
deleteFileAsync(filename: string): Promise<void> {
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<unknown> {
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<unknown> {
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));
}

View File

@ -2,13 +2,13 @@
import { Application } from "../application";
/* typehints:end */
import { ReadWriteProxy } from "../core/read_write_proxy";
import { BoolSetting, EnumSetting, RangeSetting, BaseSetting } from "./setting_types";
import { createLogger } from "../core/logging";
import { ExplainedResult } from "../core/explained_result";
import { createLogger } from "../core/logging";
import { ReadWriteProxy } from "../core/read_write_proxy";
import { THEMES, applyGameTheme } from "../game/theme";
import { T } from "../translations";
import { LANGUAGES } from "../languages";
import { T } from "../translations";
import { BaseSetting, BoolSetting, EnumSetting, RangeSetting } from "./setting_types";
const logger = createLogger("application_settings");
@ -330,8 +330,11 @@ class SettingsStorage {
}
export class ApplicationSettings extends ReadWriteProxy {
constructor(app) {
super(app, "app_settings.bin");
constructor(app, storage) {
super(storage, "app_settings.bin");
/** @type {Application} */
this.app = app;
this.settingHandles = initializeSettings();
}

View File

@ -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");
@ -36,7 +26,11 @@ export class Savegame extends ReadWriteProxy {
* @param {SavegameMetadata} param0.metaDataRef Handle to the meta data
*/
constructor(app, { internalId, metaDataRef }) {
super(app, "savegame-" + internalId + ".bin");
super(app.storage, "savegame-" + internalId + ".bin");
/** @type {Application} */
this.app = app;
this.internalId = internalId;
this.metaDataRef = metaDataRef;
@ -108,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();

View File

@ -1,168 +0,0 @@
const charmap =
"!#%&'()*+,-./:;<=>?@[]^_`{|}~¥¦§¨©ª«¬­®¯°±²³´µ¶·¸¹º»¼½¾¿ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖרÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõö÷øùúûüýþÿABCDEFGHIJKLMNOPQRSTUVWXYZ";
let compressionCache = {};
let decompressionCache = {};
/**
* Compresses an integer into a tight string representation
* @param {number} i
* @returns {string}
*/
function compressInt(i) {
// Zero value breaks
i += 1;
// save `i` as the cache key
// to avoid it being modified by the
// rest of the function.
const cache_key = i;
if (compressionCache[cache_key]) {
return compressionCache[cache_key];
}
let result = "";
do {
result += charmap[i % charmap.length];
i = Math.floor(i / charmap.length);
} while (i > 0);
return (compressionCache[cache_key] = result);
}
/**
* Decompresses an integer from its tight string representation
* @param {string} s
* @returns {number}
*/
function decompressInt(s) {
if (decompressionCache[s]) {
return decompressionCache[s];
}
s = "" + s;
let result = 0;
for (let i = s.length - 1; i >= 0; --i) {
result = result * charmap.length + charmap.indexOf(s.charAt(i));
}
// Fixes zero value break fix from above
result -= 1;
return (decompressionCache[s] = result);
}
// Sanity
if (G_IS_DEV) {
for (let i = 0; i < 10000; ++i) {
if (decompressInt(compressInt(i)) !== i) {
throw new Error(
"Bad compression for: " +
i +
" compressed: " +
compressInt(i) +
" decompressed: " +
decompressInt(compressInt(i))
);
}
}
}
/**
* @param {any} obj
* @param {Map} keys
* @param {Map} values
* @returns {any[]|object|number|string}
*/
function compressObjectInternal(obj, keys, values) {
if (Array.isArray(obj)) {
let result = [];
for (let i = 0; i < obj.length; ++i) {
result.push(compressObjectInternal(obj[i], keys, values));
}
return result;
} else if (typeof obj === "object" && obj !== null) {
let result = {};
for (const key in obj) {
let index = keys.get(key);
if (index === undefined) {
index = keys.size;
keys.set(key, index);
}
const value = obj[key];
result[compressInt(index)] = compressObjectInternal(value, keys, values);
}
return result;
} else if (typeof obj === "string") {
let index = values.get(obj);
if (index === undefined) {
index = values.size;
values.set(obj, index);
}
return compressInt(index);
}
return obj;
}
/**
* @param {Map} hashMap
* @returns {Array}
*/
function indexMapToArray(hashMap) {
const result = new Array(hashMap.size);
hashMap.forEach((index, key) => {
result[index] = key;
});
return result;
}
/**
* @param {object} obj
*/
export function compressObject(obj) {
const keys = new Map();
const values = new Map();
const data = compressObjectInternal(obj, keys, values);
return {
keys: indexMapToArray(keys),
values: indexMapToArray(values),
data,
};
}
/**
* @param {object} obj
* @param {string[]} keys
* @param {any[]} values
* @returns {object}
*/
function decompressObjectInternal(obj, keys = [], values = []) {
if (Array.isArray(obj)) {
let result = [];
for (let i = 0; i < obj.length; ++i) {
result.push(decompressObjectInternal(obj[i], keys, values));
}
return result;
} else if (typeof obj === "object" && obj !== null) {
let result = {};
for (const key in obj) {
const realIndex = decompressInt(key);
const value = obj[key];
result[keys[realIndex]] = decompressObjectInternal(value, keys, values);
}
return result;
} else if (typeof obj === "string") {
const realIndex = decompressInt(obj);
return values[realIndex];
}
return obj;
}
/**
* @param {object} obj
*/
export function decompressObject(obj) {
if (obj.keys && obj.values && obj.data) {
const keys = obj.keys;
const values = obj.values;
const result = decompressObjectInternal(obj.data, keys, values);
return result;
}
return obj;
}

View File

@ -1,3 +1,7 @@
/* typehints:start */
import { Application } from "@/application";
/* typehints:end */
import { globalConfig } from "../core/config";
import { ExplainedResult } from "../core/explained_result";
import { createLogger } from "../core/logging";
@ -17,8 +21,11 @@ export const enumLocalSavegameStatus = {
};
export class SavegameManager extends ReadWriteProxy {
constructor(app) {
super(app, "savegames.bin");
constructor(app, storage) {
super(storage, "savegames.bin");
/** @type {Application} */
this.app = app;
this.currentData = this.getDefaultData();
}

View File

@ -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 + "<br><br>" + 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 + ":<br><br>" + err
);
}
);
});
reader.addEventListener("error", error => {
this.dialogs.showWarning(
T.dialogs.importSavegameError.title,
T.dialogs.importSavegameError.text + ":<br><br>" + error
);
});
reader.readAsText(file, "utf-8");
});
}
});
this.renderMainMenu();
this.renderSavegames();
} catch (err) {
closeLoader();
this.dialogs.showWarning(
T.dialogs.importSavegameError.title,
T.dialogs.importSavegameError.text + ":<br><br>" + 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);
});
}

View File

@ -1,31 +0,0 @@
import { globalConfig } from "../core/config";
import { compressX64 } from "../core/lzstring";
import { computeCrc } from "../core/sensitive_utils.encrypt";
import { compressObject } from "../savegame/savegame_compressor";
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 optimized = compressObject(data.obj);
const stringified = JSON.stringify(optimized);
const checksum = computeCrc(stringified + globalConfig.info.file);
return data.compressionPrefix + compressX64(checksum + stringified);
}
default:
throw new Error("Webworker: Unknown job: " + job);
}
}