mirror of
https://github.com/tobspr/shapez.io.git
synced 2025-12-11 09:11:50 +00:00
Add dedicated saves storage, new fs job types
Keep track of the storage ID in each renderer Storage instance and pass it to the IPC bridge. Jobs are dispatched to the relevant handler (only saves/ for now) and all (de)compression is handled there. Add dedicated fs-job types to read or write and (de)compress data from/to the file picked by the user. Remove redundant utility functions that used web APIs instead.
This commit is contained in:
parent
6b7cfa1b1b
commit
fc33cc2fbf
10
electron/package-lock.json
generated
10
electron/package-lock.json
generated
@ -9,6 +9,7 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@msgpack/msgpack": "^3.1.1",
|
||||||
"semver": "^7.7.1",
|
"semver": "^7.7.1",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
@ -50,6 +51,15 @@
|
|||||||
"semver": "bin/semver.js"
|
"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": {
|
"node_modules/@sindresorhus/is": {
|
||||||
"version": "4.6.0",
|
"version": "4.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
"start": "tsc && electron ."
|
"start": "tsc && electron ."
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@msgpack/msgpack": "^3.1.1",
|
||||||
"semver": "^7.7.1",
|
"semver": "^7.7.1",
|
||||||
"zod": "^3.24.2"
|
"zod": "^3.24.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,68 +1,129 @@
|
|||||||
|
import { BrowserWindow, dialog, FileFilter } from "electron";
|
||||||
import fs from "fs/promises";
|
import fs from "fs/promises";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { userData } from "./config.js";
|
import { userData } from "./config.js";
|
||||||
|
import { StorageInterface } from "./storage/interface.js";
|
||||||
|
|
||||||
interface GenericFsJob {
|
interface GenericFsJob {
|
||||||
filename: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ListFsJob = GenericFsJob & { type: "list" };
|
export type InitializeFsJob = GenericFsJob & { type: "initialize" };
|
||||||
type ReadFsJob = GenericFsJob & { type: "read" };
|
type ListFsJob = GenericFsJob & { type: "list"; filename: string };
|
||||||
type WriteFsJob = GenericFsJob & { type: "write"; contents: string };
|
type ReadFsJob = GenericFsJob & { type: "read"; filename: string };
|
||||||
type DeleteFsJob = GenericFsJob & { type: "delete" };
|
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 OpenExternalFsJob = GenericFsJob & { type: "open-external"; extension: string };
|
||||||
type FsJobResult = string | string[] | void;
|
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;
|
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.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);
|
const filename = this.safeFileName(job.filename);
|
||||||
|
|
||||||
switch (job.type) {
|
switch (job.type) {
|
||||||
case "list":
|
case "list":
|
||||||
return this.list(filename);
|
return this.list(filename);
|
||||||
case "read":
|
case "read":
|
||||||
return this.read(filename);
|
return this.storage.read(filename);
|
||||||
case "write":
|
case "write":
|
||||||
return this.write(filename, job.contents);
|
return this.write(filename, job.contents);
|
||||||
case "delete":
|
case "delete":
|
||||||
return this.delete(filename);
|
return this.storage.delete(filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
// @ts-expect-error this method can actually receive garbage
|
// @ts-expect-error this method can actually receive garbage
|
||||||
throw new Error(`Unknown FS job type: ${job.type}`);
|
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[]> {
|
private list(subdir: string): Promise<string[]> {
|
||||||
// Bare-bones implementation
|
// Bare-bones implementation
|
||||||
return fs.readdir(subdir);
|
return fs.readdir(subdir);
|
||||||
}
|
}
|
||||||
|
|
||||||
private read(file: string): Promise<string> {
|
private async write(file: string, contents: T): Promise<void> {
|
||||||
return fs.readFile(file, "utf-8");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async write(file: string, contents: string): Promise<string> {
|
|
||||||
// The target directory might not exist, ensure it does
|
// The target directory might not exist, ensure it does
|
||||||
const parentDir = path.dirname(file);
|
const parentDir = path.dirname(file);
|
||||||
await fs.mkdir(parentDir, { recursive: true });
|
await fs.mkdir(parentDir, { recursive: true });
|
||||||
|
|
||||||
// Backups not implemented yet.
|
await this.storage.write(file, contents);
|
||||||
await fs.writeFile(file, contents, {
|
|
||||||
encoding: "utf-8",
|
|
||||||
flush: true,
|
|
||||||
});
|
|
||||||
return contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
private delete(file: string): Promise<void> {
|
|
||||||
return fs.unlink(file);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private safeFileName(name: string) {
|
private safeFileName(name: string) {
|
||||||
|
|||||||
@ -1,7 +1,6 @@
|
|||||||
import { BrowserWindow, app, shell } from "electron";
|
import { BrowserWindow, app, shell } from "electron";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { defaultWindowTitle, pageUrl, switches } from "./config.js";
|
import { defaultWindowTitle, pageUrl, switches } from "./config.js";
|
||||||
import { FsJobHandler } from "./fsjob.js";
|
|
||||||
import { IpcHandler } from "./ipc.js";
|
import { IpcHandler } from "./ipc.js";
|
||||||
import { ModLoader } from "./mods/loader.js";
|
import { ModLoader } from "./mods/loader.js";
|
||||||
import { ModProtocolHandler } from "./mods/protocol_handler.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 modLoader = new ModLoader();
|
||||||
const modProtocol = new ModProtocolHandler(modLoader);
|
const modProtocol = new ModProtocolHandler(modLoader);
|
||||||
const ipc = new IpcHandler(fsJob, modLoader);
|
const ipc = new IpcHandler(modLoader);
|
||||||
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
// The protocol can only be handled after "ready" event
|
// The protocol can only be handled after "ready" event
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import { BrowserWindow, IpcMainInvokeEvent, ipcMain } from "electron";
|
import { BrowserWindow, IpcMainInvokeEvent, ipcMain } from "electron";
|
||||||
import { FsJob, FsJobHandler } from "./fsjob.js";
|
import { FsJob, FsJobHandler } from "./fsjob.js";
|
||||||
import { ModLoader } from "./mods/loader.js";
|
import { ModLoader } from "./mods/loader.js";
|
||||||
|
import { SavesStorage } from "./storage/saves.js";
|
||||||
|
|
||||||
export class IpcHandler {
|
export class IpcHandler {
|
||||||
private readonly fsJob: FsJobHandler;
|
private readonly savesHandler = new FsJobHandler("saves", new SavesStorage());
|
||||||
private readonly modLoader: ModLoader;
|
private readonly modLoader: ModLoader;
|
||||||
|
|
||||||
constructor(fsJob: FsJobHandler, modLoader: ModLoader) {
|
constructor(modLoader: ModLoader) {
|
||||||
this.fsJob = fsJob;
|
|
||||||
this.modLoader = modLoader;
|
this.modLoader = modLoader;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -20,8 +20,12 @@ export class IpcHandler {
|
|||||||
// ipcMain.handle("open-mods-folder", ...)
|
// ipcMain.handle("open-mods-folder", ...)
|
||||||
}
|
}
|
||||||
|
|
||||||
private handleFsJob(_event: IpcMainInvokeEvent, job: FsJob) {
|
private handleFsJob(_event: IpcMainInvokeEvent, job: FsJob<unknown>) {
|
||||||
return this.fsJob.handleJob(job);
|
if (job.id !== "saves") {
|
||||||
|
throw new Error("Storages other than saves/ are not implemented yet");
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.savesHandler.handleJob(job);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getMods() {
|
private async getMods() {
|
||||||
|
|||||||
5
electron/src/storage/interface.ts
Normal file
5
electron/src/storage/interface.ts
Normal 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>;
|
||||||
|
}
|
||||||
16
electron/src/storage/raw.ts
Normal file
16
electron/src/storage/raw.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
electron/src/storage/saves.ts
Normal file
54
electron/src/storage/saves.ts
Normal 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,7 +13,7 @@ import { MOD_SIGNALS } from "./mods/mod_signals";
|
|||||||
import { MODS } from "./mods/modloader";
|
import { MODS } from "./mods/modloader";
|
||||||
import { ClientAPI } from "./platform/api";
|
import { ClientAPI } from "./platform/api";
|
||||||
import { Sound } from "./platform/sound";
|
import { Sound } from "./platform/sound";
|
||||||
import { Storage } from "./platform/storage";
|
import { Storage, STORAGE_SAVES } from "./platform/storage";
|
||||||
import { PlatformWrapperImplElectron } from "./platform/wrapper";
|
import { PlatformWrapperImplElectron } from "./platform/wrapper";
|
||||||
import { ApplicationSettings } from "./profile/application_settings";
|
import { ApplicationSettings } from "./profile/application_settings";
|
||||||
import { SavegameManager } from "./savegame/savegame_manager";
|
import { SavegameManager } from "./savegame/savegame_manager";
|
||||||
@ -55,7 +55,9 @@ export class Application {
|
|||||||
this.unloaded = false;
|
this.unloaded = false;
|
||||||
|
|
||||||
// Platform stuff
|
// Platform stuff
|
||||||
this.storage = new Storage(this);
|
this.storage = new Storage(this, STORAGE_SAVES);
|
||||||
|
await this.storage.initialize();
|
||||||
|
|
||||||
this.platformWrapper = new PlatformWrapperImplElectron(this);
|
this.platformWrapper = new PlatformWrapperImplElectron(this);
|
||||||
|
|
||||||
// Global stuff
|
// Global stuff
|
||||||
|
|||||||
@ -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();
|
|
||||||
@ -3,12 +3,9 @@ import { Storage } from "@/platform/storage";
|
|||||||
/* typehints:end */
|
/* typehints:end */
|
||||||
|
|
||||||
import { FsError } from "@/platform/fs_error";
|
import { FsError } from "@/platform/fs_error";
|
||||||
import { asyncCompressor, compressionPrefix } from "./async_compression";
|
|
||||||
import { IS_DEBUG, globalConfig } from "./config";
|
import { IS_DEBUG, globalConfig } from "./config";
|
||||||
import { ExplainedResult } from "./explained_result";
|
import { ExplainedResult } from "./explained_result";
|
||||||
import { createLogger } from "./logging";
|
import { createLogger } from "./logging";
|
||||||
import { compressX64, decompressX64 } from "./lzstring";
|
|
||||||
import { computeCrc } from "./sensitive_utils.encrypt";
|
|
||||||
|
|
||||||
import debounce from "debounce-promise";
|
import debounce from "debounce-promise";
|
||||||
|
|
||||||
@ -84,9 +81,8 @@ export class ReadWriteProxy {
|
|||||||
* @param {object} obj
|
* @param {object} obj
|
||||||
*/
|
*/
|
||||||
static serializeObject(obj) {
|
static serializeObject(obj) {
|
||||||
const jsonString = JSON.stringify(obj);
|
// TODO: Remove redundant method
|
||||||
const checksum = computeCrc(jsonString + salt);
|
return obj;
|
||||||
return compressionPrefix + compressX64(checksum + jsonString);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -94,29 +90,8 @@ export class ReadWriteProxy {
|
|||||||
* @param {object} text
|
* @param {object} text
|
||||||
*/
|
*/
|
||||||
static deserializeObject(text) {
|
static deserializeObject(text) {
|
||||||
const decompressed = decompressX64(text.substr(compressionPrefix.length));
|
// TODO: Remove redundant method
|
||||||
if (!decompressed) {
|
return text;
|
||||||
// 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -140,11 +115,8 @@ export class ReadWriteProxy {
|
|||||||
* @returns {Promise<void>}
|
* @returns {Promise<void>}
|
||||||
*/
|
*/
|
||||||
doWriteAsync() {
|
doWriteAsync() {
|
||||||
return asyncCompressor
|
return this.storage
|
||||||
.compressObjectAsync(this.currentData)
|
.writeFileAsync(this.filename, this.currentData)
|
||||||
.then(compressed => {
|
|
||||||
return this.storage.writeFileAsync(this.filename, compressed);
|
|
||||||
})
|
|
||||||
.then(() => {
|
.then(() => {
|
||||||
logger.log("📄 Wrote", this.filename);
|
logger.log("📄 Wrote", this.filename);
|
||||||
})
|
})
|
||||||
@ -178,59 +150,12 @@ export class ReadWriteProxy {
|
|||||||
.then(rawData => {
|
.then(rawData => {
|
||||||
if (rawData == null) {
|
if (rawData == null) {
|
||||||
// So, the file has not been found, use default data
|
// 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;
|
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
|
// Verify basic structure
|
||||||
.then(contents => {
|
.then(contents => {
|
||||||
const result = this.internalVerifyBasicStructure(contents);
|
const result = this.internalVerifyBasicStructure(contents);
|
||||||
|
|||||||
@ -599,38 +599,6 @@ export function fillInLinkIntoTranslation(translation, link) {
|
|||||||
.replace("</link>", "</a>");
|
.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 MAX_ROMAN_NUMBER = 49;
|
||||||
const romanLiteralsCache = ["0"];
|
const romanLiteralsCache = ["0"];
|
||||||
|
|
||||||
|
|||||||
@ -1,53 +1,68 @@
|
|||||||
import { Application } from "@/application";
|
import { Application } from "@/application";
|
||||||
import { FsError } from "./fs_error";
|
import { FsError } from "./fs_error";
|
||||||
|
|
||||||
|
export const STORAGE_SAVES = "saves";
|
||||||
|
export const STORAGE_MOD_PREFIX = "mod/";
|
||||||
|
|
||||||
export class Storage {
|
export class Storage {
|
||||||
readonly app: Application;
|
readonly app: Application;
|
||||||
|
readonly id: string;
|
||||||
|
|
||||||
constructor(app: Application) {
|
constructor(app: Application, id: string) {
|
||||||
this.app = app;
|
this.app = app;
|
||||||
|
this.id = id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the storage
|
* Initializes the storage
|
||||||
*/
|
*/
|
||||||
initialize(): Promise<void> {
|
initialize(): Promise<void> {
|
||||||
return Promise.resolve();
|
return this.invokeFsJob({ type: "initialize" });
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes a string to a file asynchronously
|
* Writes a string to a file asynchronously
|
||||||
*/
|
*/
|
||||||
writeFileAsync(filename: string, contents: string): Promise<void> {
|
writeFileAsync(filename: string, contents: unknown): Promise<void> {
|
||||||
return ipcRenderer
|
return this.invokeFsJob({ type: "write", filename, contents });
|
||||||
.invoke("fs-job", {
|
|
||||||
type: "write",
|
|
||||||
filename,
|
|
||||||
contents,
|
|
||||||
})
|
|
||||||
.catch(e => this.wrapError(e));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads a string asynchronously
|
* Reads a string asynchronously
|
||||||
*/
|
*/
|
||||||
readFileAsync(filename: string): Promise<string> {
|
readFileAsync(filename: string): Promise<unknown> {
|
||||||
return ipcRenderer
|
return this.invokeFsJob({ type: "read", filename });
|
||||||
.invoke("fs-job", {
|
|
||||||
type: "read",
|
|
||||||
filename,
|
|
||||||
})
|
|
||||||
.catch(e => this.wrapError(e));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tries to delete a file
|
* Tries to delete a file
|
||||||
*/
|
*/
|
||||||
deleteFileAsync(filename: string): Promise<void> {
|
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
|
return ipcRenderer
|
||||||
.invoke("fs-job", {
|
.invoke("fs-job", {
|
||||||
type: "delete",
|
id: this.id,
|
||||||
filename,
|
...data,
|
||||||
})
|
})
|
||||||
.catch(e => this.wrapError(e));
|
.catch(e => this.wrapError(e));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,16 +6,6 @@ import { MODS } from "../mods/modloader";
|
|||||||
import { BaseSavegameInterface } from "./savegame_interface";
|
import { BaseSavegameInterface } from "./savegame_interface";
|
||||||
import { getSavegameInterface, savegameInterfaces } from "./savegame_interface_registry";
|
import { getSavegameInterface, savegameInterfaces } from "./savegame_interface_registry";
|
||||||
import { SavegameSerializer } from "./savegame_serializer";
|
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");
|
const logger = createLogger("savegame");
|
||||||
|
|
||||||
@ -112,58 +102,8 @@ export class Savegame extends ReadWriteProxy {
|
|||||||
* @param {SavegameData} data
|
* @param {SavegameData} data
|
||||||
*/
|
*/
|
||||||
migrate(data) {
|
migrate(data) {
|
||||||
if (data.version < 1000) {
|
if (data.version !== this.getCurrentVersion()) {
|
||||||
return ExplainedResult.bad("Can not migrate savegame, too old");
|
return ExplainedResult.bad("Savegame upgrade is not supported");
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return ExplainedResult.good();
|
return ExplainedResult.good();
|
||||||
|
|||||||
@ -2,16 +2,13 @@ import { globalConfig, THIRDPARTY_URLS } from "../core/config";
|
|||||||
import { GameState } from "../core/game_state";
|
import { GameState } from "../core/game_state";
|
||||||
import { DialogWithForm } from "../core/modal_dialog_elements";
|
import { DialogWithForm } from "../core/modal_dialog_elements";
|
||||||
import { FormElementInput } from "../core/modal_dialog_forms";
|
import { FormElementInput } from "../core/modal_dialog_forms";
|
||||||
import { ReadWriteProxy } from "../core/read_write_proxy";
|
|
||||||
import {
|
import {
|
||||||
formatSecondsToTimeAgo,
|
formatSecondsToTimeAgo,
|
||||||
generateFileDownload,
|
|
||||||
getLogoSprite,
|
getLogoSprite,
|
||||||
makeButton,
|
makeButton,
|
||||||
makeDiv,
|
makeDiv,
|
||||||
makeDivElement,
|
makeDivElement,
|
||||||
removeAllChildren,
|
removeAllChildren,
|
||||||
startFileChoose,
|
|
||||||
waitNextFrame,
|
waitNextFrame,
|
||||||
} from "../core/utils";
|
} from "../core/utils";
|
||||||
import { HUDModalDialogs } from "../game/hud/parts/modal_dialogs";
|
import { HUDModalDialogs } from "../game/hud/parts/modal_dialogs";
|
||||||
@ -137,58 +134,34 @@ export class MainMenuState extends GameState {
|
|||||||
/**
|
/**
|
||||||
* Asks the user to import a savegame
|
* Asks the user to import a savegame
|
||||||
*/
|
*/
|
||||||
requestImportSavegame() {
|
async requestImportSavegame() {
|
||||||
// Create a 'fake' file-input to accept savegames
|
const closeLoader = this.dialogs.showLoadingDialog();
|
||||||
startFileChoose(".bin").then(file => {
|
await waitNextFrame();
|
||||||
if (file) {
|
|
||||||
const closeLoader = this.dialogs.showLoadingDialog();
|
|
||||||
waitNextFrame().then(() => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.addEventListener("load", event => {
|
|
||||||
const contents = event.target.result;
|
|
||||||
let realContent;
|
|
||||||
|
|
||||||
try {
|
const data = await this.app.storage.requestOpenFile("bin");
|
||||||
realContent = ReadWriteProxy.deserializeObject(contents);
|
if (data === undefined) {
|
||||||
} catch (err) {
|
// User canceled the request
|
||||||
closeLoader();
|
closeLoader();
|
||||||
this.dialogs.showWarning(
|
return;
|
||||||
T.dialogs.importSavegameError.title,
|
}
|
||||||
T.dialogs.importSavegameError.text + "<br><br>" + err
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.app.savegameMgr.importSavegame(realContent).then(
|
try {
|
||||||
() => {
|
this.app.savegameMgr.importSavegame(data);
|
||||||
closeLoader();
|
closeLoader();
|
||||||
this.dialogs.showWarning(
|
this.dialogs.showWarning(
|
||||||
T.dialogs.importSavegameSuccess.title,
|
T.dialogs.importSavegameSuccess.title,
|
||||||
T.dialogs.importSavegameSuccess.text
|
T.dialogs.importSavegameSuccess.text
|
||||||
);
|
);
|
||||||
|
|
||||||
this.renderMainMenu();
|
this.renderMainMenu();
|
||||||
this.renderSavegames();
|
this.renderSavegames();
|
||||||
},
|
} catch (err) {
|
||||||
err => {
|
closeLoader();
|
||||||
closeLoader();
|
this.dialogs.showWarning(
|
||||||
this.dialogs.showWarning(
|
T.dialogs.importSavegameError.title,
|
||||||
T.dialogs.importSavegameError.title,
|
T.dialogs.importSavegameError.text + ":<br><br>" + err
|
||||||
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");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onBackButton() {
|
onBackButton() {
|
||||||
@ -602,9 +575,8 @@ export class MainMenuState extends GameState {
|
|||||||
downloadGame(game) {
|
downloadGame(game) {
|
||||||
const savegame = this.app.savegameMgr.getSavegameById(game.internalId);
|
const savegame = this.app.savegameMgr.getSavegameById(game.internalId);
|
||||||
savegame.readAsync().then(() => {
|
savegame.readAsync().then(() => {
|
||||||
const data = ReadWriteProxy.serializeObject(savegame.currentData);
|
|
||||||
const filename = (game.name || "unnamed") + ".bin";
|
const filename = (game.name || "unnamed") + ".bin";
|
||||||
generateFileDownload(filename, data);
|
savegame.storage.requestSaveFile(filename, savegame.currentData);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user