From 1c766e1bb5340b82212c56034f3640505c68c4e0 Mon Sep 17 00:00:00 2001 From: Moritz Ruth Date: Mon, 15 Feb 2021 16:52:45 +0100 Subject: [PATCH] Refactor manager and add uninstall button --- src/main/.eslintrc.js | 4 +- src/main/{version.ts => constants.ts} | 2 + src/main/handleSquirrelEvents.ts | 4 +- src/main/isDevelopment.ts | 1 - src/main/main.ts | 25 +-- src/main/manager.ts | 232 -------------------------- src/main/manager/detectGameVersion.ts | 19 +++ src/main/manager/gameInfo.ts | 14 ++ src/main/manager/index.ts | 35 ++++ src/main/manager/installedMods.ts | 138 +++++++++++++++ src/main/manager/ipc.ts | 40 +++++ src/main/manager/progress.ts | 18 ++ src/main/manager/remoteMods.ts | 26 +++ src/main/manager/util.ts | 7 + src/main/window.ts | 6 +- src/renderer/components/ModCard.vue | 37 ++-- 16 files changed, 338 insertions(+), 270 deletions(-) rename src/main/{version.ts => constants.ts} (69%) delete mode 100644 src/main/isDevelopment.ts delete mode 100644 src/main/manager.ts create mode 100644 src/main/manager/detectGameVersion.ts create mode 100644 src/main/manager/gameInfo.ts create mode 100644 src/main/manager/index.ts create mode 100644 src/main/manager/installedMods.ts create mode 100644 src/main/manager/ipc.ts create mode 100644 src/main/manager/progress.ts create mode 100644 src/main/manager/remoteMods.ts create mode 100644 src/main/manager/util.ts diff --git a/src/main/.eslintrc.js b/src/main/.eslintrc.js index c8e30c9..6cc8f79 100644 --- a/src/main/.eslintrc.js +++ b/src/main/.eslintrc.js @@ -17,6 +17,8 @@ module.exports = { "mod": false, "props": false } - }] + }], + "@typescript-eslint/no-unsafe-call": "off", // many false positives + "@typescript-eslint/strict-boolean-expressions": "off" // many false positives } } diff --git a/src/main/version.ts b/src/main/constants.ts similarity index 69% rename from src/main/version.ts rename to src/main/constants.ts index f2febb8..8552515 100644 --- a/src/main/version.ts +++ b/src/main/constants.ts @@ -1,3 +1,5 @@ +export const isDevelopment: boolean = process.env.IS_DEV === "true" + // @ts-expect-error // eslint-disable-next-line no-undef,@typescript-eslint/no-unsafe-assignment export const MANAGER_VERSION: string = __MANAGER_VERSION diff --git a/src/main/handleSquirrelEvents.ts b/src/main/handleSquirrelEvents.ts index 3bcaf23..5a75a21 100644 --- a/src/main/handleSquirrelEvents.ts +++ b/src/main/handleSquirrelEvents.ts @@ -1,7 +1,7 @@ import childProcess from "child_process" import pathLib from "path" -import { app, dialog } from "electron" -import { isDevelopment } from "./isDevelopment" +import { app } from "electron" +import { isDevelopment } from "./constants" const spawn = (command: string, arguments_: string[]) => { let spawnedProcess diff --git a/src/main/isDevelopment.ts b/src/main/isDevelopment.ts deleted file mode 100644 index 1b75dfc..0000000 --- a/src/main/isDevelopment.ts +++ /dev/null @@ -1 +0,0 @@ -export const isDevelopment: boolean = process.env.IS_DEV === "true" diff --git a/src/main/main.ts b/src/main/main.ts index 75ca900..db94f45 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -1,9 +1,9 @@ -import { app, dialog, Menu } from "electron" +import { app, Menu, shell } from "electron" import { registerWindowIPC } from "./registerWindowIPC" -import { MANAGER_VERSION } from "./version" -import { initiateManager, isAmongUsInstalled } from "./manager" import { createWindow, getWindow } from "./window" import { handleSquirrelEvents } from "./handleSquirrelEvents" +import { getOriginalGameVersion, initiateManager, loadGameVersionOrShowError, STEAM_APPS_DIRECTORY } from "./manager" +import { MANAGER_VERSION } from "./constants" if (!handleSquirrelEvents()) { if (!app.requestSingleInstanceLock()) { @@ -12,17 +12,18 @@ if (!handleSquirrelEvents()) { } app.on("ready", async () => { - if (!await isAmongUsInstalled()) { - dialog.showErrorBox( - "Among Us could not be found", - "Please make sure Among Us is installed in the default location of Steam games." - ) - - app.exit(1) - } + await loadGameVersionOrShowError() app.applicationMenu = Menu.buildFromTemplate([ - { label: `Version: ${MANAGER_VERSION}`, enabled: false }, + { label: `Manager: ${MANAGER_VERSION}`, enabled: false }, + { label: `Among Us: ${getOriginalGameVersion()}`, enabled: false }, + { type: "separator" }, + { + label: "Open Steam games directory", + click() { + shell.openPath(STEAM_APPS_DIRECTORY) + } + }, { label: "Show devtools", click() { diff --git a/src/main/manager.ts b/src/main/manager.ts deleted file mode 100644 index d8b6314..0000000 --- a/src/main/manager.ts +++ /dev/null @@ -1,232 +0,0 @@ -import pathLib from "path" -import got from "got" -import fs from "fs-extra" -import { app, dialog, ipcMain } from "electron" -import download from "download" -import decompress from "decompress" -import execa from "execa" -import semver from "semver" -import { getWindow } from "./window" -import { MANAGER_VERSION } from "./version" -import { isDevelopment } from "./isDevelopment" - -const STEAM_APPS_DIRECTORY = "C:\\Program Files (x86)\\Steam\\steamapps\\common" -const ORIGINAL_GAME_DIRECTORY = pathLib.resolve(STEAM_APPS_DIRECTORY, "Among Us") - -interface RemoteModData { - id: string - title: string - author: string - projectURL: string - downloadURL: string - version: string - minManagerVersion: string -} - -interface InstalledModData { - id: string - version: string - path: string - amongUsVersion: string -} - -interface UIModData { - id: string - title: string - author: string - projectURL: string - installedVersion: string | null - outdated: boolean -} - -let activeModId: string | null = null -let originalGameVersion: string - -export const installedMods: InstalledModData[] = [] -export const remoteMods: RemoteModData[] = [] - -export function isModActive() { - return activeModId !== null -} - -async function detectAmongUsVersion(directory: string): Promise { - const content = await fs.readFile( - pathLib.resolve(directory, "./Among Us_Data/globalgamemanagers"), - { encoding: "utf8" } - ) - - const regex = /\d{4}\.\d{1,4}\.\d{1,4}/gu - regex.exec(content) - const match = regex.exec(content) - - if (match !== null) { - return match[0] - } - - throw new Error("Among Us version could not be detected") -} - -export async function discoverInstalledMods() { - if (installedMods.length > 0) return - const directoryNames = await fs.readdir(STEAM_APPS_DIRECTORY) - const mods = (await Promise.all(directoryNames.map>(async name => { - const directory = pathLib.resolve(STEAM_APPS_DIRECTORY, name) - const dataPath = pathLib.resolve(directory, "aumm.json") - - if (await fs.pathExists(dataPath)) { - const data = await fs.readJson(dataPath) as Omit - const amongUsVersion = await detectAmongUsVersion(directory) - - return { path: directory, amongUsVersion, ...data } as InstalledModData - } - - return null - }))).filter(mod => mod !== null) as InstalledModData[] - - installedMods.push(...mods) -} - -export async function fetchRemoteMods(): Promise { - try { - remoteMods.push(...(await got("http://m0.is/amongus-mods", { responseType: "json" })).body as RemoteModData[]) - } catch { - dialog.showErrorBox("Mods could not be loaded.", "Please check your internet connection.") - app.exit(1) - throw new Error("Mods could not be fetched.") - } -} - -export async function isAmongUsInstalled(): Promise { - return fs.pathExists(pathLib.resolve(STEAM_APPS_DIRECTORY, "Among Us")) -} - -export function getUIModData(): UIModData[] { - return remoteMods.map(remoteMod => { - const installedMod = installedMods.find(mod => mod.id === remoteMod.id) - - return { - id: remoteMod.id, - title: remoteMod.title, - author: remoteMod.author, - installedVersion: installedMod?.version ?? null, - projectURL: remoteMod.projectURL, - outdated: installedMod === undefined - ? false - : installedMod.version !== remoteMod.version || installedMod.amongUsVersion !== originalGameVersion - } - }) -} - -async function saveInstalledModData(mod: InstalledModData) { - const { path, ...data } = mod - await fs.writeJson(pathLib.resolve(path, "aumm.json"), data) -} - -function sendUIModData() { - getWindow().webContents.send("manager:mods-updated", getUIModData()) -} - -interface ProgressState { - title: string - text: string - finished: boolean -} - -let currentProgressState: ProgressState = { - title: "", - text: "", - finished: true -} - -function updateProgress(state: Partial) { - currentProgressState = { ...currentProgressState, ...state } - getWindow().webContents.send("manager:progress", currentProgressState) -} - -async function installMod(remoteMod: RemoteModData) { - const alreadyInstalledIndex = installedMods.findIndex(mod => mod.id === remoteMod.id) - if (alreadyInstalledIndex !== -1) installedMods.splice(alreadyInstalledIndex, 1) - - const directory = pathLib.resolve(STEAM_APPS_DIRECTORY, `Among Us (${remoteMod.title})`) - - updateProgress({ title: "Install: " + remoteMod.title, text: "Preparing...", finished: false }) - if (await fs.pathExists(directory)) await fs.remove(directory) - - updateProgress({ text: "Copying game files..." }) - await fs.copy(ORIGINAL_GAME_DIRECTORY, directory) - - const request = download(remoteMod.downloadURL, directory, { filename: "__archive" }) - - request.on("downloadProgress", ({ percent }) => { - updateProgress({ text: `Downloading... (${Math.trunc(percent * 100)}%)` }) - }) - - await request - - updateProgress({ text: "Extracting..." }) - - const archivePath = pathLib.resolve(directory, "__archive") - await decompress(archivePath, directory) - - updateProgress({ text: "Cleaning up..." }) - await fs.remove(archivePath) - - const installedMod: InstalledModData = { - id: remoteMod.id, - version: remoteMod.version, - path: directory, - amongUsVersion: originalGameVersion - } - - updateProgress({ text: "Saving metadata..." }) - await saveInstalledModData(installedMod) - installedMods.push(installedMod) - sendUIModData() - - updateProgress({ finished: true }) -} - -async function startModdedGame(id: string) { - const installedMod = installedMods.find(mod => mod.id === id)! - - const process = execa( - pathLib.resolve(installedMod.path, "Among Us.exe"), - { detached: false, stdout: "ignore", stderr: isDevelopment ? "inherit" : "ignore", windowsHide: false } - ) - - activeModId = installedMod.id - getWindow().webContents.send("manager:game-started", installedMod.id) - - process.on("exit", async () => { - activeModId = null - - const window = getWindow() - window.webContents.send("manager:game-stopped") - if (!window.isVisible()) app.exit() - }) -} - -async function tryInstallMod(id: string): Promise { - const remoteMod = remoteMods.find(mod => mod.id === id)! - - if (semver.lt(MANAGER_VERSION, remoteMod.minManagerVersion)) return false - - await installMod(remoteMod) - return true -} - -export function initiateManager() { - ipcMain.handle("manager:get-mods", () => getUIModData()) - ipcMain.handle("manager:install", async (event, id) => tryInstallMod(id)) - ipcMain.handle("manager:start", async (event, id) => startModdedGame(id)) - - Promise.all([ - discoverInstalledMods(), - fetchRemoteMods(), - (async () => { - originalGameVersion = await detectAmongUsVersion(ORIGINAL_GAME_DIRECTORY) - })() - ]).then(() => { - sendUIModData() - }) -} diff --git a/src/main/manager/detectGameVersion.ts b/src/main/manager/detectGameVersion.ts new file mode 100644 index 0000000..56d1f0e --- /dev/null +++ b/src/main/manager/detectGameVersion.ts @@ -0,0 +1,19 @@ +import pathLib from "path" +import fs from "fs-extra" + +export async function detectGameVersion(directory: string): Promise { + const content = await fs.readFile( + pathLib.resolve(directory, "./Among Us_Data/globalgamemanagers"), + { encoding: "utf8" } + ) + + const regex = /\d{4}\.\d{1,4}\.\d{1,4}/gu + regex.exec(content) + const match = regex.exec(content) + + if (match !== null) { + return match[0] + } + + throw new Error("Among Us version could not be detected") +} diff --git a/src/main/manager/gameInfo.ts b/src/main/manager/gameInfo.ts new file mode 100644 index 0000000..c00393b --- /dev/null +++ b/src/main/manager/gameInfo.ts @@ -0,0 +1,14 @@ +import pathLib from "path" +import fs from "fs-extra" +import { detectGameVersion } from "./detectGameVersion" +import { STEAM_APPS_DIRECTORY } from "./util" + +export const ORIGINAL_GAME_DIRECTORY = pathLib.resolve(STEAM_APPS_DIRECTORY, "Among Us") + +let originalGameVersion: string +export const getOriginalGameVersion = () => originalGameVersion + +export async function detectOriginalGameVersion() { + if (!await fs.pathExists(ORIGINAL_GAME_DIRECTORY)) throw new Error("Among Us could not be found") + originalGameVersion = await detectGameVersion(ORIGINAL_GAME_DIRECTORY) +} diff --git a/src/main/manager/index.ts b/src/main/manager/index.ts new file mode 100644 index 0000000..819662f --- /dev/null +++ b/src/main/manager/index.ts @@ -0,0 +1,35 @@ +import { app, dialog } from "electron" +import { detectOriginalGameVersion } from "./gameInfo" +import { registerIPC, sendUIModData } from "./ipc" +import { fetchRemoteMods } from "./remoteMods" +import { discoverInstalledMods } from "./installedMods" +import { STEAM_APPS_DIRECTORY } from "./util" + +export function initiateManager() { + registerIPC() + + Promise.all([ + discoverInstalledMods(), + fetchRemoteMods(), + detectOriginalGameVersion() + ]).then(() => { + sendUIModData() + }) +} + +export async function loadGameVersionOrShowError() { + try { + await detectOriginalGameVersion() + } catch { + dialog.showErrorBox( + "Among Us could not be found", + `Please make sure Among Us is installed in the default location of Steam games. (${STEAM_APPS_DIRECTORY})` + ) + + app.exit(1) + } +} + +export { isGameRunning } from "./installedMods" +export { getOriginalGameVersion } from "./gameInfo" +export { STEAM_APPS_DIRECTORY } from "./util" diff --git a/src/main/manager/installedMods.ts b/src/main/manager/installedMods.ts new file mode 100644 index 0000000..a22e899 --- /dev/null +++ b/src/main/manager/installedMods.ts @@ -0,0 +1,138 @@ +import pathLib from "path" +import fs from "fs-extra" +import download from "download" +import decompress from "decompress" +import execa from "execa" +import { app } from "electron" +import semver from "semver" +import { getWindow } from "../window" +import { isDevelopment, MANAGER_VERSION } from "../constants" +import { detectGameVersion } from "./detectGameVersion" +import { send, STEAM_APPS_DIRECTORY } from "./util" +import { getRemoteMod, RemoteMod } from "./remoteMods" +import { updateProgress } from "./progress" +import { getOriginalGameVersion, ORIGINAL_GAME_DIRECTORY } from "./gameInfo" +import { sendUIModData } from "./ipc" + +interface InstalledMod { + id: string + version: string + path: string + amongUsVersion: string +} + +let installedMods: InstalledMod[] = [] +export const getInstalledMods = () => installedMods +export const getInstalledMod: (id: string) => InstalledMod = id => installedMods.find(mod => mod.id === id)! + +export async function discoverInstalledMods() { + const directoryNames = await fs.readdir(STEAM_APPS_DIRECTORY) + installedMods = (await Promise.all(directoryNames.map>(async name => { + const directory = pathLib.resolve(STEAM_APPS_DIRECTORY, name) + const dataPath = pathLib.resolve(directory, "aumm.json") + + if (await fs.pathExists(dataPath)) { + const data = await fs.readJson(dataPath) as Omit + const amongUsVersion = await detectGameVersion(directory) + + return { path: directory, amongUsVersion, ...data } as InstalledMod + } + + return null + }))).filter(mod => mod !== null) as InstalledMod[] +} + +let activeModId: string | null = null +export const isGameRunning = () => activeModId !== null + +async function saveInstalledMod(mod: InstalledMod) { + const { path, ...data } = mod + await fs.writeJson(pathLib.resolve(path, "aumm.json"), data) +} + +async function installMod(remoteMod: RemoteMod) { + const alreadyInstalledIndex = installedMods.findIndex(mod => mod.id === remoteMod.id) + if (alreadyInstalledIndex !== -1) installedMods.splice(alreadyInstalledIndex, 1) + + const directory = pathLib.resolve(STEAM_APPS_DIRECTORY, `Among Us (${remoteMod.title})`) + + updateProgress({ title: "Install: " + remoteMod.title, text: "Preparing", finished: false }) + if (await fs.pathExists(directory)) await fs.remove(directory) + + updateProgress({ text: "Copying game files" }) + await fs.copy(ORIGINAL_GAME_DIRECTORY, directory) + + const request = download(remoteMod.downloadURL, directory, { filename: "__archive" }) + + request.on("downloadProgress", ({ percent }) => { + updateProgress({ text: `Downloading (${Math.trunc(percent * 100)}%)` }) + }) + + await request + + updateProgress({ text: "Extracting" }) + + const archivePath = pathLib.resolve(directory, "__archive") + await decompress(archivePath, directory) + + updateProgress({ text: "Cleaning up" }) + await fs.remove(archivePath) + + const installedMod: InstalledMod = { + id: remoteMod.id, + version: remoteMod.version, + path: directory, + amongUsVersion: getOriginalGameVersion() + } + + updateProgress({ text: "Saving metadata" }) + await saveInstalledMod(installedMod) + installedMods.push(installedMod) + sendUIModData() + + updateProgress({ finished: true }) +} + +export async function startMod(id: string) { + const installedMod = getInstalledMod(id) + + const process = execa( + pathLib.resolve(installedMod.path, "Among Us.exe"), + { detached: false, stdout: "ignore", stderr: isDevelopment ? "inherit" : "ignore", windowsHide: false } + ) + + activeModId = installedMod.id + send("manager:game-started", installedMod.id) + + process.on("exit", async () => { + activeModId = null + + const window = getWindow() + send("manager:game-stopped") + if (!window.isVisible()) app.exit() + }) +} + +export async function installModIfMinManagerVersionSatisfied(id: string): Promise { + const remoteMod = getRemoteMod(id) + if (semver.lt(MANAGER_VERSION, remoteMod.minManagerVersion)) return false + await installMod(remoteMod) + return true +} + +export async function uninstallMod(id: string): Promise { + const remoteMod = getRemoteMod(id) + const installedMod = getInstalledMod(id) + + updateProgress({ + title: `Uninstall: ${remoteMod.title}`, + text: "Removing game files", + finished: false + }) + + await fs.remove(installedMod.path) + + updateProgress({ finished: true }) + installedMods.splice(installedMods.findIndex(mod => mod.id === remoteMod.id), 1) + sendUIModData() +} diff --git a/src/main/manager/ipc.ts b/src/main/manager/ipc.ts new file mode 100644 index 0000000..8e6426d --- /dev/null +++ b/src/main/manager/ipc.ts @@ -0,0 +1,40 @@ +import { ipcMain } from "electron" +import { getOriginalGameVersion } from "./gameInfo" +import { send } from "./util" +import { getRemoteMods } from "./remoteMods" +import { getInstalledMods, installModIfMinManagerVersionSatisfied, startMod, uninstallMod } from "./installedMods" + +interface UIMod { + id: string + title: string + author: string + projectURL: string + installedVersion: string | null + outdated: boolean +} + +const getUIModData: () => UIMod[] = () => getRemoteMods().map(remoteMod => { + const installedMod = getInstalledMods().find(mod => mod.id === remoteMod.id) + + return { + id: remoteMod.id, + title: remoteMod.title, + author: remoteMod.author, + installedVersion: installedMod?.version ?? null, + projectURL: remoteMod.projectURL, + outdated: installedMod === undefined + ? false + : installedMod.version !== remoteMod.version || installedMod.amongUsVersion !== getOriginalGameVersion() + } +}) + +export function sendUIModData() { + send("manager:mods-updated", getUIModData()) +} + +export function registerIPC() { + ipcMain.handle("manager:get-mods", () => getUIModData()) + ipcMain.handle("manager:install", async (event, id) => installModIfMinManagerVersionSatisfied(id)) + ipcMain.handle("manager:uninstall", async (event, id) => uninstallMod(id)) + ipcMain.handle("manager:start", async (event, id) => startMod(id)) +} diff --git a/src/main/manager/progress.ts b/src/main/manager/progress.ts new file mode 100644 index 0000000..102d3f7 --- /dev/null +++ b/src/main/manager/progress.ts @@ -0,0 +1,18 @@ +import { send } from "./util" + +interface ProgressState { + title: string + text: string + finished: boolean +} + +let currentProgressState: ProgressState = { + title: "", + text: "", + finished: true +} + +export function updateProgress(state: Partial) { + currentProgressState = { ...currentProgressState, ...state } + send("manager:progress", currentProgressState) +} diff --git a/src/main/manager/remoteMods.ts b/src/main/manager/remoteMods.ts new file mode 100644 index 0000000..86afe2c --- /dev/null +++ b/src/main/manager/remoteMods.ts @@ -0,0 +1,26 @@ +import got from "got" +import { app, dialog } from "electron" + +export interface RemoteMod { + id: string + title: string + author: string + projectURL: string + downloadURL: string + version: string + minManagerVersion: string +} + +let remoteMods: RemoteMod[] = [] +export const getRemoteMods = () => remoteMods +export const getRemoteMod: (id: string) => RemoteMod = id => remoteMods.find(mod => mod.id === id)! + +export async function fetchRemoteMods(): Promise { + try { + remoteMods = (await got("http://m0.is/amongus-mods", { responseType: "json" })).body as RemoteMod[] + } catch { + dialog.showErrorBox("Mods could not be loaded.", "Please check your internet connection.") + app.exit(1) + throw new Error("Mods could not be fetched.") + } +} diff --git a/src/main/manager/util.ts b/src/main/manager/util.ts new file mode 100644 index 0000000..038af07 --- /dev/null +++ b/src/main/manager/util.ts @@ -0,0 +1,7 @@ +import { getWindow } from "../window" + +export const STEAM_APPS_DIRECTORY = "C:\\Program Files (x86)\\Steam\\steamapps\\common" + +export function send(channel: string, ...arguments_: unknown[]) { + getWindow().webContents.send(channel, ...arguments_) +} diff --git a/src/main/window.ts b/src/main/window.ts index e7ddad1..a6b7d68 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -1,9 +1,9 @@ import pathLib from "path" import { app, BrowserWindow, nativeImage as NativeImage, shell } from "electron" import windowStateKeeper from "electron-window-state" -import { isDevelopment } from "./isDevelopment" import { createTray, destroyTray } from "./tray" -import { isModActive } from "./manager" +import { isGameRunning } from "./manager" +import { isDevelopment } from "./constants" let window: BrowserWindow @@ -48,7 +48,7 @@ export async function createWindow() { window.on("show", () => destroyTray()) window.on("close", event => { - if (isModActive()) { + if (isGameRunning()) { event.preventDefault() window.hide() } else app.exit() diff --git a/src/renderer/components/ModCard.vue b/src/renderer/components/ModCard.vue index d2c3c9c..e86f5e4 100644 --- a/src/renderer/components/ModCard.vue +++ b/src/renderer/components/ModCard.vue @@ -1,5 +1,5 @@ @@ -66,14 +66,14 @@