mirror of
https://github.com/araxiaonline/wow-client-patcher.git
synced 2026-06-13 03:12:24 -04:00
Main library for managing files for the client and remote
This commit is contained in:
589
src/main/libs/FileManager.ts
Normal file
589
src/main/libs/FileManager.ts
Normal file
@@ -0,0 +1,589 @@
|
||||
import { WowLauncher, Manifest } from 'typings';
|
||||
|
||||
import fs from 'fs-extra';
|
||||
import path, { join } from 'path';
|
||||
import * as crypto from 'crypto';
|
||||
import AdmZip from 'adm-zip';
|
||||
import log from 'electron-log';
|
||||
import Downloader from './Downloader';
|
||||
import { santizeETag } from '../util';
|
||||
|
||||
// Confiruation of file management
|
||||
import config from "../config.json";
|
||||
|
||||
/**
|
||||
* Groupings of Patchfiles that are from external sources
|
||||
*/
|
||||
export const HDPatchList: WowLauncher.PatchFile[] = config.patches.HDContent;
|
||||
export const MiscPatchList: WowLauncher.PatchFile[] = config.patches.misc;
|
||||
export const Reserved: WowLauncher.PatchFile[] = config.patches.reserved;
|
||||
export const Experimental: WowLauncher.PatchFile[] = config.patches.experimental;
|
||||
|
||||
/**
|
||||
* Addons that can be installed to the client for the user
|
||||
*/
|
||||
export const AddOns: WowLauncher.PatchFile[] = config.addOns;
|
||||
|
||||
type patchGroup = 'hd' | 'misc' | 'reserved' | 'experimental';
|
||||
|
||||
const AIORemotePath = `${config.remotePaths.aio}/AIO_Client.zip`;
|
||||
const AIOLocalPath = 'Interface/AddOns/AIO_Client.zip';
|
||||
|
||||
type VersionDetails = {
|
||||
version: string;
|
||||
lastupdate: string;
|
||||
by: string;
|
||||
files: WowLauncher.PatchFile[];
|
||||
};
|
||||
|
||||
type Versions = VersionDetails[];
|
||||
|
||||
/**
|
||||
* This is the main backend library of the updater that is responsible for
|
||||
* remote file reads, local file writes, and application information stored in files.
|
||||
*
|
||||
* The system is completely managed over a S3 endpoint which can use AWS S3 Cloud service
|
||||
* or Garage HQ (if you want to host your own open source SDK compatible Server) for more inforation
|
||||
* go here: https://garagehq.deuxfleurs.fr/documentation/quick-start/
|
||||
*
|
||||
* Everything this class does is built of the config.json at the root of this folder.
|
||||
*
|
||||
* This class uses many function from Downloader class to manager downloading of files and getting information from
|
||||
* the Remote object host. There are 2 ways to reference the Downloader class and use cases are described below:
|
||||
* 1. this.downloader - This should be used internally for this class when looking up information or when download
|
||||
* events do not need to be handled by the main process.
|
||||
* 2. DownloaderInstance - This function will create a new Downloader object that can be exposed by scope. This is used
|
||||
* when you expect to export the downloader events for a main process to add event handlers to.
|
||||
*
|
||||
* @class FileManager
|
||||
* @export FileManager
|
||||
* @param basePath <string> | path to the root of the game files
|
||||
*/
|
||||
export default class FileManager {
|
||||
private basePath: string;
|
||||
private dataPath: string;
|
||||
private addOnsPath: string;
|
||||
private extraResources: string;
|
||||
private allPatches: WowLauncher.PatchFile[] = [];
|
||||
private patchNames: string[] = [];
|
||||
private manifestFile: string;
|
||||
private downloader: Downloader;
|
||||
private remoteVersions: Versions = [];
|
||||
|
||||
constructor(basePath: string) {
|
||||
// This sets up all local paths for the file manager
|
||||
this.basePath = basePath;
|
||||
this.dataPath = path.join(this.basePath, 'Data');
|
||||
this.addOnsPath = path.join(this.basePath, 'Interface', 'AddOns');
|
||||
this.extraResources = path.join(__dirname, '../../../extraResources');
|
||||
this.manifestFile = path.join(this.basePath, 'manifest.json');
|
||||
|
||||
// This sets up all the patches that can be installed
|
||||
this.allPatches = [
|
||||
...HDPatchList,
|
||||
...MiscPatchList,
|
||||
...Experimental,
|
||||
];
|
||||
this.patchNames = this.allPatches.map((patch) => patch.name);
|
||||
|
||||
this.downloader = new Downloader({
|
||||
bucket: config.aws.bucket,
|
||||
root: this.basePath,
|
||||
s3config: config.aws,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an instance of the downloader with base configuration.
|
||||
* @returns Downloader
|
||||
*/
|
||||
DownloaderInstance(): Downloader {
|
||||
return new Downloader({
|
||||
bucket: config.aws.bucket,
|
||||
root: this.basePath,
|
||||
s3config: config.aws,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache folder is a safe place to store files that can be referenced later, but can be deleted.
|
||||
* @returns {Promise<string>} The path to the cache folder
|
||||
*/
|
||||
async GetCacheFolder(): Promise<string> {
|
||||
|
||||
if(!fs.existsSync(path.join(this.basePath, 'Cache/WL'))) {
|
||||
fs.mkdirSync(path.join(this.basePath, 'Cache/WL'), { recursive: true });
|
||||
}
|
||||
|
||||
return 'Cache/WL';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get remote version information from the server
|
||||
* @returns {Promise<Versions>} List of versions from the server
|
||||
*/
|
||||
async GetRemoteVersion(): Promise<Versions> {
|
||||
|
||||
if(this.remoteVersions.length > 0) {
|
||||
return this.remoteVersions;
|
||||
}
|
||||
|
||||
const cacheFolder = await this.GetCacheFolder();
|
||||
const downloader = this.DownloaderInstance();
|
||||
const result = await downloader.downloadFile(
|
||||
'version.json',
|
||||
path.join(cacheFolder,'version.json')
|
||||
);
|
||||
if (!result) {
|
||||
throw new Error('Failed to download version file');
|
||||
}
|
||||
|
||||
const jsonfile = await fs.promises.readFile(path.join(this.basePath, cacheFolder, 'version.json'), 'utf8')
|
||||
const parsed = JSON.parse(jsonfile);
|
||||
this.remoteVersions = parsed.versions as Versions;
|
||||
|
||||
return this.remoteVersions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Total List of files from updates that need to be installed.
|
||||
* @returns {Promise<WowLauncher.PatchFile[]>} List of custom files from versions that need installed
|
||||
*/
|
||||
async GetCustomFiles(): Promise<WowLauncher.PatchFile[]> {
|
||||
const remoteVersion = await this.GetRemoteVersion();
|
||||
|
||||
if(remoteVersion.length === 0) {
|
||||
throw new Error('No remote version information');
|
||||
}
|
||||
|
||||
const customFiles: WowLauncher.PatchFile[] = [];
|
||||
const map = new Map();
|
||||
remoteVersion.forEach((version: VersionDetails) => {
|
||||
for(const file of version.files) {
|
||||
if(!map.has(file.name)){
|
||||
map.set(file.name, true); // set any value to Map
|
||||
customFiles.push({
|
||||
name: file.name,
|
||||
description: file.description,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return customFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
* Latest news comes in the form of a markdown file.
|
||||
* @returns {Promise<string>} The latest news from the server.
|
||||
*/
|
||||
async GetLatestNews(): Promise<string> {
|
||||
|
||||
const cacheFolder = await this.GetCacheFolder();
|
||||
const downloader = this.DownloaderInstance();
|
||||
const news = await downloader.getLatestNews();
|
||||
|
||||
if(news === undefined) {
|
||||
return '';
|
||||
}
|
||||
|
||||
fs.writeFile(path.join(this.basePath, cacheFolder, 'news.md'), news);
|
||||
return news;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the installed version of the client files.
|
||||
* @returns {Promise<string>} The local version of the client.
|
||||
*/
|
||||
async GetVersion(): Promise<Manifest> {
|
||||
const manifest: Manifest = await this.GetManifest();
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the list of installed patches on the host machine.
|
||||
* @returns {Promise<WowLauncher.PatchFile[] | null>} List of all installed patches.
|
||||
*/
|
||||
async GetInstalledPatches(): Promise<WowLauncher.PatchFile[] | null> {
|
||||
const filelist = await fs.promises.readdir(this.dataPath);
|
||||
const installed = filelist
|
||||
.filter((file) => file.endsWith('.MPQ') && this.patchNames.includes(file))
|
||||
.map((file) => this.allPatches.find((patch) => patch.name === file)!)
|
||||
.filter((patch) => !!patch);
|
||||
|
||||
const patchesToRemove = await Promise.all(
|
||||
installed.map(async (patch) => {
|
||||
const isPatched = await this.IsPatched(patch.name);
|
||||
if (!isPatched) {
|
||||
return patch; // Return the patch that needs to be removed
|
||||
}
|
||||
return null; // Return null for patches that should not be removed
|
||||
})
|
||||
).catch((error) => {
|
||||
console.error(error);
|
||||
});
|
||||
|
||||
const patchedInstalls = installed.filter(
|
||||
(patch) => !patchesToRemove?.includes(patch)
|
||||
);
|
||||
|
||||
return patchedInstalls;
|
||||
}
|
||||
|
||||
/**
|
||||
* This will determine if the patch locally matches the remote patch.
|
||||
* @param patch <string> | name of the patch to compare
|
||||
* @returns
|
||||
*/
|
||||
async IsPatched(patch: string): Promise<boolean> {
|
||||
const manifest = await this.GetManifest();
|
||||
const remoteETag = await this.downloader.getETag(`patches/${patch}`);
|
||||
|
||||
if (!manifest.Files[`patches/${patch}`]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return manifest.Files[`patches/${patch}`] === santizeETag(remoteETag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of of which HD patches are installed.
|
||||
* @returns {Promise<WowLauncher.PatchFile[] | null>} List of installed HD patches.
|
||||
*/
|
||||
async GetInstalledHDPatches(): Promise<WowLauncher.PatchFile[] | null> {
|
||||
const installed = await this.GetInstalledPatches();
|
||||
const hdPatches =
|
||||
installed?.filter((patch) => HDPatchList.includes(patch)) ?? null;
|
||||
|
||||
return hdPatches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom Content allows for patches and addOns to be uploaded. This function
|
||||
* will handle each type of content. Based on the files requested by versions.
|
||||
*
|
||||
* @returns {Promise<Downloader | boolean>} Downloader instance if patches need to be installed, false if not.
|
||||
*/
|
||||
async InstallUpdates(): Promise<Downloader | boolean> {
|
||||
const files = await this.GetCustomFiles();
|
||||
|
||||
const downloader = this.DownloaderInstance();
|
||||
const filemapping: { remotePath: string; localPath: string }[] = [];
|
||||
let localPath = 'Data/';
|
||||
|
||||
files.forEach((file) => {
|
||||
|
||||
// custom content can also include addons in the file type
|
||||
if(file.name.includes('addOns')) {
|
||||
localPath = 'Interface/AddOns/';
|
||||
}
|
||||
|
||||
filemapping.push({
|
||||
remotePath: `${file.name}`,
|
||||
localPath: path.join(localPath, path.basename(file.name)),
|
||||
});
|
||||
});
|
||||
|
||||
downloader.on('end', async ({ file }) => {
|
||||
let baseName = 'custom/';
|
||||
|
||||
if(file.includes('addOns')) {
|
||||
setTimeout(() => {
|
||||
const zip = new AdmZip(path.join(this.basePath, file));
|
||||
zip.extractAllTo(path.join(this.basePath, 'Interface/AddOns'), true);
|
||||
}, 200);
|
||||
|
||||
baseName = 'custom/addOns/';
|
||||
|
||||
}
|
||||
const remotefile = `${baseName}${path.basename(file)}`;
|
||||
const etag = await downloader.getETag(remotefile);
|
||||
|
||||
this.UpdateManifest(remotefile, etag);
|
||||
});
|
||||
|
||||
downloader.on('batchEnd', async () => {
|
||||
await this.UpdateLocalVersion();
|
||||
});
|
||||
|
||||
downloader.downloadFiles(filemapping);
|
||||
return downloader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Install a set of patches that are not installed.
|
||||
* @param group: patchGroup
|
||||
* @returns {Promise<Downloader | boolean>} Downloader instance if patches need to be installed, false if not.
|
||||
*/
|
||||
async InstallPatches(group: patchGroup): Promise<Downloader | boolean> {
|
||||
let patches: WowLauncher.PatchFile[] | null = null;
|
||||
switch (group) {
|
||||
case 'hd':
|
||||
patches = await this.GetMissingHDPatches();
|
||||
break;
|
||||
case 'misc':
|
||||
patches = await this.GetMissingMiscPatches();
|
||||
break;
|
||||
case 'experimental':
|
||||
/** @TODO Future Implementation */
|
||||
break;
|
||||
default:
|
||||
patches = null;
|
||||
break;
|
||||
}
|
||||
|
||||
if (patches) {
|
||||
const downloader = this.DownloaderInstance();
|
||||
|
||||
const filemapping: { remotePath: string; localPath: string }[] = [];
|
||||
patches.forEach((patch): void => {
|
||||
filemapping.push({
|
||||
remotePath: `patches/${patch.name}`,
|
||||
localPath: `Data/${patch.name}`,
|
||||
});
|
||||
});
|
||||
|
||||
downloader.on('end', async ({ file }) => {
|
||||
const remotefile = `patches/${path.basename(file)}`;
|
||||
const etag = await downloader.getETag(remotefile);
|
||||
this.UpdateManifest(remotefile, etag);
|
||||
});
|
||||
|
||||
downloader.downloadFiles(filemapping);
|
||||
return downloader;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of HD patches that are not installed.
|
||||
* @returns {Promise<WowLauncher.PatchFile[] | null>} List of HD patches that are not installed.
|
||||
*/
|
||||
async GetMissingHDPatches(): Promise<WowLauncher.PatchFile[] | null> {
|
||||
const installed = await this.GetInstalledPatches();
|
||||
const missing =
|
||||
HDPatchList.filter((patch) => !installed?.includes(patch)) ?? null;
|
||||
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of extra HD patches that are installed.
|
||||
* @returns {Promise<WowLauncher.PatchFile[] | null>} List of extra HD patches that are installed.
|
||||
*/
|
||||
async GetInstalledMiscPatches(): Promise<WowLauncher.PatchFile[] | null> {
|
||||
const installed = await this.GetInstalledPatches();
|
||||
const extraPatches =
|
||||
installed?.filter((patch) => MiscPatchList.includes(patch)) ?? null;
|
||||
|
||||
return extraPatches;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of extra HD patches that are not installed.
|
||||
* @returns {Promise<WowLauncher.PatchFile[] | null>} List of extra HD patches that are not installed.
|
||||
*/
|
||||
async GetMissingMiscPatches(): Promise<WowLauncher.PatchFile[] | null> {
|
||||
const installed = await this.GetInstalledPatches();
|
||||
const missing =
|
||||
MiscPatchList.filter((patch) => !installed?.includes(patch)) ?? null;
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of reserved custom patches that are installed.
|
||||
*/
|
||||
async GetCustomPatches(): Promise<WowLauncher.PatchFile[] | null> {
|
||||
const installed = await this.GetInstalledPatches();
|
||||
const remoteVersion = await this.GetRemoteVersion();
|
||||
|
||||
const araxia =
|
||||
installed?.filter((patch) => Reserved.includes(patch)) ?? null;
|
||||
return araxia;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of reserved custom patches that are not installed.
|
||||
*/
|
||||
async GetMissingCustomPatches(): Promise<WowLauncher.PatchFile[] | null> {
|
||||
const installed = await this.GetInstalledPatches();
|
||||
const missing =
|
||||
Reserved.filter((patch) => !installed?.includes(patch)) ?? null;
|
||||
return missing;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all HD patches are installed.
|
||||
* @return {Promise<boolean>} True if all HD patches are installed.
|
||||
*/
|
||||
async IsHDSetup(): Promise<boolean> {
|
||||
const missing = await this.GetMissingHDPatches();
|
||||
return missing?.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all feature patches are installed.
|
||||
* @returns {Promise<boolean>} True if all feature patches are installed.
|
||||
*/
|
||||
async IsMiscSetup(): Promise<boolean> {
|
||||
const missing = await this.GetMissingMiscPatches();
|
||||
return missing?.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if all custom patches are installed
|
||||
* @returns {Promise<boolean>} True if all Araxia patches are installed.
|
||||
*/
|
||||
async IsCustomSetup(): Promise<boolean> {
|
||||
const installed = await this.GetInstalledPatches();
|
||||
const missing =
|
||||
Reserved.filter((patch) => !installed?.includes(patch)) ?? null;
|
||||
return missing?.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* IS Wow.exe installed in the current director
|
||||
* @returns {Promise<boolean>} True if Wow.exe is installed.
|
||||
*/
|
||||
async IsWoWInstalled(): Promise<boolean> {
|
||||
return fs.existsSync(path.join(this.basePath, 'Wow.exe'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Is the base game patched to allow custom content and more memory for users?
|
||||
* @returns {Promise<boolean>} True if the base game is patched.
|
||||
*/
|
||||
async IsWoWPatched(): Promise<boolean> {
|
||||
const wowExe = path.join(this.basePath, 'Wow.exe');
|
||||
const content = await fs.promises.readFile(wowExe);
|
||||
const hash = crypto.createHash('md5').update(content);
|
||||
|
||||
return config.wowexe.patched_md5 === hash.digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* This will patch the local wow.exe with a patched version of wow.exe
|
||||
*/
|
||||
async PatchWowExe(): Promise<void> {
|
||||
await fs.promises.copyFile(
|
||||
path.join(this.basePath, 'Wow.exe'),
|
||||
path.join(this.basePath, 'Wow.exe.bak')
|
||||
);
|
||||
await fs.promises.copyFile(
|
||||
path.join(this.extraResources, 'Wow.exe'),
|
||||
path.join(this.basePath, 'Wow.exe')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* AIO client by rochet2 is required to be installed on the client for the store
|
||||
* to be in the client. This checks the manifest and the local file system
|
||||
* @returns {Promise<boolean>} True if the AIO client is installed.
|
||||
*/
|
||||
async IsAIOInstalled(): Promise<boolean> {
|
||||
const aioETag = await this.downloader.getETag(AIORemotePath);
|
||||
const inManifest = await this.CheckManifest(AIORemotePath, aioETag);
|
||||
const aioClient = path.join(this.addOnsPath, 'AIO_Client/AIO_Client.toc');
|
||||
|
||||
if (!inManifest || !fs.existsSync(aioClient)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the manifest files that decribes what things have been installed by the
|
||||
* client.
|
||||
* @returns {Promise<Manifest>} The manifest file.
|
||||
*/
|
||||
async GetManifest(): Promise<Manifest> {
|
||||
let manifest: Manifest;
|
||||
if (!fs.existsSync(this.manifestFile)) {
|
||||
manifest = {
|
||||
Version: 'v0',
|
||||
LastUpdate: new Date().toISOString(),
|
||||
Files: {},
|
||||
};
|
||||
|
||||
await fs.promises.writeFile(
|
||||
this.manifestFile,
|
||||
JSON.stringify(manifest, null, 2)
|
||||
);
|
||||
} else {
|
||||
manifest = JSON.parse(
|
||||
await fs.promises.readFile(this.manifestFile, 'utf8')
|
||||
) as Manifest;
|
||||
}
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks the local manifest file to see if the file has changed.
|
||||
* @param filename <string> | name of file to check
|
||||
* @param tag <string> | ETag of the file to check to see if it has changed
|
||||
*/
|
||||
async CheckManifest(filename: string, tag: string): Promise<boolean> {
|
||||
const manifest = await this.GetManifest();
|
||||
return manifest.Files[filename] === santizeETag(tag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the manifest file with the new ETag from the S3 server
|
||||
* @param filename <string> | path to the file
|
||||
* @param tag < string> | ETag of the file
|
||||
*/
|
||||
async UpdateManifest(filename: string, tag: string): Promise<void> {
|
||||
const manifest = await this.GetManifest();
|
||||
manifest.Files[filename] = santizeETag(tag);
|
||||
manifest.LastUpdate = new Date().toISOString();
|
||||
await fs.promises.writeFile(
|
||||
this.manifestFile,
|
||||
JSON.stringify(manifest, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This updates the client version to the latest versin from the server
|
||||
*/
|
||||
async UpdateLocalVersion() {
|
||||
const manifest = await this.GetManifest();
|
||||
const remoteVersion = await this.GetRemoteVersion();
|
||||
manifest.Version = remoteVersion[0].version;
|
||||
manifest.LastUpdate = new Date().toISOString();
|
||||
await fs.promises.writeFile(
|
||||
this.manifestFile,
|
||||
JSON.stringify(manifest, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads AIO from the client and saves it to the local machine
|
||||
*/
|
||||
async InstallAIO(): Promise<Downloader | boolean> {
|
||||
const installed = await this.IsAIOInstalled();
|
||||
if (installed) return true;
|
||||
|
||||
// we need a new instances to make sure we pass the correct event handler.
|
||||
const downloader = this.DownloaderInstance();
|
||||
const aioETag = await this.downloader.getETag(AIORemotePath);
|
||||
|
||||
downloader.on('end', () => {
|
||||
// update the manifest file with the new install need some more error handling here
|
||||
this.UpdateManifest(AIORemotePath, aioETag);
|
||||
|
||||
setTimeout(() => {
|
||||
const zip = new AdmZip(path.join(this.basePath, AIOLocalPath));
|
||||
zip.extractAllTo(path.join(this.basePath, 'Interface/AddOns'), true);
|
||||
}, 250);
|
||||
});
|
||||
|
||||
if (!(await downloader.downloadFile(AIORemotePath, AIOLocalPath))) {
|
||||
log.error('Failed to download AIO');
|
||||
}
|
||||
|
||||
return downloader;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user