mirror of
https://github.com/tobspr-games/shapez.io.git
synced 2026-02-04 14:52:27 -08: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
16 changed files with 257 additions and 421 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue