├── .gitignore ├── LICENSE ├── README.md ├── babel.config.js ├── build ├── m3-icon.ico └── m3-icon.png ├── package.json ├── public └── index.html ├── repo ├── app-screenshot--discover.png ├── app-screenshot--settings.png ├── app-screenshot.png └── m3-small.png ├── src ├── App.vue ├── api.js ├── assets │ ├── m3-small.png │ ├── m3-smaller.png │ └── m3.png ├── background.js ├── bus.js ├── components │ ├── AddFromFile.vue │ ├── AddFromUrl.vue │ ├── BrowserMod.vue │ ├── MainHeader.vue │ ├── MelvorLocator.vue │ ├── Mod.vue │ ├── ModList.vue │ ├── ModListHeader.vue │ ├── PromptMoreInfo.vue │ ├── Sidebar.vue │ ├── SystemBar.vue │ ├── Toolbar.vue │ └── VersionChip.vue ├── fileHandler.js ├── main.js ├── messageTypes.js ├── modsHandler.js ├── plugins │ └── vuetify.js ├── preload.js ├── processHandler.js ├── router │ └── index.js ├── store │ └── index.js ├── util.js └── views │ ├── Browse.vue │ ├── Mods.vue │ └── Settings.vue └── vue.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | pnpm-debug.log* 15 | 16 | # Editor directories and files 17 | .idea 18 | .vscode 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | 25 | #Electron-builder output 26 | /dist_electronpackage-lock.json 27 | package-lock.json 28 | dist_electron/ 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2021-present, Chase (Buttchouda) Strackbein 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This project is no longer supported. Thanks to everyone who used and enjoyed M3! 2 | 3 | ## 🎉🎉🎉 Official mod support is out! Go use the in-game Mod Manager instead! 4 | 5 | ----- 6 | 7 | ![M3 Logo](repo/m3-small.png) 8 | # Melvor Mod Manager (M3) 9 | Melvor Mod Manager (M3) allows you to quickly add userscripts and browser extensions as mods to the Steam edition of Melvor Idle. 10 | 11 | ## Installation 12 | Download and run the setup .exe from the [Latest Release](https://github.com/ChaseStrackbein/melvor-mod-manager/releases/latest). You will likely receive a warning that the publisher is unknown - I do not have a code signing certificate so make sure you only download [releases from GitHub](https://github.com/ChaseStrackbein/melvor-mod-manager/releases) or [compile the code yourself](https://github.com/ChaseStrackbein/melvor-mod-manager/#steps-for-compiling-m3-yourself). 13 | 14 | ## Use 15 | **!!! Only add scripts or extensions that you trust !!!** 16 | 17 | **And as always, backing up your save(s) is a good idea before using any new tool or mod.** 18 | 19 | Once installed and launched: 20 | 1. Click the 'Browse' button and find your Melvor Idle installation directory. It is most likely something like: `C:\Program Files (x86)\Steam\steamapps\common\Melvor Idle` 21 | * *Note: On MacOS, you may need to make sure your Library directory is visible in the Finder.* 22 | 2. Add popular mods from the curated **Discover** tab. Simply find the mod you want and click **Install**! 23 | 3. Alternatively, you can click the **Add +** button and manually add a mod from either a file or a web URL. 24 | 4. Launch the game using the button in the upper-right and your mods should be loaded upon selecting your character. 25 | 26 | ### Add from Discover Tab 27 | Mods can be added from the Discover tab. This is a curated list of popular mods that have generally been tested by the community. Still, I cannot guarantee them to be working or not including malicious code - I'll do my best to inspect the code and test them, I urge you to use the **Info** button to guage the mod for yourself. 28 | 29 | ### Add From File 30 | Files can be either a JavaScript (.js) file formatted with UserScript metadata or a WebExtension manifest (manifest.json). This means that for extensions, you should manually download them (for example, from the Combat Simulator Reloaded's [release page](https://github.com/visua0/Melvor-Idle-Combat-Simulator-Reloaded/releases)), extract the .zip file, and then navigate the M3 file prompt to the manifest.json file found within. 31 | 32 | ### Add From Web 33 | Currently only GreasyFork userscript URLs are supported. Example: `https://greasyfork.org/en/scripts/428146-quickshards-for-melvor-idle` 34 | 35 | ### Updating Mods 36 | M3 currently only supports updating of mods through the UI that have been added via the Discover tab or GreasyFork. For all other mods, you should first remove it and then re-add using the newer version. 37 | 38 | ### Mod Load Order 39 | You can adjust the load order of the mods using the arrows on the right-hand side of the mod list. This may help in resolving dependencies in the correct order. 40 | 41 | ## Screenshots 42 | ![Screenshot of Discover Tab](repo/app-screenshot--discover.png) 43 | ![Screenshot of M3](repo/app-screenshot.png) 44 | ![Screenshot of Settings Tab](repo/app-screenshot--settings.png) 45 | 46 | ## Steps for Compiling M3 Yourself 47 | For now, in order to use M3 on MacOS or Linux, you must build the application yourself. You may also choose to do this on Windows. 48 | 1. Download and install [Node.js](https://nodejs.org/en/) 49 | 2. Download the M3 source code from the [latest release](https://github.com/CherryMace/melvor-mod-manager/releases/latest) and unzip it. 50 | 3. Open the directory in your choice of terminal/command prompt. 51 | 4. Install dependencies using `npm install`. This may take several minutes. 52 | 5. Package into an executable using `npm run electron:build`. 53 | 6. The resulting application that may be installed should be available within `dist_electron`. 54 | 55 | ## License 56 | MIT 57 | 58 | ## Submitting Feedback or Contributing 59 | Feel free to [create an issue here on GitHub](https://github.com/ChaseStrackbein/melvor-mod-manager/issues) or reach out to me on Discord @ Buttchouda#3950. 60 | 61 | To develop locally, ensure you have [Node.js](https://nodejs.org/en/) installed, install dependencies using `npm install`, and start a dev instance using `npm run electron:serve`. -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/cli-plugin-babel/preset' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /build/m3-icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CherryMace/melvor-mod-manager/00bfaf4f544547149868931baefb6a9393432f4b/build/m3-icon.ico -------------------------------------------------------------------------------- /build/m3-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CherryMace/melvor-mod-manager/00bfaf4f544547149868931baefb6a9393432f4b/build/m3-icon.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "melvor-mod-manager", 3 | "version": "1.0.0", 4 | "private": true, 5 | "description": "Melvor Mod Manager (M3)", 6 | "author": { 7 | "name": "Chase Strackbein", 8 | "email": "chase@cherrymace.com" 9 | }, 10 | "scripts": { 11 | "serve": "vue-cli-service serve", 12 | "build": "vue-cli-service build", 13 | "lint": "vue-cli-service lint", 14 | "electron:build": "vue-cli-service electron:build", 15 | "electron:serve": "vue-cli-service electron:serve", 16 | "postinstall": "electron-builder install-app-deps", 17 | "postuninstall": "electron-builder install-app-deps" 18 | }, 19 | "main": "background.js", 20 | "dependencies": { 21 | "axios": "^0.24.0", 22 | "cheerio": "^1.0.0-rc.10", 23 | "core-js": "^3.6.5", 24 | "decompress": "^4.2.1", 25 | "electron-dl": "^3.2.1", 26 | "electron-updater": "^4.3.9", 27 | "electron-window-state": "^5.0.3", 28 | "fs-extra": "^10.0.0", 29 | "open": "^8.2.1", 30 | "simple-git": "^2.48.0", 31 | "userscript-parser": "^2.2.2", 32 | "vue": "^2.6.11", 33 | "vue-router": "^3.2.0", 34 | "vuetify": "^2.5.6", 35 | "vuex": "^3.4.0" 36 | }, 37 | "devDependencies": { 38 | "@vue/cli-plugin-babel": "~4.5.0", 39 | "@vue/cli-plugin-eslint": "~4.5.0", 40 | "@vue/cli-plugin-router": "^4.5.13", 41 | "@vue/cli-plugin-vuex": "^4.5.13", 42 | "@vue/cli-service": "^4.5.15", 43 | "babel-eslint": "^10.1.0", 44 | "electron": "^15.3.0", 45 | "electron-builder": "^22.11.7", 46 | "electron-devtools-installer": "^3.1.0", 47 | "eslint": "^6.7.2", 48 | "eslint-plugin-vue": "^6.2.2", 49 | "material-design-icons-iconfont": "^6.1.0", 50 | "sass": "~1.32.0", 51 | "sass-loader": "^10.0.0", 52 | "vue-cli-plugin-electron-builder": "^2.1.1", 53 | "vue-cli-plugin-vuetify": "^2.4.1", 54 | "vue-template-compiler": "^2.6.11", 55 | "vuetify-loader": "^1.7.2" 56 | }, 57 | "eslintConfig": { 58 | "root": true, 59 | "env": { 60 | "node": true 61 | }, 62 | "extends": [ 63 | "plugin:vue/essential", 64 | "eslint:recommended" 65 | ], 66 | "parserOptions": { 67 | "parser": "babel-eslint" 68 | }, 69 | "rules": {} 70 | }, 71 | "browserslist": [ 72 | "> 1%", 73 | "last 2 versions", 74 | "not dead" 75 | ], 76 | "license": "MIT", 77 | "repository": "https://github.com/CherryMace/melvor-mod-manager" 78 | } 79 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | <%= htmlWebpackPlugin.options.title %> 10 | 11 | 12 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /repo/app-screenshot--discover.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CherryMace/melvor-mod-manager/00bfaf4f544547149868931baefb6a9393432f4b/repo/app-screenshot--discover.png -------------------------------------------------------------------------------- /repo/app-screenshot--settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CherryMace/melvor-mod-manager/00bfaf4f544547149868931baefb6a9393432f4b/repo/app-screenshot--settings.png -------------------------------------------------------------------------------- /repo/app-screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CherryMace/melvor-mod-manager/00bfaf4f544547149868931baefb6a9393432f4b/repo/app-screenshot.png -------------------------------------------------------------------------------- /repo/m3-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CherryMace/melvor-mod-manager/00bfaf4f544547149868931baefb6a9393432f4b/repo/m3-small.png -------------------------------------------------------------------------------- /src/App.vue: -------------------------------------------------------------------------------- 1 | 21 | 22 | 35 | 36 | -------------------------------------------------------------------------------- /src/api.js: -------------------------------------------------------------------------------- 1 | export const process = window.process; 2 | export const file = window.file; 3 | export const mods = window.mods; 4 | 5 | export default { process, file, mods }; 6 | -------------------------------------------------------------------------------- /src/assets/m3-small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CherryMace/melvor-mod-manager/00bfaf4f544547149868931baefb6a9393432f4b/src/assets/m3-small.png -------------------------------------------------------------------------------- /src/assets/m3-smaller.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CherryMace/melvor-mod-manager/00bfaf4f544547149868931baefb6a9393432f4b/src/assets/m3-smaller.png -------------------------------------------------------------------------------- /src/assets/m3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CherryMace/melvor-mod-manager/00bfaf4f544547149868931baefb6a9393432f4b/src/assets/m3.png -------------------------------------------------------------------------------- /src/background.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | import { app, protocol, BrowserWindow, ipcMain } from 'electron'; 4 | import { createProtocol } from 'vue-cli-plugin-electron-builder/lib'; 5 | import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer'; 6 | import windowStateKeeper from 'electron-window-state'; 7 | import processHandler from './processHandler'; 8 | import fileHandler from './fileHandler'; 9 | import modsHandler from './modsHandler'; 10 | import path from 'path'; 11 | import { autoUpdater } from 'electron-updater'; 12 | const isDevelopment = process.env.NODE_ENV !== 'production'; 13 | 14 | // Scheme must be registered before the app is ready 15 | protocol.registerSchemesAsPrivileged([ 16 | { scheme: 'app', privileges: { secure: true, standard: true } } 17 | ]); 18 | 19 | async function createWindow() { 20 | const mainWindowState = windowStateKeeper({ 21 | defaultWidth: 1000, 22 | defaultHeight: 800 23 | }); 24 | 25 | // Create the browser window. 26 | const win = new BrowserWindow({ 27 | x: mainWindowState.x, 28 | y: mainWindowState.y, 29 | width: mainWindowState.width, 30 | height: mainWindowState.height, 31 | frame: false, 32 | webPreferences: { 33 | preload: path.join(__dirname, 'preload.js'), 34 | // Use pluginOptions.nodeIntegration, leave this alone 35 | // See nklayman.github.io/vue-cli-plugin-electron-builder/guide/security.html#node-integration for more info 36 | nodeIntegration: process.env.ELECTRON_NODE_INTEGRATION, 37 | contextIsolation: !process.env.ELECTRON_NODE_INTEGRATION 38 | } 39 | }); 40 | 41 | mainWindowState.manage(win); 42 | 43 | if (process.env.WEBPACK_DEV_SERVER_URL) { 44 | // Load the url of the dev server if in development mode 45 | await win.loadURL(process.env.WEBPACK_DEV_SERVER_URL); 46 | if (!process.env.IS_TEST) win.webContents.openDevTools(); 47 | } else { 48 | createProtocol('app'); 49 | // Load the index.html when not in development 50 | win.removeMenu(); 51 | win.loadURL('app://./index.html'); 52 | autoUpdater.checkForUpdatesAndNotify(); 53 | } 54 | } 55 | 56 | // Quit when all windows are closed. 57 | app.on('window-all-closed', () => { 58 | // On macOS it is common for applications and their menu bar 59 | // to stay active until the user quits explicitly with Cmd + Q 60 | if (process.platform !== 'darwin') { 61 | app.quit(); 62 | } 63 | }); 64 | 65 | app.on('activate', () => { 66 | // On macOS it's common to re-create a window in the app when the 67 | // dock icon is clicked and there are no other windows open. 68 | if (BrowserWindow.getAllWindows().length === 0) createWindow(); 69 | }); 70 | 71 | // This method will be called when Electron has finished 72 | // initialization and is ready to create browser windows. 73 | // Some APIs can only be used after this event occurs. 74 | app.on('ready', async () => { 75 | if (isDevelopment && !process.env.IS_TEST) { 76 | // Install Vue Devtools 77 | try { 78 | await installExtension(VUEJS_DEVTOOLS); 79 | } catch (e) { 80 | console.error('Vue Devtools failed to install:', e.toString()); 81 | } 82 | } 83 | 84 | ipcMain.handle('process', processHandler); 85 | ipcMain.handle('file', fileHandler); 86 | ipcMain.handle('mods', modsHandler); 87 | 88 | createWindow(); 89 | }); 90 | 91 | // Exit cleanly on request from parent process in development mode. 92 | if (isDevelopment) { 93 | if (process.platform === 'win32') { 94 | process.on('message', (data) => { 95 | if (data === 'graceful-exit') { 96 | app.quit(); 97 | } 98 | }); 99 | } else { 100 | process.on('SIGTERM', () => { 101 | app.quit(); 102 | }); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/bus.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | 3 | Vue.config.productionTip = false; 4 | 5 | export default new Vue(); 6 | -------------------------------------------------------------------------------- /src/components/AddFromFile.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 92 | 93 | -------------------------------------------------------------------------------- /src/components/AddFromUrl.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 104 | -------------------------------------------------------------------------------- /src/components/BrowserMod.vue: -------------------------------------------------------------------------------- 1 | 38 | 39 | -------------------------------------------------------------------------------- /src/components/MainHeader.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | -------------------------------------------------------------------------------- /src/components/MelvorLocator.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 68 | 69 | -------------------------------------------------------------------------------- /src/components/Mod.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 46 | 47 | -------------------------------------------------------------------------------- /src/components/ModList.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 36 | 37 | -------------------------------------------------------------------------------- /src/components/ModListHeader.vue: -------------------------------------------------------------------------------- 1 | 78 | 79 | 167 | -------------------------------------------------------------------------------- /src/components/PromptMoreInfo.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 72 | -------------------------------------------------------------------------------- /src/components/Sidebar.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 67 | 68 | -------------------------------------------------------------------------------- /src/components/SystemBar.vue: -------------------------------------------------------------------------------- 1 | 16 | 17 | 34 | 35 | -------------------------------------------------------------------------------- /src/components/Toolbar.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | -------------------------------------------------------------------------------- /src/components/VersionChip.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | -------------------------------------------------------------------------------- /src/fileHandler.js: -------------------------------------------------------------------------------- 1 | import { dialog } from 'electron'; 2 | import { access } from 'fs/promises'; 3 | import path from 'path'; 4 | import { getExecutableFilename } from './util' 5 | import { file } from './messageTypes'; 6 | 7 | export default async (_event, message) => { 8 | return await handlers[message.type](message); 9 | }; 10 | 11 | const handlers = { 12 | [file.openScript]: async () => { 13 | // Open .js or .json and parse manifest 14 | const res = await dialog.showOpenDialog({ 15 | properties: ['openFile'], 16 | filters: [ 17 | { name: 'Userscript or extension manifest', extensions: ['js', 'json'] } 18 | ] 19 | }); 20 | 21 | return res.canceled ? null : res.filePaths[0]; 22 | }, 23 | 24 | [file.openDir]: async () => { 25 | // Prompt directory and return path 26 | const res = await dialog.showOpenDialog({ properties: ['openFile', 'openDirectory'] }); 27 | const dir = res.canceled ? null : res.filePaths[0]; 28 | return dir; 29 | }, 30 | 31 | [file.validateMelvorDir]: async ({ dir }) => { 32 | // Validate the executable exists in dir 33 | const melvorPath = path.join(dir, getExecutableFilename(process.platform)); 34 | try { 35 | await access(melvorPath); 36 | return true; 37 | } catch { 38 | return false; 39 | } 40 | } 41 | }; -------------------------------------------------------------------------------- /src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import App from './App.vue'; 3 | import vuetify from './plugins/vuetify'; 4 | import store from './store'; 5 | import router from './router'; 6 | 7 | Vue.config.productionTip = false; 8 | 9 | new Vue({ 10 | vuetify, 11 | store, 12 | router, 13 | render: h => h(App) 14 | }).$mount('#app'); 15 | -------------------------------------------------------------------------------- /src/messageTypes.js: -------------------------------------------------------------------------------- 1 | export const process = { 2 | launchMelvor: 'launch-melvor', 3 | openLink: 'open-link', 4 | minimize: 'minimize', 5 | maximize: 'maximize', 6 | exit: 'exit', 7 | getPlatform: 'get-platform', 8 | getVersion: 'get-version' 9 | }; 10 | 11 | export const file = { 12 | openScript: 'open-script', 13 | openDir: 'open-directory', 14 | validateMelvorDir: 'validate-melvor-directory' 15 | }; 16 | 17 | export const mods = { 18 | parseFile: 'parse-file', 19 | parseWeb: 'parse-web', 20 | cloneGit: 'clone-git', 21 | browserInstall: 'browser-install', 22 | add: 'add', 23 | loadAll: 'load-all', 24 | load: 'load', 25 | checkForUpdates: 'check-for-updates', 26 | update: 'update', 27 | remove: 'remove', 28 | inject: 'inject' 29 | }; 30 | 31 | export default { process, file, mods }; 32 | -------------------------------------------------------------------------------- /src/modsHandler.js: -------------------------------------------------------------------------------- 1 | import { pathExists, readFile, readJson, writeFile, writeJson, ensureDir, opendir, emptyDir, copy, remove, readdir, lstat } from 'fs-extra'; 2 | import path from 'path'; 3 | import { app, BrowserWindow } from 'electron'; 4 | 5 | import axios from 'axios'; 6 | import simpleGit from 'simple-git'; 7 | import cheerio from 'cheerio'; 8 | import usp from 'userscript-parser'; 9 | import { download } from 'electron-dl'; 10 | import decompress from 'decompress'; 11 | 12 | import { isGreasyForkUrl, isGitUrl, ppJson } from './util'; 13 | import { mods } from './messageTypes'; 14 | 15 | export default async (_event, message) => { 16 | return await handlers[message.type](message); 17 | }; 18 | 19 | const handlers = { 20 | [mods.parseFile]: async ({ filePath }) => { 21 | try { 22 | const isScriptFile = path.extname(filePath) === '.js'; 23 | const content = await readFile(filePath, 'utf8'); 24 | return isScriptFile ? 25 | await parseScript(path.parse(filePath).base, content) : 26 | await parseManifest(content); 27 | } catch (e) { 28 | console.error(e); 29 | return { error: 'Unable to parse the selected file. Make sure it is a valid userscript or WebExtensions extension manifest.' }; 30 | } 31 | }, 32 | 33 | [mods.parseWeb]: async ({ origin, fromBrowser }) => { 34 | try { 35 | console.log('Parsing web...'); 36 | ppJson({origin, fromBrowser}); 37 | if (!isWebOrigin(origin) || (!fromBrowser && !isGreasyForkUrl(origin))) 38 | return { error: 'Only web scripts from GreasyFork are currently supported.' }; 39 | 40 | const script = await downloadScript(origin); 41 | 42 | if (!script) return { error: 'Unable to retrieve script.' }; 43 | 44 | const manifest = await parseScript(script.file, script.content); 45 | return { manifest: { ...manifest, origin }, content: script.content }; 46 | } catch (e) { 47 | console.error(e); 48 | return { error: 'Unable to parse the selected script.' }; 49 | } 50 | }, 51 | 52 | [mods.cloneGit]: async ({ origin, packageDir }) => { 53 | try { 54 | console.log('Cloning git repository...'); 55 | ppJson({origin, packageDir}); 56 | if (!isGitUrl(origin)) { 57 | ppJson({ origin: origin, isURL: isGitUrl(origin) }); 58 | return { error: 'Only github repositories are currently supported.' }; 59 | } 60 | 61 | const gitScript = await downloadGitRepository(origin, packageDir); 62 | if (gitScript.error) return { error: gitScript.error }; 63 | const manifestContent = await readFile(gitScript.manifestPath, 'utf8'); 64 | const parsedManifest = await parseManifest(manifestContent); 65 | 66 | let res = await handlers[mods.add]({ 67 | packageDir, 68 | origin: gitScript.origin, 69 | manifest: parsedManifest 70 | }); 71 | 72 | ppJson(res); 73 | return res; 74 | } catch (e) { 75 | console.error(e); 76 | return { error: 'Unable to clone repository' }; 77 | } 78 | }, 79 | 80 | [mods.browserInstall]: async ({ packageDir, data }) => { 81 | console.log('Installing from browser...'); 82 | ppJson({packageDir, data}); 83 | let res; 84 | 85 | if (data.type === 'script') { 86 | const { manifest, content } = await handlers[mods.parseWeb]({ origin: data.download, fromBrowser: true }); 87 | const browserManifest = { ...manifest, name: data.title, origin: 'browser', browserId: data.id }; 88 | res = await handlers[mods.add]({ packageDir, origin: data.url, manifest: browserManifest, content: content, fromBrowser: true }); 89 | } else { 90 | const tempPath = path.join(app.getPath('temp'), 'M3'); 91 | await ensureDir(tempPath); 92 | const dl = await download(BrowserWindow.getFocusedWindow(), data.download, { directory: tempPath }); 93 | const extDir = path.join(tempPath, dl.getFilename().split('.')[0]); 94 | await decompress(dl.savePath, extDir); 95 | const manifestPath = await findManifest(extDir); 96 | if (!manifestPath) return; 97 | const manifest = await handlers[mods.parseFile]({ filePath: manifestPath }); 98 | const browserManifest = { ...manifest, name: data.title, origin: 'browser', browserId: data.id }; 99 | res = await handlers[mods.add]({ packageDir, origin: manifestPath, manifest: browserManifest }); 100 | await emptyDir(tempPath); 101 | } 102 | 103 | return res; 104 | }, 105 | 106 | [mods.add]: async ({ packageDir, origin, manifest, content, fromBrowser }) => { 107 | try { 108 | console.log('Adding mod...'); 109 | ppJson({ packageDir, origin, manifest, content }); 110 | if (!fromBrowser && isGitUrl(origin)) { 111 | return manifest; 112 | } 113 | const id = manifest.id ? manifest.id : generateId(manifest.name); // note that git repositories require id to be $owner_$reponame 114 | const duplicateCount = await getDuplicateCount(packageDir, id); 115 | manifest = { 116 | ...manifest, 117 | id: (duplicateCount > 0) ? `${id}_${duplicateCount}` : id, 118 | name: (duplicateCount > 0) ? `${manifest.name} (${duplicateCount})` : manifest.name 119 | }; 120 | 121 | const modPath = getModPath(packageDir, manifest.id); 122 | await ensureDir(modPath); 123 | console.log('Using modPath:', modPath); 124 | 125 | if (content) { 126 | await writeFile(path.join(modPath, manifest.entryScripts[0]), content); 127 | } else { 128 | if (isWebOrigin(origin)) { 129 | await writeFile(path.join(modPath, manifest.entryScripts[0]), await downloadScript(origin)); 130 | } else if (path.extname(origin) === '.js') { 131 | await copy(origin, path.join(modPath, manifest.entryScripts[0])); 132 | } else { 133 | await copy(path.dirname(origin), modPath); 134 | await injectModId(modPath, manifest.id); 135 | } 136 | } 137 | 138 | await writeJson(path.join(modPath, 'manifest.json'), manifest, { spaces: 2 }); 139 | 140 | return manifest; 141 | } catch (e) { 142 | console.error(e); 143 | if (manifest.id) await remove(getModPath(packageDir, manifest.id)); 144 | return { error: 'Unable to add the selected mod.' }; 145 | } 146 | }, 147 | 148 | [mods.loadAll]: async ({ packageDir }) => { 149 | try { 150 | console.log('Loading all mods...'); 151 | ppJson({packageDir}); 152 | const modPath = getModPath(packageDir); 153 | if (!await pathExists(modPath)) return []; 154 | const loadedMods = []; 155 | 156 | const modDir = await opendir(modPath); 157 | for await (const dirent of modDir) { 158 | if (!dirent.isDirectory()) continue; 159 | 160 | const manifestPath = path.join(modPath, dirent.name, 'manifest.json'); 161 | if (!await pathExists(manifestPath)) continue; 162 | try { 163 | const manifest = await readJson(manifestPath); 164 | loadedMods.push(manifest); 165 | } catch (e) { 166 | console.error(e); 167 | continue; 168 | } 169 | } 170 | 171 | return loadedMods; 172 | } catch (e) { 173 | console.error(e); 174 | return []; 175 | } 176 | }, 177 | 178 | [mods.load]: async ({ packageDir, id }) => { 179 | console.log('Loading mod...'); 180 | ppJson({packageDir, id}); 181 | try { 182 | const modPath = getModPath(packageDir, id); 183 | const manifest = await readJson(path.join(modPath, 'manifest.json')); 184 | return manifest; 185 | } catch (e) { 186 | console.error(e); 187 | return { error: `Unable to load mod ${id}.`}; 188 | } 189 | }, 190 | 191 | [mods.checkForUpdates]: async ({ mod }) => { 192 | try { 193 | console.log('Checking for updates...'); 194 | ppJson({mod}); 195 | return await getUpdates(mod); 196 | } catch (e) { 197 | console.error(e); 198 | return null; 199 | } 200 | }, 201 | 202 | [mods.update]: async ({ packageDir, id, browserData }) => { 203 | try { 204 | console.log('Updating mod...'); 205 | ppJson({packageDir, id, browserData}); 206 | const modPath = getModPath(packageDir, id); 207 | const manifestPath = path.join(modPath, 'manifest.json'); 208 | const manifest = await readJson(manifestPath); 209 | 210 | if (!manifest.origin) return; 211 | 212 | if (manifest.origin === 'browser' && browserData.type === 'ext') 213 | return await updateExtension(modPath, manifestPath, manifest, browserData); 214 | 215 | return await updateScript(modPath, manifestPath, manifest, browserData); 216 | } catch (e) { 217 | console.error(e); 218 | return `Unable to update mod ${id}.`; 219 | } 220 | }, 221 | 222 | [mods.remove]: async ({ packageDir, id }) => { 223 | try { 224 | if (!id) return; 225 | console.log('Removing mod...'); 226 | ppJson({packageDir, id}); 227 | const modPath = getModPath(packageDir, id); 228 | 229 | await remove(modPath); 230 | } catch (e) { 231 | console.error(e); 232 | return `Unable to remove mod ${id}.`; 233 | } 234 | }, 235 | 236 | [mods.inject]: async ({ packageDir, mods }) => { 237 | console.log('Injecting mods...'); 238 | ppJson({packageDir, mods}); 239 | const m3jsPath = path.join(packageDir, 'm3.js'); 240 | const m3js = buildM3Js(mods); 241 | await writeFile(m3jsPath, m3js); 242 | 243 | const melvorPackagePath = path.join(packageDir, 'package.json'); 244 | const melvorPackage = await readJson(melvorPackagePath); 245 | melvorPackage['inject_js_end'] = 'm3.js'; 246 | await writeJson(melvorPackagePath, melvorPackage); 247 | } 248 | }; 249 | 250 | const parseScript = async (scriptFile, content) => { 251 | console.log('Parsing script...'); 252 | ppJson({scriptFile}); 253 | const userScript = usp(content); 254 | 255 | if (!userScript) return { 256 | name: scriptFile.split('.')[0], 257 | entryScripts: [scriptFile] 258 | }; 259 | 260 | const { meta } = userScript; 261 | return { 262 | name: (meta.name && meta.name.length) ? meta.name[0] : undefined, 263 | description: (meta.description && meta.description.length) ? meta.description[0] : undefined, 264 | version: (meta.version && meta.version.length) ? meta.version[0] : undefined, 265 | entryScripts: [scriptFile] 266 | }; 267 | }; 268 | 269 | const parseManifest = async (content) => { 270 | console.log('Parsing manifest...'); 271 | ppJson({content}); 272 | const manifest = JSON.parse(content); 273 | 274 | const isWebExtension = !!manifest.content_scripts; 275 | 276 | if (!isWebExtension) throw 'Invalid manifest - not a WebExtension'; 277 | 278 | return { 279 | id: manifest.id ? manifest.id : manifest.name, 280 | name: manifest.name, 281 | description: manifest.description, 282 | version: manifest.version, 283 | entryScripts: manifest.content_scripts[0].js, 284 | styles: manifest.content_scripts[0].css 285 | }; 286 | }; 287 | 288 | const findManifest = async (dir) => { 289 | console.log('Looking for manifest...'); 290 | ppJson({dir}); 291 | const files = await readdir(dir, { withFileTypes: true }); 292 | const manifest = files.find(file => file.isFile() && file.name === 'manifest.json'); 293 | if (manifest) return path.join(dir, manifest.name); 294 | 295 | const folders = files.filter(dirent => dirent.isDirectory()).map(dirent => dirent.name); 296 | for (const folder of folders) { 297 | const manifest = await findManifest(path.join(dir, folder)); 298 | if (manifest) return manifest; 299 | } 300 | 301 | return false; 302 | }; 303 | 304 | const generateId = name => { 305 | return name.replace(/[^a-z ]/gi, '').replace(/ /g, '_').toLowerCase(); 306 | }; 307 | 308 | const getModPath = (packageDir, modId) => { 309 | const baseModPath = path.join(packageDir, 'Mods'); 310 | 311 | if (modId) return path.join(baseModPath, modId); 312 | 313 | return baseModPath; 314 | }; 315 | 316 | const getDuplicateCount = async (packageDir, baseId) => { 317 | let testId = baseId; 318 | let duplicateCount = 0; 319 | 320 | while (await pathExists(getModPath(packageDir, testId))) { 321 | duplicateCount++; 322 | testId = `${baseId}_${duplicateCount}`; 323 | } 324 | 325 | return duplicateCount; 326 | } 327 | 328 | const isWebOrigin = origin => { 329 | try { 330 | const url = new URL(origin); 331 | return (/https?/).test(url.protocol); 332 | } catch { 333 | return false; 334 | } 335 | }; 336 | 337 | const downloadScript = async (url, packageDir) => { 338 | if (isGreasyForkUrl(url)) { 339 | return await downloadGreasyForkScript(url); 340 | } 341 | if (isGitUrl(url)) { 342 | return await downloadGitRepository(url, packageDir); 343 | } 344 | 345 | console.log('Downloading file...'); 346 | const scriptPath = url.split('/'); 347 | const scriptFile = decodeURI(scriptPath[scriptPath.length - 1]); 348 | const { data } = await axios.get(url); 349 | return { file: scriptFile, content: data }; 350 | }; 351 | 352 | const downloadGreasyForkScript = async url => { 353 | try { 354 | console.log('Downloading Greasyfork script...', JSON.stringify({ url }, null, 2)); 355 | const scriptHomepage = await axios.get(url); 356 | const $ = cheerio.load(scriptHomepage.data); 357 | const scriptUrl = $('#install-area .install-link').attr('href'); 358 | const scriptPath = scriptUrl.split('/'); 359 | const scriptFile = decodeURI(scriptPath[scriptPath.length - 1]); 360 | const { data } = await axios.get(`https://greasyfork.org${scriptUrl}`); 361 | return { file: scriptFile, content: data }; 362 | } catch (e) { 363 | console.error(e); 364 | return; 365 | } 366 | }; 367 | const downloadGitRepository = async (url, packageDir, modPath) => { 368 | try { 369 | console.log('Downloading git script...'); 370 | ppJson({url, packageDir, modPath}); 371 | 372 | const git = simpleGit().env('GIT_TERMINAL_PROMPT', '0'); 373 | let scriptPath; 374 | if (url.startsWith('git@')) { 375 | scriptPath = url.split(':')[1]; 376 | scriptPath = scriptPath.split('/'); 377 | } else { 378 | scriptPath = url.split('/'); 379 | } 380 | const repoOwner = scriptPath[scriptPath.length-2]; 381 | const repoName = scriptPath[scriptPath.length-1].replace(/^.git$/, ''); 382 | modPath = modPath ? modPath : getModPath(packageDir, repoOwner + '_' + repoName); 383 | ppJson({url, packageDir, modPath, repoOwner, repoName}); 384 | 385 | 386 | if ((modPath === undefined) || (repoName === undefined)) 387 | console.log('Invalid url'); 388 | 389 | try { 390 | await git.clone(url, modPath); 391 | } catch (e) { 392 | return { error: 'Couldn\'t clone the repository. Are you sure it exists?' }; 393 | } 394 | return { 395 | name: repoName, 396 | author: repoOwner, 397 | origin: url, 398 | manifestPath: path.join(modPath, 'manifest.json') 399 | } 400 | } catch (e) { 401 | console.error(e); 402 | return; 403 | } 404 | }; 405 | 406 | const getUpdates = async (mod) => { 407 | if (!mod.origin) return null; 408 | 409 | const isGreasyForkOrigin = isGreasyForkUrl(mod.origin); 410 | 411 | // We only support Greasy Fork updates for now 412 | if (!isGreasyForkOrigin) return null; 413 | 414 | try { 415 | if (isGreasyForkOrigin) return await getGreasyForkUpdates(mod); 416 | } catch (e) { 417 | console.error(e); 418 | return null; 419 | } 420 | 421 | return null; 422 | }; 423 | 424 | const getGreasyForkUpdates = async mod => { 425 | const { data } = await axios.get(mod.origin); 426 | const $ = cheerio.load(data); 427 | const originVersion = $('#install-area .install-link').attr('data-script-version'); 428 | if (!originVersion) return null; 429 | 430 | const currentVersion = mod.version.split('.'); 431 | const latestVersion = originVersion.split('.'); 432 | 433 | let isNewVersion = false; 434 | 435 | for (let i = 0; i < latestVersion.length; i++) { 436 | if (currentVersion.length === i || latestVersion[i] > currentVersion[i]) { 437 | isNewVersion = true; 438 | break; 439 | } 440 | } 441 | 442 | if (isNewVersion) { 443 | return originVersion; 444 | } 445 | } 446 | 447 | const updateScript = async (modPath, manifestPath, manifest, browserData) => { 448 | const url = manifest.origin === 'browser' ? browserData.download : manifest.origin; 449 | const { content: updatedScript } = await downloadScript(url); 450 | 451 | if (!updatedScript) return; 452 | 453 | const { meta, content } = usp(updatedScript); 454 | const updatedManifest = { 455 | ...manifest, 456 | name: (meta.name && meta.name.length) ? meta.name[0] : manifest.name, 457 | description: (meta.description && meta.description.length) ? meta.description[0] : manifest.description, 458 | version: (meta.version && meta.version.length) ? meta.version[0] : manifest.version, 459 | }; 460 | 461 | const scriptPath = path.join(modPath, updatedManifest.entryScripts[0]); 462 | await writeFile(scriptPath, content); 463 | await writeJson(manifestPath, updatedManifest, { spaces: 2 }); 464 | 465 | return { ...updatedManifest, version: updatedManifest.version, updateAvailable: null }; 466 | }; 467 | 468 | const updateExtension = async (modPath, manifestPath, manifest, browserData) => { 469 | const tempPath = path.join(app.getPath('temp'), 'M3'); 470 | await ensureDir(tempPath); 471 | const dl = await download(BrowserWindow.getFocusedWindow(), browserData.download, { directory: tempPath }); 472 | const extDir = path.join(tempPath, dl.getFilename().split('.')[0]); 473 | await decompress(dl.savePath, extDir); 474 | const tempManifestPath = await findManifest(extDir); 475 | if (!tempManifestPath) return; 476 | const tempManifest = await handlers[mods.parseFile]({ filePath: tempManifestPath }); 477 | const updatedManifest = { 478 | ...manifest, 479 | name: browserData.title, 480 | description: tempManifest.description, 481 | version: browserData.version, 482 | origin: 'browser', 483 | browserId: browserData.id 484 | }; 485 | await emptyDir(modPath); 486 | await copy(path.dirname(tempManifestPath), modPath); 487 | await injectModId(modPath, updatedManifest.id); 488 | await writeJson(manifestPath, updatedManifest, { spaces: 2 }); 489 | await emptyDir(tempPath); 490 | 491 | return { ...updatedManifest, version: updatedManifest.version, updateAvailable: null }; 492 | } 493 | 494 | const injectModId = async (dir, modId) => { 495 | if (!await pathExists(dir)) return; 496 | 497 | const files = await readdir(dir); 498 | for (const file of files) { 499 | const filePath = path.join(dir, file); 500 | const stat = await lstat(filePath); 501 | if (stat.isDirectory()) { 502 | await injectModId(filePath, modId); 503 | continue; 504 | } 505 | if (!(/\.js$/).test(filePath)) continue; 506 | 507 | const contents = await readFile(filePath, 'utf8'); 508 | if (!(/getURL\(([^\)]*)/).test(contents)) continue; 509 | const newContents = contents.replace(/getURL\(([^\)]*)/g, 'getURL($1, "Mods/' + modId + '/"'); 510 | await writeFile(filePath, newContents); 511 | } 512 | } 513 | 514 | const buildM3Js = (mods) => { 515 | mods = []; 516 | const modInjectables = []; 517 | for (const mod of mods) { 518 | if (mod.disabled) continue; 519 | if (!!mod.content_scripts) { 520 | modInjectables.push(`{ id: '${mod.id}', scripts: ${JSON.stringify(mod.content_scripts[0].js || [])}, styles: ${JSON.stringify(mod.content_scripts[0].css || [])} }`); 521 | } else { 522 | modInjectables.push(`{ id: '${mod.id}', scripts: ${JSON.stringify(mod.entryScripts || [])}, styles: ${JSON.stringify(mod.styles || [])} }`); 523 | } 524 | } 525 | return ` 526 | window.addEventListener('load', () => { 527 | if (!document.getElementById('m-page-loader')) return; 528 | 529 | SwalLocale.fire('Uninstall Melvor Mod Manager (M3) and use the in-game Mod Manager instead!'); 530 | return; 531 | 532 | const fs = require('fs'); 533 | const path = require('path'); 534 | 535 | const runtimeGetURL = chrome.runtime.getURL; 536 | chrome.runtime.getURL = (path, modId) => { 537 | if (modId) path = modId + path; 538 | return runtimeGetURL(path); 539 | }; 540 | 541 | const mods = [ 542 | ${modInjectables.join(',\n ')} 543 | ]; 544 | 545 | for (const mod of mods) { 546 | for (const script of mod.scripts) { 547 | const filePath = path.join('Mods', mod.id, script); 548 | try { 549 | const content = fs.readFileSync(filePath, 'utf8'); 550 | const scriptEl = document.createElement('script'); 551 | scriptEl.innerHTML = 552 | \`(() => { 553 | \${content} 554 | })();\`; 555 | document.body.appendChild(scriptEl); 556 | } catch {} 557 | } 558 | for (const style of mod.styles) { 559 | const filePath = path.join('Mods', mod.id, style); 560 | const linkEl = document.createElement('link'); 561 | linkEl.rel = 'stylesheet'; 562 | linkEl.href = chrome.runtime.getURL(filePath); 563 | document.head.appendChild(linkEl); 564 | } 565 | } 566 | });` 567 | }; 568 | -------------------------------------------------------------------------------- /src/plugins/vuetify.js: -------------------------------------------------------------------------------- 1 | import 'material-design-icons-iconfont/dist/material-design-icons.css'; 2 | import Vue from 'vue'; 3 | import Vuetify from 'vuetify/lib/framework'; 4 | import colors from 'vuetify/lib/util/colors'; 5 | 6 | Vue.use(Vuetify); 7 | 8 | export default new Vuetify({ 9 | icons: { 10 | iconfont: 'md' 11 | }, 12 | theme: { 13 | dark: true, 14 | themes: { 15 | dark: { 16 | primary: colors.teal.base 17 | } 18 | } 19 | } 20 | }); 21 | -------------------------------------------------------------------------------- /src/preload.js: -------------------------------------------------------------------------------- 1 | import { process, file, mods } from './messageTypes'; 2 | import { contextBridge, ipcRenderer } from 'electron'; 3 | 4 | contextBridge.exposeInMainWorld('process', { 5 | launchMelvor: async (melvorDir, launchMode) => await ipcRenderer.invoke('process', { type: process.launchMelvor, melvorDir, launchMode }), 6 | openLink: async (url) => await ipcRenderer.invoke('process', { type: process.openLink, url }), 7 | minimize: () => ipcRenderer.invoke('process', { type: process.minimize }), 8 | maximize: () => ipcRenderer.invoke('process', { type: process.maximize }), 9 | exit: () => ipcRenderer.invoke('process', { type: process.exit }), 10 | getPlatform: () => ipcRenderer.invoke('process', { type: process.getPlatform }), 11 | getVersion: () => ipcRenderer.invoke('process', { type: process.getVersion }) 12 | }); 13 | 14 | contextBridge.exposeInMainWorld('file', { 15 | // Returns generated manifest (for validation/renaming) and path 16 | openScript: async () => await ipcRenderer.invoke('file', { type: file.openScript }), 17 | // Returns path to opened directory 18 | openDir: async () => await ipcRenderer.invoke('file', { type: file.openDir }), 19 | // Returns true/false based on if Melvor is detected at path 20 | validateMelvorDir: async (dir) => await ipcRenderer.invoke('file', { type: file.validateMelvorDir, dir }) 21 | }); 22 | 23 | contextBridge.exposeInMainWorld('mods', { 24 | // Returns mod manifest 25 | parseFile: async (filePath) => await ipcRenderer.invoke('mods', { type: mods.parseFile, filePath }), 26 | // Returns mod manifest 27 | parseWeb: async (url) => await ipcRenderer.invoke('mods', { type: mods.parseWeb, origin: url }), 28 | // Returns mod manifest 29 | cloneGit: async (url, packageDir) => await ipcRenderer.invoke('mods', { type: mods.cloneGit, origin: url, packageDir }), 30 | // Returns mod manifest 31 | browserInstall: async (packageDir, data) => await ipcRenderer.invoke('mods', { type: mods.browserInstall, packageDir, data }), 32 | // Returns error 33 | add: async (packageDir, origin, manifest, content) => await ipcRenderer.invoke('mods', { type: mods.add, packageDir, origin, manifest, content }), 34 | // Returns array of mod manifests 35 | loadAll: async (packageDir) => await ipcRenderer.invoke('mods', { type: mods.loadAll, packageDir }), 36 | // Returns mod manifest 37 | load: async (packageDir, id) => await ipcRenderer.invoke('mods', { type: mods.load, packageDir, id }), 38 | // Returns latest mod version or null if unable to fetch 39 | checkForUpdates: async (mod) => await ipcRenderer.invoke('mods', { type: mods.checkForUpdates, mod }), 40 | // Returns error 41 | update: async (packageDir, id, browserData) => await ipcRenderer.invoke('mods', { type: mods.update, packageDir, id, browserData }), 42 | // Returns error 43 | remove: async (packageDir, id) => await ipcRenderer.invoke('mods', { type: mods.remove, packageDir, id }), 44 | // Returns error 45 | inject: async (packageDir, modsToInject) => await ipcRenderer.invoke('mods', { type: mods.inject, packageDir, mods: modsToInject }) 46 | }); 47 | -------------------------------------------------------------------------------- /src/processHandler.js: -------------------------------------------------------------------------------- 1 | import cp from 'child_process'; 2 | import path from 'path'; 3 | import { BrowserWindow, app } from 'electron'; 4 | 5 | import open from 'open'; 6 | import { getExecutableFilename } from './util' 7 | import messageTypes from './messageTypes'; 8 | 9 | export default async (_event, message) => { 10 | return await handlers[message.type](message); 11 | }; 12 | 13 | const handlers = { 14 | [messageTypes.process.launchMelvor]: async ({ melvorDir, launchMode }) => { 15 | if (launchMode === 'exe') { 16 | const exePath = path.join(melvorDir, getExecutableFilename(process.platform)); 17 | 18 | const subprocess = cp.spawn(exePath, { 19 | detached: true, 20 | stdio: 'ignore' 21 | }); 22 | 23 | subprocess.unref(); 24 | return; 25 | } 26 | 27 | const appId = 1267910; 28 | await open(`steam://run/${appId}`, { wait: true }); 29 | }, 30 | 31 | [messageTypes.process.openLink]: ({ url }) => { 32 | open(url); 33 | }, 34 | 35 | [messageTypes.process.minimize]: () => { 36 | BrowserWindow.getFocusedWindow().minimize(); 37 | }, 38 | 39 | [messageTypes.process.maximize]: () => { 40 | const win = BrowserWindow.getFocusedWindow(); 41 | if (win.isMaximized()) win.unmaximize(); 42 | else win.maximize(); 43 | }, 44 | 45 | [messageTypes.process.exit]: process.exit, 46 | 47 | [messageTypes.process.getPlatform]: () => process.platform, 48 | 49 | [messageTypes.process.getVersion]: () => app.getVersion() 50 | }; -------------------------------------------------------------------------------- /src/router/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueRouter from 'vue-router'; 3 | import Mods from '../views/Mods.vue'; 4 | import Browse from '../views/Browse.vue'; 5 | import Settings from '../views/Settings.vue'; 6 | 7 | Vue.use(VueRouter); 8 | 9 | const routes = [ 10 | { 11 | path: '/', 12 | name: 'Mods', 13 | component: Mods 14 | }, 15 | { 16 | path: '/browse', 17 | name: 'Browse', 18 | component: Browse 19 | }, 20 | { 21 | path: '/settings', 22 | name: 'Settings', 23 | component: Settings 24 | } 25 | ]; 26 | 27 | const router = new VueRouter({ 28 | routes 29 | }); 30 | 31 | export default router; 32 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuex from 'vuex' 3 | import axios from 'axios'; 4 | 5 | import api from '@/api'; 6 | import { sortModsByLoadOrder } from '@/util'; 7 | 8 | Vue.use(Vuex) 9 | 10 | export default new Vuex.Store({ 11 | state: { 12 | dir: '', 13 | packageDir: '', 14 | isValidDir: false, 15 | checkForUpdates: true, 16 | launchMode: 'steam', 17 | closeOnLaunch: true, 18 | gameVersion: '0.21', 19 | browserMods: [], 20 | mods: [], 21 | modLoadOrder: [], 22 | disabledMods: [], 23 | selectedMod: null, 24 | modBeingUpdated: null, 25 | isLoadingMods: false 26 | }, 27 | getters: { 28 | selectedMod ({ mods, selectedMod }) { 29 | if (!selectedMod) return null; 30 | return mods.find(mod => mod.id === selectedMod); 31 | }, 32 | selectedModIndex ({ mods, selectedMod }) { 33 | return mods.map(mod => mod.id).indexOf(selectedMod); 34 | }, 35 | installedBrowserMod ({ mods }) { 36 | return (browserId) => mods.find(mod => mod.origin === 'browser' && mod.browserId === browserId); 37 | } 38 | }, 39 | mutations: { 40 | setDir (state, dir) { 41 | state.dir = dir; 42 | }, 43 | setPackageDir (state, packageDir) { 44 | state.packageDir = packageDir; 45 | }, 46 | setDirValidity (state, isValid) { 47 | state.isValidDir = isValid; 48 | }, 49 | setGameVersion (state, version) { 50 | state.gameVersion = version; 51 | }, 52 | setBrowserMods (state, mods) { 53 | state.browserMods = mods; 54 | }, 55 | setModLoadOrder (state, loadOrder) { 56 | state.modLoadOrder = loadOrder; 57 | }, 58 | setDisabledMods (state, mods) { 59 | state.disabledMods = mods; 60 | }, 61 | setMods (state, mods) { 62 | state.mods = mods; 63 | }, 64 | setMod (state, mod) { 65 | state.mods = state.mods.map(m => m.id === mod.id ? mod : m); 66 | }, 67 | selectMod (state, id) { 68 | state.selectedMod = id; 69 | }, 70 | beginUpdateMod (state, id) { 71 | state.modBeingUpdated = id; 72 | }, 73 | endUpdateMod (state) { 74 | state.modBeingUpdated = null; 75 | }, 76 | setLoadModsState (state, isLoading) { 77 | state.isLoadingMods = isLoading; 78 | }, 79 | setSetting (state, { key, value }) { 80 | state[key] = value; 81 | } 82 | }, 83 | actions: { 84 | async loadSavedState ({ commit, dispatch }) { 85 | const checkForUpdates = localStorage.getItem('checkForUpdates'); 86 | if (checkForUpdates !== null) commit('setSetting', { key: 'checkForUpdates', value: checkForUpdates === "true" }); 87 | 88 | const launchMode = localStorage.getItem('launchMode'); 89 | if (launchMode) commit('setSetting', { key: 'launchMode', value: launchMode }); 90 | 91 | const closeOnLaunch = localStorage.getItem('closeOnLaunch'); 92 | if (closeOnLaunch !== null) commit('setSetting', { key: 'closeOnLaunch', value: closeOnLaunch === "true" }); 93 | 94 | const disabledMods = localStorage.getItem('disabledMods'); 95 | if (disabledMods) commit('setDisabledMods', JSON.parse(disabledMods)); 96 | 97 | const loadOrder = localStorage.getItem('modLoadOrder'); 98 | if (loadOrder) dispatch('loadModLoadOrder', JSON.parse(loadOrder)); 99 | 100 | await dispatch('loadGameMetadata'); 101 | await dispatch('loadBrowser'); 102 | 103 | const dir = localStorage.getItem('melvorDir'); 104 | if (dir) await dispatch('setDir', dir); 105 | }, 106 | async setDir ({ commit, dispatch }, dir) { 107 | const isValidDir = await api.file.validateMelvorDir(dir); 108 | if (!isValidDir) dir = 'Invalid Directory'; 109 | commit('setDir', dir); 110 | let packageDir = dir; 111 | const platform = await api.process.getPlatform(); 112 | if (platform === 'darwin') { 113 | packageDir = `${dir}/Melvor Idle.app/Contents/Resources/app.nw`; 114 | } 115 | commit('setDirValidity', isValidDir); 116 | commit('setPackageDir', packageDir); 117 | if (isValidDir) { 118 | localStorage.setItem('packageDir', packageDir); 119 | localStorage.setItem('melvorDir', dir); 120 | await dispatch('loadMods'); 121 | } else { 122 | await dispatch('setMods', []); 123 | } 124 | }, 125 | async loadGameMetadata({ commit }) { 126 | const res = await axios.get('https://cherrymace.github.io/m3-mod-browser/game.json'); 127 | commit('setGameVersion', res.data.version); 128 | }, 129 | async loadBrowser ({ commit }) { 130 | // const res = await axios.get('https://cherrymace.github.io/m3-mod-browser/mods/all.json', { 131 | // params: { t: new Date().getTime() } 132 | // }); 133 | // commit('setBrowserMods', res.data); 134 | commit('setBrowserMods', []); 135 | }, 136 | loadModLoadOrder ({ commit }, modLoadOrder) { 137 | commit('setModLoadOrder', modLoadOrder); 138 | }, 139 | saveModLoadOrder({ state, commit }) { 140 | const loadOrder = state.mods.map(mod => mod.id); 141 | commit('setModLoadOrder', loadOrder); 142 | localStorage.setItem('modLoadOrder', JSON.stringify(loadOrder)); 143 | }, 144 | async setMods ({ state, commit, dispatch }, mods) { 145 | commit('setMods', mods); 146 | if (!mods.length) return; 147 | dispatch('saveModLoadOrder'); 148 | await api.mods.inject(state.packageDir, mods); 149 | }, 150 | async loadMods ({ state, commit, dispatch }) { 151 | commit('setLoadModsState', true); 152 | const mods = await api.mods.loadAll(state.packageDir); 153 | commit('setLoadModsState', false); 154 | const modsWithDisabledFlag = mods.map(mod => ({ ...mod, disabled: state.disabledMods.includes(mod.id) })); 155 | const modsOrderedByLoadOrder = sortModsByLoadOrder(modsWithDisabledFlag, state.modLoadOrder); 156 | await dispatch('setMods', modsOrderedByLoadOrder); 157 | 158 | if (state.checkForUpdates) { 159 | for (const mod of modsOrderedByLoadOrder) { 160 | await dispatch('checkForUpdates', mod); 161 | } 162 | } 163 | }, 164 | async loadMod ({ state, commit, dispatch }, id) { 165 | commit('setLoadModsState', true); 166 | const modToLoad = await api.mods.load(state.packageDir, id); 167 | commit('setLoadModsState', false); 168 | if (!modToLoad) return; 169 | 170 | modToLoad.disabled = state.disabledMods.includes(id); 171 | 172 | let mods = []; 173 | const alreadyLoaded = state.mods.find(mod => mod.id === modToLoad.id); 174 | if (alreadyLoaded) { 175 | mods = state.mods.map(mod => mod.id === modToLoad.id ? modToLoad : mod); 176 | } else { 177 | mods = [ ...state.mods, modToLoad ]; 178 | mods = sortModsByLoadOrder(mods, state.modLoadOrder); 179 | } 180 | await dispatch('setMods', mods); 181 | if (state.checkForUpdates) { 182 | await dispatch('checkForUpdates', modToLoad); 183 | } 184 | }, 185 | async setModDisabledState ({ state, dispatch }, { id, disabled }) { 186 | const mods = state.mods.map(mod => mod.id === id ? { ...mod, disabled } : mod); 187 | await dispatch('setMods', mods); 188 | dispatch('saveDisabledMods'); 189 | }, 190 | saveDisabledMods ({ state, commit }) { 191 | const disabledMods = state.mods.filter(mod => mod.disabled).map(mod => mod.id); 192 | commit('setDisabledMods', disabledMods); 193 | localStorage.setItem('disabledMods', JSON.stringify(disabledMods)); 194 | }, 195 | async addMod({ dispatch }, filePath) { 196 | const { id } = await api.mods.add(filePath); 197 | if (!id) return; 198 | await dispatch('loadMod', id); 199 | }, 200 | selectMod ({ commit }, id) { 201 | commit('selectMod', id); 202 | }, 203 | async checkForUpdates ({ state, commit }, mod) { 204 | let updateAvailable = null; 205 | 206 | if (mod.origin === 'browser') { 207 | const browserMod = state.browserMods.find(m => m.id === mod.browserId); 208 | if (!browserMod) return; // Mod has been removed from Discover 209 | if (browserMod.version !== mod.version) 210 | updateAvailable = browserMod.version; 211 | } 212 | else updateAvailable = await api.mods.checkForUpdates(mod); 213 | if (updateAvailable) commit('setMod', { ...mod, updateAvailable }); 214 | }, 215 | async updateAllMods ({ state, dispatch }) { 216 | for (const mod of state.mods) { 217 | if (mod.updateAvailable) await dispatch('updateMod', mod.id); 218 | } 219 | }, 220 | async updateMod ({ state, dispatch, commit }, id) { 221 | commit('beginUpdateMod', id); 222 | const mod = state.mods.find(m => m.id === id); 223 | const browserData = mod.origin === 'browser' ? state.browserMods.find(m => m.id === mod.browserId) : null; 224 | await api.mods.update(state.packageDir, id, browserData); 225 | await dispatch('loadMod', id); 226 | commit('endUpdateMod'); 227 | }, 228 | async removeMod ({ state, dispatch }, id) { 229 | await api.mods.remove(state.packageDir, id); 230 | const mods = state.mods.filter(mod => mod.id !== id); 231 | await dispatch('setMods', mods); 232 | }, 233 | async moveModLoadOrder ({ state, dispatch }, { id, moveUp }) { 234 | const index = state.mods.map(mod => mod.id).indexOf(id); 235 | if (index === -1) return; 236 | const moveToIndex = moveUp ? (index - 1) : (index + 1); 237 | if (moveToIndex < 0 || moveToIndex > state.mods.length - 1) return; 238 | 239 | const mods = [ ...state.mods ]; 240 | const modToMove = mods[index]; 241 | mods[index] = mods[moveToIndex]; 242 | mods[moveToIndex] = modToMove; 243 | await dispatch('setMods', mods); 244 | }, 245 | changeSetting ({ commit }, { key, value }) { 246 | localStorage.setItem(key, value); 247 | commit('setSetting', { key, value }); 248 | } 249 | } 250 | }) 251 | -------------------------------------------------------------------------------- /src/util.js: -------------------------------------------------------------------------------- 1 | export const sortModsByLoadOrder = (mods, modLoadOrder) => { 2 | const modsSortedByLoadOrder = [ ...mods ]; 3 | modsSortedByLoadOrder.sort((modA, modB) => { 4 | const loadOrderA = modLoadOrder.indexOf(modA.id); 5 | const loadOrderB = modLoadOrder.indexOf(modB.id); 6 | 7 | if (loadOrderA === -1 && loadOrderB === -1) return 0; 8 | if (loadOrderA === -1) return 1; 9 | if (loadOrderB === -1) return -1; 10 | 11 | if (loadOrderA < loadOrderB) return -1; 12 | if (loadOrderB < loadOrderA) return 1; 13 | return 0; 14 | }); 15 | return modsSortedByLoadOrder; 16 | }; 17 | 18 | export const isGreasyForkUrl = url => { 19 | return (/^https?:\/\/(www\.)?greasyfork\.org/).test(url); 20 | }; 21 | 22 | export const isGitUrl = url => { 23 | // should add support for all git urls, not only github; but i'm not 100% sure about the proper url syntax 24 | // ssh urls should be easier than http ones though 25 | const httpsGit = /^(https:\/\/)?(www\.)?github.com?\/([^/]*)\/([^/]*)(.git)?$/; 26 | const sshGit = /^git@github.com:([^/]*)\/([^/]*)(.git)?$/; 27 | return httpsGit.test(url) || sshGit.test(url); 28 | }; 29 | 30 | export const getExecutableFilename = (platform) => ({ 31 | win32: 'Melvor Idle.exe', 32 | darwin: 'Melvor Idle.app', 33 | linux: 'Melvor Idle', 34 | }[platform]); 35 | 36 | export const ppJson = obj => { 37 | if (obj !== null) { 38 | console.dir(obj, { depth: null, colors: true }); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/views/Browse.vue: -------------------------------------------------------------------------------- 1 | 48 | 49 | -------------------------------------------------------------------------------- /src/views/Mods.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | -------------------------------------------------------------------------------- /src/views/Settings.vue: -------------------------------------------------------------------------------- 1 | 55 | 56 | 91 | 92 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | pluginOptions: { 3 | electronBuilder: { 4 | preload: "src/preload.js", 5 | builderOptions: { 6 | appId: "com.cherrymace.m3", 7 | productName: "Melvor Mod Manager", 8 | linux: { 9 | category: "Game" 10 | }, 11 | copyright: "Copyright © 2021 ${author}", 12 | icon: "build/m3-icon.png", 13 | publish: ['github'] 14 | } 15 | }, 16 | }, 17 | transpileDependencies: [ 18 | 'vuetify' 19 | ] 20 | }; 21 | --------------------------------------------------------------------------------