├── .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 | 
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 | 
43 | 
44 | 
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 |
13 | We're sorry but <%= htmlWebpackPlugin.options.title %> doesn't work properly without JavaScript enabled. Please enable it to continue.
14 |
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 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | Official Mod Support is out! As a result, M3 is no longer supported and will no longer load mods to avoid breaking your game.
10 | Thank you to everyone who used and enjoyed M3! Now go use my in-game Mod Manager instead :)
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
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 |
2 |
3 | Add From File
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | Browse
12 |
13 |
14 | {{ error }}
15 | Accepted types are script (.js) files and WebExtension extension manifests (manifest.json).
16 |
17 |
18 |
19 |
20 | Cancel
21 |
22 |
23 |
24 |
25 |
92 |
93 |
--------------------------------------------------------------------------------
/src/components/AddFromUrl.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Add From URL
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 | {{ error }}
12 | GreasyFork URLs are accepted. Example:
https://greasyfork.org/en/scripts/script-id-and-name
13 | Github repositories are also accepted. Example:
https://github.com/user/repository.git
14 |
15 |
16 |
17 |
18 | Cancel
19 | Add
20 |
21 |
22 |
23 |
24 |
104 |
--------------------------------------------------------------------------------
/src/components/BrowserMod.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 | {{ mod.title }}
10 | by {{ mod.authors.join(', ') }}
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 | {{ mod.title }}
21 | by {{ mod.authors.join(', ') }}
22 |
23 |
24 |
25 |
26 | {{ featured ? 'More Details' : 'Details' }}
27 |
28 | Update
29 | arrow_upward
30 |
31 |
32 | {{ installedMod ? 'Installed' : 'Install' }}
33 | {{ installedMod ? 'done' : 'add' }}
34 |
35 |
36 |
37 |
38 |
39 |
--------------------------------------------------------------------------------
/src/components/MainHeader.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
--------------------------------------------------------------------------------
/src/components/MelvorLocator.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | {{ display[result].icon }}
7 |
8 |
9 |
10 |
11 | Browse
12 | search
13 |
14 |
15 | Launch
16 | play_arrow
17 |
18 |
19 |
20 |
21 |
68 |
69 |
--------------------------------------------------------------------------------
/src/components/Mod.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
{{ loadOrder }}
6 |
7 |
8 |
{{ mod.name }}
9 | {{ mod.description }}
10 |
11 |
12 |
13 |
14 |
15 |
16 |
v{{ mod.version }}
17 |
18 |
19 |
20 | arrow_circle_up
21 | v{{ mod.updateAvailable }}
22 |
23 |
24 | Update available
25 |
26 |
Source: {{ source }}
27 |
28 |
29 |
30 |
31 |
32 |
46 |
47 |
--------------------------------------------------------------------------------
/src/components/ModList.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
13 |
14 |
15 |
16 |
17 |
18 |
36 |
37 |
--------------------------------------------------------------------------------
/src/components/ModListHeader.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
15 | Add
16 | add
17 |
18 |
19 |
20 |
21 |
27 |
28 |
29 |
30 | insert_drive_file
31 | From File
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
45 |
46 |
47 |
48 | language
49 | From URL
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
64 |
65 |
66 |
67 |
Update All
68 |
Update
69 |
{{ toggleDisabledText }}
70 |
Remove
71 |
72 |
Load Order
73 |
keyboard_arrow_up
74 |
keyboard_arrow_down
75 |
76 |
77 |
78 |
79 |
167 |
--------------------------------------------------------------------------------
/src/components/PromptMoreInfo.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 | Add Mod
4 |
5 |
6 | Provide a name for this mod:
7 |
8 |
9 |
10 |
11 |
12 | {{ error }}
13 |
14 |
15 |
16 |
17 | Cancel
18 | Add
19 |
20 |
21 |
22 |
23 |
72 |
--------------------------------------------------------------------------------
/src/components/Sidebar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 | {{ item.icon }}
14 |
15 |
16 |
17 |
18 | {{ item.title }}
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | v {{ version }}
27 |
28 |
29 |
30 |
67 |
68 |
--------------------------------------------------------------------------------
/src/components/SystemBar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | minimize
7 |
8 |
9 | web_asset
10 |
11 |
12 | close
13 |
14 |
15 |
16 |
17 |
34 |
35 |
--------------------------------------------------------------------------------
/src/components/Toolbar.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
--------------------------------------------------------------------------------
/src/components/VersionChip.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
9 | v{{ version }}
10 |
11 |
12 | This mod has not been updated for the latest version of Melvor Idle and may not work.
13 |
14 |
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 |
2 |
3 |
7 |
10 |
11 |
12 |
13 | Featured
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 | All Mods
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
--------------------------------------------------------------------------------
/src/views/Mods.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
11 |
12 | arrow_upward Read the message above - go use the in-game Mod Manager instead!
13 |
14 |
15 |
16 | No mods loaded. Install some from the Discover tab or add them manually above.
17 |
18 |
19 |
20 |
21 |
--------------------------------------------------------------------------------
/src/views/Settings.vue:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | General
5 |
6 |
7 |
8 |
9 |
10 |
11 | Automatically check for updates to mods
12 |
13 |
14 | help_outline
15 |
16 | Only works for supported sources:
17 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 | Melvor Idle launch behavior
28 |
29 |
30 | help_outline
31 |
32 | Using Steam: Launch Melvor Idle as if it's being launched through Steam. This enables achievements. (Recommended)
33 | Using Melvor Idle.exe: Launches Melvor directly through the executable. Primarily useful if you have modified Steam launch options for Melvor Idle to launch M3 instead.
34 |
35 |
36 |
37 |
38 |
39 | Launch using Steam
40 |
41 |
42 |
43 |
44 | Launch using Melvor Idle.exe
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
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 |
--------------------------------------------------------------------------------