├── .eslintignore ├── .eslintrc.js ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── SECURITY.md ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── pre-merge.yml │ ├── rebase.yml │ ├── release.yml │ └── version.yml ├── .gitignore ├── .mocharc.json ├── .nycrc ├── .prettierignore ├── .prettierrc.json ├── .stylelintrc.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── babel.config.js ├── build └── installerHeaderIcon.ico ├── dev-app-update.yml.example ├── lint-staged.config.js ├── package-lock.json ├── package.json ├── public ├── images │ ├── default-background.png │ └── logos │ │ ├── discord.svg │ │ ├── faq.svg │ │ ├── new-player-guide.svg │ │ ├── patreon.svg │ │ ├── reddit.svg │ │ ├── twitch.svg │ │ ├── website.svg │ │ ├── wildlander-full-light.svg │ │ ├── wildlander-icon-dark.png │ │ ├── wildlander-icon-light.png │ │ └── youtube.svg └── index.html ├── src ├── __tests__ │ └── unit │ │ ├── controllers │ │ └── graphics.controller.test.ts │ │ ├── services │ │ ├── blacklist.service.test.ts │ │ ├── config.service.test.ts │ │ ├── game.service.test.ts │ │ ├── graphics.service.test.ts │ │ ├── migration.service.test.ts │ │ ├── modOrganizer.service.test.ts │ │ ├── profile.service.test.ts │ │ ├── startup.service.test.ts │ │ ├── system.service.test.ts │ │ └── wabbajack.service.test.ts │ │ └── setup │ │ └── global-setup.ts ├── additional-instructions.json ├── assets │ ├── fonts │ │ ├── averta-black.ttf │ │ ├── averta-blackitalic.ttf │ │ ├── averta-bold.ttf │ │ ├── averta-bolditalic.ttf │ │ ├── averta-extrabold.ttf │ │ ├── averta-extrabolditalic.ttf │ │ ├── averta-extrathin.ttf │ │ ├── averta-extrathinitalic.ttf │ │ ├── averta-light.ttf │ │ ├── averta-lightitalic.ttf │ │ ├── averta-regular.ttf │ │ ├── averta-regularitalic.ttf │ │ ├── averta-semibold.ttf │ │ ├── averta-semibolditalic.ttf │ │ ├── averta-thin.ttf │ │ └── averta-thinitalic.ttf │ ├── icons │ │ └── MaterialIcons-Regular.woff2 │ ├── scss │ │ ├── index.scss │ │ ├── layout.scss │ │ ├── popper.scss │ │ ├── settings │ │ │ ├── colours.scss │ │ │ ├── font-face.scss │ │ │ ├── font.scss │ │ │ └── sizes.scss │ │ └── utility.scss │ └── tools │ │ └── QRes.exe ├── main.ts ├── main │ ├── application.ts │ ├── controllers │ │ ├── config │ │ │ ├── config.controller.ts │ │ │ └── config.events.ts │ │ ├── dialog │ │ │ ├── dialog.controller.ts │ │ │ └── dialog.events.ts │ │ ├── enb │ │ │ ├── enb.controller.ts │ │ │ └── enb.events.ts │ │ ├── graphics │ │ │ ├── graphics.controller.ts │ │ │ └── graphics.events.ts │ │ ├── launcher │ │ │ ├── launcher.controller.ts │ │ │ └── launcher.events.ts │ │ ├── modOrganizer │ │ │ ├── modOrganizer.controller.ts │ │ │ └── modOrganizer.events.ts │ │ ├── modpack │ │ │ ├── modpack.controller.ts │ │ │ └── mopack.events.ts │ │ ├── profile │ │ │ ├── profile.controller.ts │ │ │ └── profile.events.ts │ │ ├── resolution │ │ │ ├── resolution.controller.ts │ │ │ └── resolution.events.ts │ │ ├── system │ │ │ ├── system.controller.ts │ │ │ └── system.events.ts │ │ ├── update │ │ │ └── update.events.ts │ │ ├── wabbajack │ │ │ ├── wabbajack.controller.ts │ │ │ └── wabbajack.events.ts │ │ └── window │ │ │ ├── window.controller.ts │ │ │ └── window.events.ts │ ├── decorators │ │ └── controller.decorator.ts │ ├── logger.ts │ ├── preload.ts │ └── services │ │ ├── blacklist.service.ts │ │ ├── config.service.ts │ │ ├── enb.service.ts │ │ ├── error.service.ts │ │ ├── game.service.ts │ │ ├── graphics.service.ts │ │ ├── instruction.service.ts │ │ ├── launcher.service.ts │ │ ├── migration.service.ts │ │ ├── modOrganizer.service.ts │ │ ├── modpack.service.ts │ │ ├── profile.service.ts │ │ ├── resolution.service.ts │ │ ├── startup.service.ts │ │ ├── system.service.ts │ │ ├── update.service.ts │ │ ├── wabbajack.service.ts │ │ └── window.service.ts ├── modpack.json ├── renderer │ ├── App.vue │ ├── components │ │ ├── AppDropdownFileSelect.vue │ │ ├── AppModal.vue │ │ ├── AppPage.vue │ │ ├── AppPageContent.vue │ │ ├── BaseButton.vue │ │ ├── BaseDropdown.vue │ │ ├── BaseImage.vue │ │ ├── BaseInput.vue │ │ ├── BaseLabel.vue │ │ ├── BaseLink.vue │ │ ├── BaseList.vue │ │ ├── Community.vue │ │ ├── ENB.vue │ │ ├── GraphicsSelection.vue │ │ ├── ImageWithText.vue │ │ ├── LauncherVersion.vue │ │ ├── MO2RunningModal.vue │ │ ├── ModDirectory.vue │ │ ├── NavigationItem.vue │ │ ├── News.vue │ │ ├── Patrons.vue │ │ ├── ProfileSelection.vue │ │ ├── Resolution.vue │ │ ├── TheHeader.vue │ │ ├── TheNavigation.vue │ │ └── TheTitleBar.vue │ ├── index.ts │ ├── router │ │ └── index.ts │ ├── services │ │ ├── cache.service.ts │ │ ├── event.service.ts │ │ ├── ipc.service.ts │ │ ├── message.service.ts │ │ ├── modal.service.ts │ │ ├── modpack.service.ts │ │ ├── patreon.service.ts │ │ ├── posts.service.ts │ │ └── service-container.ts │ └── views │ │ ├── AutoUpdate.vue │ │ ├── ModDirectory.vue │ │ ├── ViewAdvanced.vue │ │ ├── ViewCommunity.vue │ │ └── ViewHome.vue ├── shared │ ├── enums │ │ └── userPreferenceKeys.ts │ └── util │ │ └── asyncFilter.ts └── types │ ├── ModOrganizer.ini.d.ts │ ├── Resolution.d.ts │ ├── additional-instructions.d.ts │ ├── fetch-installed-software.d.ts │ ├── modpack-metadata.d.ts │ ├── shims-vue.d.ts │ ├── vu3-popper.d.ts │ └── wabbajack.d.ts ├── tsconfig.json ├── vue.config.js └── wallaby.js /.eslintignore: -------------------------------------------------------------------------------- 1 | .eslintrc.js 2 | .github 3 | node_modules/ 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | 4 | env: { 5 | node: true, 6 | }, 7 | 8 | 'extends': [ 9 | 'plugin:vue/vue3-essential', 10 | 'eslint:recommended', 11 | '@vue/typescript/recommended', 12 | '@vue/prettier', 13 | '@vue/prettier/@typescript-eslint' 14 | ], 15 | 16 | parserOptions: { 17 | parser: '@typescript-eslint/parser', 18 | }, 19 | 20 | rules: { 21 | 'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 22 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off', 23 | 'prettier/prettier': ['error', { 'endOfLine': 'auto' }] 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /.github/CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | ## Install dependencies 4 | 5 | ``` 6 | npm install 7 | ``` 8 | 9 | ## Running 10 | 11 | Start the application 12 | ``` 13 | npm run start 14 | //or for automatic reloading of the main process with nodemon 15 | npm run start:dev 16 | ``` 17 | 18 | ## Building 19 | 20 | The application will be built and published automatically when merged to the main branch. 21 | However, if you want to build locally then you can run 22 | ``` 23 | npm run build 24 | ``` 25 | 26 | ## Linting 27 | 28 | All files will be automatically linted when committing. However, if you want to manually lint you can run 29 | 30 | ``` 31 | npm run lint 32 | ``` 33 | 34 | ## Commit validation 35 | 36 | This repository uses [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). All commits must follow this format. 37 | 38 | ## Architecture 39 | 40 | Key technology: 41 | 42 | - [Electron](https://www.electronjs.org/) - The core of the application is built using this. 43 | - [Node.js](https://nodejs.org/en/) - Language used to build the electron `main` process (the backend of the application). 44 | - [Vue 3](https://vuejs.org/) - Frontend framework 45 | - [Loopback Context](https://loopback.io/doc/en/lb4/Context.html) - Dependency injection and IoC container used in the `main` process to simplify setup 46 | 47 | ### Main process 48 | 49 | The code for the main process is mostly broken into controllers and services, along with the application bootstrapping. 50 | 51 | - `controllers` - [Handle](https://www.electronjs.org/docs/latest/api/ipc-main#ipcmainhandlechannel-listener) ipc events from the `renderer`. These can be registered with the `@controller` decorator and ipc handlers with `@handle`. 52 | - `services` - Responsible for the bulk of the logic. Controllers delegate to services to perform actual business logic. 53 | - `application.ts` - Bootstraps and initialise the application, including registering the controller and service artefacts. 54 | 55 | #### Example Controller 56 | 57 | The following will register a controller with 2 ipc handlers. 58 | It is recommended that the channel names are stored in a `const enum`. 59 | ```typescript 60 | // `main/controllers/example/example.controller.ts` 61 | 62 | import { controller, handle } from "@/main/decorators/controller.decorator"; 63 | import { EXAMPLE_EVENTS } from "@/main/controllers/example/example.events"; 64 | import { service } from "@loopback/core"; 65 | import { ExampleService } from "@/main/services/example.service"; 66 | 67 | @controller 68 | export class ExampleController { 69 | constructor(@service(ExampleService) private exampleService: ExampleService) { 70 | } 71 | 72 | @handle('something else') 73 | async somethingElse(...args) { 74 | await this.exampleService.doSomethingElse(...args); 75 | } 76 | 77 | @handle(EXAMPLE_EVENTS.DO_SOMETHING) 78 | async doSomething(...args) { 79 | await this.exampleService.doSomething(...args); 80 | } 81 | } 82 | ``` 83 | 84 | ### Renderer process 85 | 86 | Only the main process can access node events. The renderer process cannot directly access node events. To access " 87 | backend" events, the renderer process must `invoke` events from the ipc service. 88 | Because of this, importing anything with any node code into the renderer process will cause errors. 89 | So, anything that must be shared between both must not contain node code. It is recommended that shared `const enum`s are put in their own file. 90 | 91 | #### Example ipc invoke 92 | 93 | ```typescript 94 | 95 | export default class Example extends Vue { 96 | private ipcService = injectStrict(SERVICE_BINDINGS.IPC_SERVICE); 97 | 98 | async created() { 99 | await this.ipcService.invoke(EXAMPLE_EVENTS.DO_SOMETHING) 100 | } 101 | } 102 | 103 | ``` 104 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug, outstanding questions 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | **Expected behavior** 17 | A clear and concise description of what you expected to happen. 18 | 19 | **Screenshots** 20 | If applicable, add screenshots to help explain your problem. 21 | 22 | **Desktop (please complete the following information):** 23 | - OS: [e.g. Windows version] 24 | - Version [e.g. 22] 25 | 26 | **Additional context** 27 | Add any other context about the problem here. 28 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: feature, outstanding questions 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | | Version | Supported | 6 | | ------- | ------------------ | 7 | | 1.x.x | :white_check_mark: | 8 | 9 | ## Reporting a Vulnerability 10 | 11 | To report a vulnerability, create an issue in this repository and label it with "security". 12 | Or, visit the Discord. 13 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "daily" 12 | assignees: 13 | - "MattLish" 14 | labels: 15 | - "dependencies" 16 | reviewers: 17 | - "MattLish" 18 | allow: 19 | - dependency-type: "production" 20 | 21 | - package-ecosystem: "github-actions" 22 | directory: "/" 23 | schedule: 24 | # Check for updates to GitHub Actions every weekday 25 | interval: "weekly" 26 | assignees: 27 | - "MattLish" 28 | labels: 29 | - "dependencies" 30 | reviewers: 31 | - "MattLish" 32 | allow: 33 | - dependency-name: "*" 34 | dependency-type: "production" 35 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ main ] 17 | schedule: 18 | # ┌───────────── minute (0 - 59) 19 | # │ ┌───────────── hour (0 - 23) 20 | # │ │ ┌───────────── day of the month (1 - 31) 21 | # │ │ │ ┌───────────── month (1 - 12 or JAN-DEC) 22 | # │ │ │ │ ┌───────────── day of the week (0 - 6 or SUN-SAT) 23 | # │ │ │ │ │ 24 | # │ │ │ │ │ 25 | # │ │ │ │ │ 26 | # * * * * * 27 | - cron: '30 1 * * 0' 28 | 29 | 30 | jobs: 31 | analyze: 32 | name: Analyze 33 | runs-on: ubuntu-latest 34 | permissions: 35 | actions: read 36 | contents: read 37 | security-events: write 38 | 39 | strategy: 40 | fail-fast: false 41 | matrix: 42 | language: [ 'javascript' ] 43 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python' ] 44 | # Learn more: 45 | # https://docs.github.com/en/free-pro-team@latest/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#changing-the-languages-that-are-analyzed 46 | 47 | steps: 48 | - name: Checkout repository 49 | uses: actions/checkout@v3 50 | 51 | # Initializes the CodeQL tools for scanning. 52 | - name: Initialize CodeQL 53 | uses: github/codeql-action/init@v2 54 | with: 55 | languages: ${{ matrix.language }} 56 | # If you wish to specify custom queries, you can do so here or in a config file. 57 | # By default, queries listed here will override any specified in a config file. 58 | # Prefix the list here with "+" to use these queries and those in the config file. 59 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 60 | 61 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 62 | # If this step fails, then you should remove it and run the build manually (see below) 63 | - name: Autobuild 64 | uses: github/codeql-action/autobuild@v2 65 | 66 | # ℹ️ Command-line programs to run using the OS shell. 67 | # 📚 https://git.io/JvXDl 68 | 69 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 70 | # and modify them (or add more) to build your code if your project 71 | # uses a compiled language 72 | 73 | #- run: | 74 | # make bootstrap 75 | # make release 76 | 77 | - name: Perform CodeQL Analysis 78 | uses: github/codeql-action/analyze@v2 79 | -------------------------------------------------------------------------------- /.github/workflows/pre-merge.yml: -------------------------------------------------------------------------------- 1 | name: Pre-merge 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'release-temp/**' 7 | pull_request: 8 | branches: 9 | - main 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3 17 | 18 | - name: Use Node.js ${{ matrix.node-version }} 19 | uses: actions/setup-node@v3 20 | with: 21 | node-version: '16' 22 | 23 | - run: npm ci --legacy-peer-deps 24 | - run: npm run lint -- --no-fix 25 | 26 | test: 27 | runs-on: ubuntu-latest 28 | 29 | steps: 30 | - uses: actions/checkout@v3 31 | 32 | - name: Use Node.js ${{ matrix.node-version }} 33 | uses: actions/setup-node@v3 34 | with: 35 | node-version: '16' 36 | 37 | - run: npm ci --legacy-peer-deps 38 | - run: npm run test:coverage 39 | 40 | # Validate that commits are following the conventional commit format 41 | commit-validate: 42 | # Don't run for dependabot updates 43 | if: contains(github.head_ref, 'dependabot/') == false 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v3 47 | with: 48 | fetch-depth: 0 49 | 50 | - name: Use Node.js ${{ matrix.node-version }} 51 | uses: actions/setup-node@v3 52 | with: 53 | node-version: '16' 54 | 55 | - name: install dependencies 56 | run: npm ci --legacy-peer-deps 57 | 58 | - name: lint commits 59 | run: npx commitlint --from origin/main 60 | 61 | version-dry-run: 62 | runs-on: ubuntu-latest 63 | steps: 64 | - uses: actions/checkout@v3 65 | 66 | - name: Use Node.js 67 | uses: actions/setup-node@v3 68 | with: 69 | node-version: '16' 70 | 71 | - name: bump version dry run 72 | run: npx standard-version --dry-run 73 | -------------------------------------------------------------------------------- /.github/workflows/rebase.yml: -------------------------------------------------------------------------------- 1 | name: Automatic Rebase 2 | on: 3 | issue_comment: 4 | types: [ created ] 5 | jobs: 6 | rebase: 7 | name: Rebase 8 | if: github.event.issue.pull_request != '' && contains(github.event.comment.body, '/rebase') 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout the latest code 12 | uses: actions/checkout@v3 13 | with: 14 | token: ${{ secrets.ACCESS_TOKEN }} 15 | fetch-depth: 0 # otherwise, you will fail to push refs to dest repo 16 | - name: Automatic Rebase 17 | uses: cirrus-actions/rebase@1.7 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | workflow_run: 5 | workflows: [ "Version" ] 6 | types: 7 | - completed 8 | 9 | # Allows you to run this workflow manually from the Actions tab 10 | workflow_dispatch: 11 | 12 | permissions: 13 | contents: write 14 | 15 | jobs: 16 | create-release: 17 | runs-on: windows-latest 18 | 19 | if: ${{ github.event.workflow_run.conclusion == 'success' }} 20 | 21 | steps: 22 | - uses: actions/checkout@v3 23 | 24 | - name: Use Node.js 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: '16' 28 | 29 | - name: Install dependencies 30 | run: npm ci --legacy-peer-deps 31 | 32 | # When testing or working with forks, electron builder will try and publish to the main repo, not the fork. 33 | # This allows it to publish to the forked repo for testing 34 | - name: Replace package.json repository 35 | shell: bash 36 | run: | 37 | contents="$(jq --arg repo $GITHUB_SERVER_URL/$GITHUB_REPOSITORY '.repository = $repo' package.json)" 38 | echo $contents > package.json 39 | 40 | - name: Build app 41 | run: npm run build -- --publish always 42 | env: 43 | CI: true 44 | GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 45 | -------------------------------------------------------------------------------- /.github/workflows/version.yml: -------------------------------------------------------------------------------- 1 | name: Version 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | # Allows you to run this workflow manually from the Actions tab 9 | workflow_dispatch: 10 | 11 | jobs: 12 | version-bump: 13 | runs-on: ubuntu-latest 14 | # Prevent the versioning getting stuck in a loop when it pushes back to the main branch 15 | if: "!contains(toJSON(github.event.commits.*.message), 'chore(release)')" 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | with: 20 | token: ${{ secrets.ACCESS_TOKEN }} 21 | 22 | - uses: fregante/setup-git-user@v1 23 | 24 | - name: Use Node.js 25 | uses: actions/setup-node@v3 26 | with: 27 | node-version: '16' 28 | 29 | - name: bump version 30 | run: npx standard-version --no-verify 31 | 32 | # Inspired by https://github.com/CasperWA/push-protected but unable to get it working with this repository. 33 | # Instead, we just wait a set amount of time and hope the status checks complete. 34 | # Eventually this will need revisiting to ensure status checks pass before continuing 35 | - name: publish changes 36 | run: | 37 | TEMP_BRANCH=release-temp/$(cat /proc/sys/kernel/random/uuid) 38 | git checkout -b $TEMP_BRANCH 39 | git push -f origin $TEMP_BRANCH 40 | 41 | sleep 200 42 | 43 | git checkout main 44 | git reset --hard $TEMP_BRANCH 45 | git push origin main 46 | git push --tags 47 | git push -d origin $TEMP_BRANCH 48 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | /coverage 5 | .nyc_output 6 | 7 | # local env files 8 | .env.local 9 | .env.*.local 10 | 11 | # Log files 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | pnpm-debug.log* 16 | 17 | # Editor directories and files 18 | .idea 19 | .vscode 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | dev-app-update.yml 27 | 28 | # Mock directory to replicate %APPDATA% 29 | local 30 | -------------------------------------------------------------------------------- /.mocharc.json: -------------------------------------------------------------------------------- 1 | { 2 | "recursive": true, 3 | "require": [ 4 | "source-map-support/register", 5 | "tsconfig-paths/register" 6 | ], 7 | "file": [ 8 | "./dist/__tests__/unit/setup/global-setup.js" 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /.nycrc: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["dist"], 3 | "exclude": ["dist/__tests__/"], 4 | "extension": [".js", ".ts"], 5 | "reporter": ["text", "html"], 6 | "exclude-after-remap": false, 7 | "check-coverage": true, 8 | "per-file": true, 9 | "all": true, 10 | "branches": 0, 11 | "lines": 0, 12 | "functions": 0, 13 | "statements": 0 14 | } 15 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist_electron 3 | .eslintrc.js 4 | .github 5 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "stylelint-config-recommended-scss", 3 | "rules": { 4 | "selector-pseudo-class-no-unknown": [ 5 | true, 6 | { 7 | "ignorePseudoClasses": [ 8 | "deep" 9 | ] 10 | } 11 | ] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Thomas Blasquez 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Wildlander launcher 2 | 3 | A launcher for the [Wildlander](https://www.wildlandermod.com/) modpack. 4 | 5 | The intention of the launcher is to ease the installation process for modpacks that utilise [Wabbajack](https://www.wabbajack.org/#/) but require extra steps. 6 | This includes copying game files, installing prerequisites, various support options, etc. 7 | The idea is to attempt to make the installation of a modpack more of a one click procedure. 8 | 9 | [Install the latest release from the releases page](https://github.com/Wildlander-mod/Launcher/releases/latest). 10 | 11 | # Contributing 12 | 13 | [CONTRIBUTING.md](.github/CONTRIBUTING.md) 14 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ["@vue/cli-plugin-babel/preset"], 3 | }; 4 | -------------------------------------------------------------------------------- /build/installerHeaderIcon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/build/installerHeaderIcon.ico -------------------------------------------------------------------------------- /dev-app-update.yml.example: -------------------------------------------------------------------------------- 1 | owner: wildlander-mod 2 | repo: launcher 3 | provider: github 4 | releaseType: "release" 5 | -------------------------------------------------------------------------------- /lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "*.{js,jsx,vue,ts,tsx}": ["vue-cli-service lint", () => "npm run test"], 3 | "*.scss": ["npm run lint:styles"], 4 | }; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wildlander-launcher", 3 | "version": "2.19.0", 4 | "description": "A launcher for the Wabbajack modpacks", 5 | "author": "Wildlander", 6 | "scripts": { 7 | "clean": "lb-clean dist", 8 | "precompile": "npm run clean", 9 | "compile": "lb-tsc", 10 | "postcompile": "tsc-alias", 11 | "prestart": "npm run compile", 12 | "start": "vue-cli-service electron:serve", 13 | "start:dev": "nodemon", 14 | "prebuild": "npm run compile", 15 | "build": "vue-cli-service electron:build", 16 | "lint": "npm run lint:typescript && npm run lint:eslint && npm run lint:styles", 17 | "lint:fix": "npm run lint:eslint:fix", 18 | "electron:debug:main": "electron --remote-debugging-port=9223 ./dist_electron", 19 | "electron:debug:renderer": "npm run start -- --debug", 20 | "lint:eslint": "vue-cli-service lint", 21 | "lint:eslint:fix": "vue-cli-service lint --fix", 22 | "lint:styles": "stylelint **/*.scss", 23 | "lint:typescript": "tsc --noEmit", 24 | "postinstall": "electron-builder install-app-deps", 25 | "postuninstall": "electron-builder install-app-deps", 26 | "pretest": "npm run compile", 27 | "test": "npm run test:unit", 28 | "test:coverage": "lb-nyc npm run test", 29 | "test:unit": "lb-mocha \"dist/__tests__/unit\"" 30 | }, 31 | "nodemonConfig": { 32 | "watch": [ 33 | "src/**" 34 | ], 35 | "ignore": [ 36 | "src/renderer/**" 37 | ], 38 | "exec": "npm run start", 39 | "ext": "*" 40 | }, 41 | "main": "background.js", 42 | "dependencies": { 43 | "@loopback/boot": "^5.0.2", 44 | "@loopback/context": "^5.0.1", 45 | "@loopback/core": "^4.0.2", 46 | "@vueform/toggle": "^2.1.1", 47 | "electron-context-menu": "^3.4.0", 48 | "electron-log": "^4.4.8", 49 | "electron-shutdown-command": "^2.0.1", 50 | "electron-store": "^8.1.0", 51 | "electron-updater": "^4.6.5", 52 | "fetch-installed-software": "^0.0.7", 53 | "fs-extra": "^10.1.0", 54 | "js-ini": "^1.5.1", 55 | "junk": "^3.1.0", 56 | "mitt": "^3.0.0", 57 | "node-fetch": "^2.6.7", 58 | "ps-list": "^7.2.0", 59 | "tslib": "^2.5.0", 60 | "vue": "^3.2.31", 61 | "vue-class-component": "^8.0.0-0", 62 | "vue-final-modal": "^3.4.4", 63 | "vue-router": "^4.1.3", 64 | "vue3-click-away": "^1.2.4", 65 | "vue3-popper": "^1.5.0" 66 | }, 67 | "devDependencies": { 68 | "@babel/plugin-proposal-class-properties": "^7.16.7", 69 | "@babel/preset-typescript": "^7.16.5", 70 | "@commitlint/config-conventional": "^15.0.0", 71 | "@loopback/build": "^9.0.2", 72 | "@loopback/testlab": "^5.0.2", 73 | "@types/electron-devtools-installer": "^2.2.0", 74 | "@types/fs-extra": "^9.0.13", 75 | "@types/ini": "^1.3.31", 76 | "@types/mocha": "^9.1.1", 77 | "@types/mock-fs": "^4.13.1", 78 | "@types/mock-require": "^2.0.1", 79 | "@types/ncp": "^2.0.5", 80 | "@types/node-fetch": "^2.6.1", 81 | "@typescript-eslint/eslint-plugin": "^2.33.0", 82 | "@typescript-eslint/parser": "^2.33.0", 83 | "@vue/cli-plugin-babel": "~4.5.15", 84 | "@vue/cli-plugin-eslint": "^4.5.15", 85 | "@vue/cli-plugin-router": "~4.5.15", 86 | "@vue/cli-plugin-typescript": "^4.5.15", 87 | "@vue/cli-service": "~4.5.15", 88 | "@vue/compiler-sfc": "^3.2.26", 89 | "@vue/eslint-config-prettier": "^6.0.0", 90 | "@vue/eslint-config-standard": "^5.1.2", 91 | "@vue/eslint-config-typescript": "^5.0.2", 92 | "babel-eslint": "^10.1.0", 93 | "commitlint": "^15.0.0", 94 | "electron": "^16.0.5", 95 | "electron-devtools-installer": "^3.1.0", 96 | "eslint": "^6.7.2", 97 | "eslint-plugin-import": "^2.25.3", 98 | "eslint-plugin-node": "^11.1.0", 99 | "eslint-plugin-prettier": "^3.4.1", 100 | "eslint-plugin-promise": "^6.0.0", 101 | "eslint-plugin-standard": "^4.0.0", 102 | "eslint-plugin-vue": "^8.2.0", 103 | "lint-staged": "^12.1.3", 104 | "mocha": "^10.0.0", 105 | "mock-fs": "^5.1.3", 106 | "mock-require": "^3.0.3", 107 | "node-sass": "^6.0.1", 108 | "nodemon": "^2.0.15", 109 | "prettier": "^2.5.1", 110 | "reflect-metadata": "^0.1.13", 111 | "sass-loader": "^10.2.0", 112 | "stylelint": "^13.12.0", 113 | "stylelint-config-recommended-scss": "^4.3.0", 114 | "stylelint-scss": "^3.21.0", 115 | "tsc-alias": "^1.6.4", 116 | "tsconfig-paths": "^4.0.0", 117 | "typescript": "^4.5.4", 118 | "vue-cli-plugin-electron-builder": "~2.1.1", 119 | "vue-property-decorator": "^10.0.0-rc.3" 120 | }, 121 | "commitlint": { 122 | "extends": [ 123 | "@commitlint/config-conventional" 124 | ], 125 | "rules": { 126 | "footer-max-line-length": [ 127 | 0, 128 | "always" 129 | ] 130 | }, 131 | "parserPreset": { 132 | "parserOpts": { 133 | "noteKeywords": [ 134 | "docs:" 135 | ] 136 | } 137 | } 138 | }, 139 | "gitHooks": { 140 | "pre-commit": "lint-staged", 141 | "commit-msg": "commitlint -e -V " 142 | }, 143 | "repository": "https://github.com/Wildlander-mod/Launcher" 144 | } 145 | -------------------------------------------------------------------------------- /public/images/default-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/public/images/default-background.png -------------------------------------------------------------------------------- /public/images/logos/discord.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 11 | 15 | 19 | 23 | 24 | -------------------------------------------------------------------------------- /public/images/logos/faq.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 11 | 15 | 19 | 23 | 27 | 31 | 35 | 36 | -------------------------------------------------------------------------------- /public/images/logos/new-player-guide.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 11 | 15 | 19 | 23 | 27 | 31 | 35 | 39 | 43 | 44 | -------------------------------------------------------------------------------- /public/images/logos/patreon.svg: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 14 | 18 | 19 | 20 | 21 | 22 | 28 | 29 | 37 | 38 | 39 | 40 | 46 | 47 | 55 | 56 | 57 | 58 | 59 | 60 | -------------------------------------------------------------------------------- /public/images/logos/reddit.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 11 | 15 | 16 | -------------------------------------------------------------------------------- /public/images/logos/twitch.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 11 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /public/images/logos/wildlander-icon-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/public/images/logos/wildlander-icon-dark.png -------------------------------------------------------------------------------- /public/images/logos/wildlander-icon-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/public/images/logos/wildlander-icon-light.png -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/__tests__/unit/controllers/graphics.controller.test.ts: -------------------------------------------------------------------------------- 1 | import { GraphicsController } from "@/main/controllers/graphics/graphics.controller"; 2 | import { GraphicsService } from "@/main/services/graphics.service"; 3 | import { 4 | createStubInstance, 5 | sinon, 6 | StubbedInstanceWithSinonAccessor, 7 | } from "@loopback/testlab"; 8 | 9 | describe("Graphics controller", () => { 10 | let graphicsService: StubbedInstanceWithSinonAccessor; 11 | let graphicsController: GraphicsController; 12 | 13 | beforeEach(() => { 14 | graphicsService = createStubInstance(GraphicsService); 15 | graphicsController = new GraphicsController(graphicsService); 16 | }); 17 | 18 | it("should get graphics preferences", () => { 19 | graphicsController.getGraphicsPreference(); 20 | sinon.assert.called(graphicsService.stubs.getGraphicsPreference); 21 | }); 22 | 23 | it("should get the graphics settings", () => { 24 | graphicsController.getGraphics(); 25 | sinon.assert.called(graphicsService.stubs.getGraphics); 26 | }); 27 | 28 | it("should set the graphics preference", () => { 29 | graphicsController.setGraphics("mock graphics"); 30 | sinon.assert.calledWith(graphicsService.stubs.setGraphics, "mock graphics"); 31 | }); 32 | 33 | it("should restore the graphics preference", () => { 34 | graphicsController.restoreGraphics(); 35 | sinon.assert.called(graphicsService.stubs.restoreGraphics); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/__tests__/unit/services/blacklist.service.test.ts: -------------------------------------------------------------------------------- 1 | import { createStubInstance, expect } from "@loopback/testlab"; 2 | import { SystemService } from "@/main/services/system.service"; 3 | import { BlacklistService } from "@/main/services/blacklist.service"; 4 | 5 | describe("Blacklist service", () => { 6 | it("should return a list of blacklisted processes running", async () => { 7 | const mockSystemService = createStubInstance(SystemService); 8 | 9 | mockSystemService.stubs.isProcessRunning.resolves(false); 10 | mockSystemService.stubs.isProcessRunning 11 | .withArgs("WRCoreService.x64.exe") 12 | .resolves(true); 13 | 14 | const blacklistService = new BlacklistService(mockSystemService); 15 | 16 | expect(await blacklistService.blacklistedProcessesRunning()).to.eql([ 17 | { 18 | name: "WebRoot Antivirus", 19 | processName: "WRCoreService.x64.exe", 20 | running: true, 21 | }, 22 | ]); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/__tests__/unit/services/config.service.test.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService, UserPreferences } from "@/main/services/config.service"; 2 | import { expect } from "@loopback/testlab"; 3 | import mockFs from "mock-fs"; 4 | import Store from "electron-store"; 5 | 6 | describe("Config Service", () => { 7 | let mockStore: Store; 8 | let mockModDirectory: string; 9 | 10 | beforeEach(() => { 11 | const mockUserConfig = "/mock/config"; 12 | const preferenceFile = "userPreferences"; 13 | mockModDirectory = "mock/mod/directory"; 14 | mockFs({ 15 | [`${mockUserConfig}/${preferenceFile}.json`]: JSON.stringify({ 16 | MOD_DIRECTORY: mockModDirectory, 17 | }), 18 | }); 19 | mockStore = new Store({ 20 | name: preferenceFile, 21 | cwd: mockUserConfig, 22 | }); 23 | }); 24 | 25 | afterEach(() => { 26 | mockFs.restore(); 27 | }); 28 | 29 | it("should get the mod directory", () => { 30 | const configService = new ConfigService(mockStore); 31 | expect(configService.modDirectory()).to.eql("mock/mod/directory"); 32 | }); 33 | 34 | it("should return the modlist launcher directory", () => { 35 | const configService = new ConfigService(mockStore); 36 | expect(configService.launcherDirectory()).to.eql( 37 | `${mockModDirectory}/launcher` 38 | ); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/__tests__/unit/services/game.service.test.ts: -------------------------------------------------------------------------------- 1 | import mockFs from "mock-fs"; 2 | import { 3 | createStubInstance, 4 | expect, 5 | StubbedInstanceWithSinonAccessor, 6 | } from "@loopback/testlab"; 7 | import { GameService } from "@/main/services/game.service"; 8 | import { ConfigService } from "@/main/services/config.service"; 9 | import fs from "fs"; 10 | 11 | describe("GameService", () => { 12 | let mockConfigService: StubbedInstanceWithSinonAccessor; 13 | let gameService: GameService; 14 | 15 | let logPath: string; 16 | 17 | beforeEach(() => { 18 | mockConfigService = createStubInstance(ConfigService); 19 | gameService = new GameService(mockConfigService); 20 | 21 | logPath = "mock/log/path"; 22 | }); 23 | 24 | afterEach(() => { 25 | mockFs.restore(); 26 | }); 27 | 28 | it("should copy Skyrim logs if they exist", async () => { 29 | mockConfigService.stubs.skyrimDirectory.returns("mock/path"); 30 | mockConfigService.stubs.getLogDirectory.returns(logPath); 31 | 32 | mockFs({ 33 | "mock/path/d3dx9_42.log": "mock log file", 34 | [logPath]: {}, 35 | }); 36 | 37 | await gameService.copySkyrimLaunchLogs(); 38 | 39 | expect(fs.existsSync(`${logPath}/skyrim-launch-logs.log`)).to.true(); 40 | }); 41 | 42 | it("should not copy Skyrim logs if they don't exist", async () => { 43 | mockConfigService.stubs.skyrimDirectory.returns("mock/path"); 44 | mockConfigService.stubs.getLogDirectory.returns(logPath); 45 | 46 | mockFs({ 47 | [logPath]: {}, 48 | }); 49 | 50 | await gameService.copySkyrimLaunchLogs(); 51 | 52 | expect(fs.existsSync(`${logPath}/skyrim-launch-logs.log`)).to.false(); 53 | }); 54 | }); 55 | -------------------------------------------------------------------------------- /src/__tests__/unit/services/modOrganizer.service.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | MO2Names, 3 | ModOrganizerService, 4 | } from "@/main/services/modOrganizer.service"; 5 | import { 6 | createStubInstance, 7 | expect, 8 | StubbedInstanceWithSinonAccessor, 9 | } from "@loopback/testlab"; 10 | import { EnbService } from "@/main/services/enb.service"; 11 | import { ErrorService } from "@/main/services/error.service"; 12 | import { ConfigService } from "@/main/services/config.service"; 13 | import { ResolutionService } from "@/main/services/resolution.service"; 14 | import { GameService } from "@/main/services/game.service"; 15 | import { ProfileService } from "@/main/services/profile.service"; 16 | import { SystemService } from "@/main/services/system.service"; 17 | import { GraphicsService } from "@/main/services/graphics.service"; 18 | import mockFs from "mock-fs"; 19 | 20 | describe("ModOrganizer service", () => { 21 | let mockEnbService: StubbedInstanceWithSinonAccessor; 22 | let mockErrorService: StubbedInstanceWithSinonAccessor; 23 | let mockConfigService: StubbedInstanceWithSinonAccessor; 24 | let mockResolutionService: StubbedInstanceWithSinonAccessor; 25 | let mockGameService: StubbedInstanceWithSinonAccessor; 26 | let mockProfileService: StubbedInstanceWithSinonAccessor; 27 | let mockSystemService: StubbedInstanceWithSinonAccessor; 28 | let mockGraphicsService: StubbedInstanceWithSinonAccessor; 29 | 30 | let modOrganizerService: ModOrganizerService; 31 | 32 | beforeEach(() => { 33 | mockEnbService = createStubInstance(EnbService); 34 | mockErrorService = createStubInstance(ErrorService); 35 | mockConfigService = createStubInstance(ConfigService); 36 | mockResolutionService = createStubInstance(ResolutionService); 37 | mockGameService = createStubInstance(GameService); 38 | mockProfileService = createStubInstance(ProfileService); 39 | mockSystemService = createStubInstance(SystemService); 40 | mockGraphicsService = createStubInstance(GraphicsService); 41 | }); 42 | 43 | afterEach(() => { 44 | mockFs.restore(); 45 | }); 46 | 47 | it("should determine if Mod Organizer is running", async () => { 48 | mockSystemService.stubs.isProcessRunning 49 | .withArgs(MO2Names.MO2EXE) 50 | .resolves(true); 51 | 52 | modOrganizerService = new ModOrganizerService( 53 | mockEnbService, 54 | mockErrorService, 55 | mockConfigService, 56 | mockResolutionService, 57 | mockGameService, 58 | mockProfileService, 59 | mockSystemService, 60 | mockGraphicsService 61 | ); 62 | 63 | expect(await modOrganizerService.isRunning()).to.eql(true); 64 | }); 65 | 66 | it("should get the first binary", async () => { 67 | const mockModDirectory = "mock/mod/directory"; 68 | 69 | mockFs({ 70 | [mockModDirectory]: { 71 | [MO2Names.MO2Settings]: ` 72 | [customExecutables] 73 | size=1 74 | 1\\binary=mock/first/custom/executable.exe 75 | 1\\title=SKSE 76 | `, 77 | }, 78 | }); 79 | 80 | mockConfigService.stubs.modDirectory.returns(mockModDirectory); 81 | 82 | modOrganizerService = new ModOrganizerService( 83 | mockEnbService, 84 | mockErrorService, 85 | mockConfigService, 86 | mockResolutionService, 87 | mockGameService, 88 | mockProfileService, 89 | mockSystemService, 90 | mockGraphicsService 91 | ); 92 | 93 | expect(await modOrganizerService.getFirstCustomExecutableTitle()).to.eql( 94 | "SKSE" 95 | ); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /src/__tests__/unit/services/profile.service.test.ts: -------------------------------------------------------------------------------- 1 | import mockFs from "mock-fs"; 2 | import { FriendlyDirectoryMap } from "@/modpack-metadata"; 3 | import { 4 | createStubInstance, 5 | expect, 6 | sinon, 7 | StubbedInstanceWithSinonAccessor, 8 | } from "@loopback/testlab"; 9 | import { ConfigService } from "@/main/services/config.service"; 10 | import { ProfileService } from "@/main/services/profile.service"; 11 | import { USER_PREFERENCE_KEYS } from "@/shared/enums/userPreferenceKeys"; 12 | 13 | describe("Profile service", () => { 14 | let mockConfigService: StubbedInstanceWithSinonAccessor; 15 | let profileService: ProfileService; 16 | 17 | beforeEach(() => { 18 | mockConfigService = createStubInstance(ConfigService); 19 | profileService = new ProfileService(mockConfigService); 20 | }); 21 | 22 | afterEach(() => { 23 | mockFs.restore(); 24 | }); 25 | 26 | it("should get if the user is showing hidden profiles", () => { 27 | mockConfigService.stubs.getPreference 28 | .withArgs(USER_PREFERENCE_KEYS.SHOW_HIDDEN_PROFILE) 29 | .returns(true); 30 | 31 | expect(profileService.getShowHiddenProfiles()).to.eql(true); 32 | }); 33 | 34 | it("should default showing hidden profiles to false if the key doesn't exist", () => { 35 | mockConfigService.stubs.getPreference 36 | .withArgs(USER_PREFERENCE_KEYS.SHOW_HIDDEN_PROFILE) 37 | .returns(undefined); 38 | 39 | expect(profileService.getShowHiddenProfiles()).to.eql(false); 40 | }); 41 | 42 | it("should set if the user is showing hidden profiles", () => { 43 | mockConfigService.stubs.getPreference 44 | .withArgs(USER_PREFERENCE_KEYS.SHOW_HIDDEN_PROFILE) 45 | .returns(true); 46 | profileService.setShowHiddenProfiles(false); 47 | 48 | mockConfigService.stubs.setPreference.calledWith(sinon.match.string, false); 49 | }); 50 | 51 | it("should get the profiles", async () => { 52 | const modDirectory = "mod/directory"; 53 | const launcherDirectory = `${modDirectory}/launcher`; 54 | 55 | mockConfigService.stubs.modDirectory.returns(modDirectory); 56 | mockConfigService.stubs.launcherDirectory.returns(launcherDirectory); 57 | 58 | mockFs({ 59 | [`${launcherDirectory}/namesMO2.json`]: JSON.stringify([ 60 | { 61 | real: "mock-real-name", 62 | friendly: "mock friendly name", 63 | }, 64 | ] as FriendlyDirectoryMap[]), 65 | [`${modDirectory}/profiles/mock-real-name`]: {}, 66 | [`${modDirectory}/profiles/unmapped`]: {}, 67 | }); 68 | 69 | const profiles = await profileService.getProfiles(); 70 | const expected: FriendlyDirectoryMap[] = [ 71 | { 72 | real: "mock-real-name", 73 | friendly: "mock friendly name", 74 | }, 75 | { 76 | real: "unmapped", 77 | friendly: "unmapped", 78 | }, 79 | ]; 80 | 81 | expect(profiles).to.eql(expected); 82 | }); 83 | 84 | it("should get the profile's directories", async () => { 85 | const modDirectory = "mod/directory"; 86 | const launcherDirectory = `${modDirectory}/launcher`; 87 | 88 | mockConfigService.stubs.modDirectory.returns(modDirectory); 89 | mockConfigService.stubs.launcherDirectory.returns(launcherDirectory); 90 | 91 | mockFs({ 92 | [`${launcherDirectory}/namesMO2.json`]: JSON.stringify([ 93 | { 94 | real: "mock-real-name", 95 | friendly: "mock friendly name", 96 | }, 97 | ] as FriendlyDirectoryMap[]), 98 | [`${modDirectory}/profiles/unmapped`]: {}, 99 | [`${modDirectory}/profiles/mock-real-name`]: {}, 100 | }); 101 | 102 | const profileDirectories = await profileService.getProfileDirectories(); 103 | const expected = [ 104 | `${modDirectory}/profiles/mock-real-name`, 105 | `${modDirectory}/profiles/unmapped`, 106 | ]; 107 | 108 | expect(profileDirectories).to.eql(expected); 109 | }); 110 | 111 | it("should prepend the profile directory", () => { 112 | const modDirectory = "mod/directory"; 113 | 114 | mockConfigService.stubs.modDirectory.returns(modDirectory); 115 | 116 | expect(profileService.prependProfileDirectory("mock-file.json")).to.eql( 117 | "mod/directory/profiles/mock-file.json" 118 | ); 119 | }); 120 | }); 121 | -------------------------------------------------------------------------------- /src/__tests__/unit/services/startup.service.test.ts: -------------------------------------------------------------------------------- 1 | import mock from "mock-require"; 2 | // electron-is-dev throws an error if not running in electron 3 | mock("electron-is-dev", () => { 4 | return true; 5 | }); 6 | 7 | import { StartupService } from "@/main/services/startup.service"; 8 | import { 9 | createStubInstance, 10 | sinon, 11 | StubbedInstanceWithSinonAccessor, 12 | } from "@loopback/testlab"; 13 | import { ModpackService } from "@/main/services/modpack.service"; 14 | import { LauncherService } from "@/main/services/launcher.service"; 15 | import { ConfigService } from "@/main/services/config.service"; 16 | import { WabbajackService } from "@/main/services/wabbajack.service"; 17 | import { ResolutionService } from "@/main/services/resolution.service"; 18 | import { UpdateService } from "@/main/services/update.service"; 19 | import { BlacklistService } from "@/main/services/blacklist.service"; 20 | import { ErrorService } from "@/main/services/error.service"; 21 | import { WindowService } from "@/main/services/window.service"; 22 | 23 | describe("Startup service", () => { 24 | let mockModpackService: StubbedInstanceWithSinonAccessor; 25 | let mockLauncherService: StubbedInstanceWithSinonAccessor; 26 | let mockConfigService: StubbedInstanceWithSinonAccessor; 27 | let mockWabbajackService: StubbedInstanceWithSinonAccessor; 28 | let mockResolutionService: StubbedInstanceWithSinonAccessor; 29 | let mockUpdateService: StubbedInstanceWithSinonAccessor; 30 | let mockBlacklistService: StubbedInstanceWithSinonAccessor; 31 | let mockErrorService: StubbedInstanceWithSinonAccessor; 32 | let mockWindowService: StubbedInstanceWithSinonAccessor; 33 | 34 | let startupService: StartupService; 35 | 36 | beforeEach(() => { 37 | mockModpackService = createStubInstance(ModpackService); 38 | mockLauncherService = createStubInstance(LauncherService); 39 | mockConfigService = createStubInstance(ConfigService); 40 | mockWabbajackService = createStubInstance(WabbajackService); 41 | mockResolutionService = createStubInstance(ResolutionService); 42 | mockUpdateService = createStubInstance(UpdateService); 43 | mockBlacklistService = createStubInstance(BlacklistService); 44 | mockErrorService = createStubInstance(ErrorService); 45 | mockWindowService = createStubInstance(WindowService); 46 | 47 | startupService = new StartupService( 48 | mockModpackService, 49 | mockLauncherService, 50 | mockConfigService, 51 | mockWabbajackService, 52 | mockResolutionService, 53 | mockUpdateService, 54 | mockBlacklistService, 55 | mockErrorService, 56 | mockWindowService 57 | ); 58 | }); 59 | 60 | after(() => { 61 | mock.stopAll(); 62 | }); 63 | 64 | it("should quit if a blacklisted process is running", async () => { 65 | mockBlacklistService.stubs.blacklistedProcessesRunning.resolves([ 66 | { name: "mock name", processName: "mockProcess.exe", running: true }, 67 | ]); 68 | 69 | startupService.registerStartupCommands("processBlacklist"); 70 | await startupService.runStartup(); 71 | 72 | sinon.assert.called(mockWindowService.stubs.quit); 73 | }); 74 | 75 | it("should not quit if there are no blacklisted processes", () => { 76 | startupService.registerStartupCommands( 77 | 'Check if blacklisted process is running"' 78 | ); 79 | startupService.runStartup(); 80 | 81 | sinon.assert.notCalled(mockWindowService.stubs.quit); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/__tests__/unit/services/system.service.test.ts: -------------------------------------------------------------------------------- 1 | import { SystemService } from "@/main/services/system.service"; 2 | import { 3 | createStubInstance, 4 | expect, 5 | StubbedInstanceWithSinonAccessor, 6 | } from "@loopback/testlab"; 7 | import { ConfigService } from "@/main/services/config.service"; 8 | import { ErrorService } from "@/main/services/error.service"; 9 | import psList from "ps-list"; 10 | 11 | describe("System service", () => { 12 | let mockConfigService: StubbedInstanceWithSinonAccessor; 13 | let mockErrorService: StubbedInstanceWithSinonAccessor; 14 | 15 | let systemService: SystemService; 16 | 17 | beforeEach(() => { 18 | mockConfigService = createStubInstance(ConfigService); 19 | mockErrorService = createStubInstance(ErrorService); 20 | }); 21 | 22 | it("should return true if a process is running", async () => { 23 | const mockListProcess = () => 24 | new Promise((resolve) => 25 | resolve([{ name: "mockname.exe", pid: 1234, ppid: 1234 }]) 26 | ); 27 | systemService = new SystemService( 28 | mockConfigService, 29 | mockErrorService, 30 | mockListProcess 31 | ); 32 | 33 | expect(await systemService.isProcessRunning("mockname.exe")).to.eql(true); 34 | }); 35 | 36 | it("should return false if a process is not", async () => { 37 | const mockListProcess = () => 38 | new Promise((resolve) => 39 | resolve([{ name: "mockname.exe", pid: 1234, ppid: 1234 }]) 40 | ); 41 | systemService = new SystemService( 42 | mockConfigService, 43 | mockErrorService, 44 | mockListProcess 45 | ); 46 | 47 | expect(await systemService.isProcessRunning("notrunning.exe")).to.eql( 48 | false 49 | ); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/__tests__/unit/setup/global-setup.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "@/main/logger"; 2 | 3 | // Disable logging 4 | logger.transports.console.level = false; 5 | logger.transports.file.level = false; 6 | -------------------------------------------------------------------------------- /src/additional-instructions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "action": "disable-plugin", 4 | "type": "enb", 5 | "target": "noEnb", 6 | "plugin": "NightEyeENBFix_PredatorVision.esp" 7 | }, 8 | { 9 | "action": "disable-ultra-widescreen", 10 | "version": "1.0.0" 11 | }, 12 | { 13 | "action": "enable-mod", 14 | "type": "resolution-ratio", 15 | "target": "21:9", 16 | "mod": "Wildlander 21-9 Resolution Support" 17 | }, 18 | { 19 | "action": "enable-mod", 20 | "type": "resolution-ratio", 21 | "target": "32:9", 22 | "mod": "Wildlander 32-9 Resolution Support" 23 | }, 24 | { 25 | "action": "enable-plugin", 26 | "type": "resolution-ratio", 27 | "target": [ 28 | "21:9", 29 | "32:9" 30 | ], 31 | "plugin": "widescreen_skyui_fix.esp" 32 | } 33 | ] 34 | -------------------------------------------------------------------------------- /src/assets/fonts/averta-black.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/src/assets/fonts/averta-black.ttf -------------------------------------------------------------------------------- /src/assets/fonts/averta-blackitalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/src/assets/fonts/averta-blackitalic.ttf -------------------------------------------------------------------------------- /src/assets/fonts/averta-bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/src/assets/fonts/averta-bold.ttf -------------------------------------------------------------------------------- /src/assets/fonts/averta-bolditalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/src/assets/fonts/averta-bolditalic.ttf -------------------------------------------------------------------------------- /src/assets/fonts/averta-extrabold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/src/assets/fonts/averta-extrabold.ttf -------------------------------------------------------------------------------- /src/assets/fonts/averta-extrabolditalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/src/assets/fonts/averta-extrabolditalic.ttf -------------------------------------------------------------------------------- /src/assets/fonts/averta-extrathin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/src/assets/fonts/averta-extrathin.ttf -------------------------------------------------------------------------------- /src/assets/fonts/averta-extrathinitalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/src/assets/fonts/averta-extrathinitalic.ttf -------------------------------------------------------------------------------- /src/assets/fonts/averta-light.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/src/assets/fonts/averta-light.ttf -------------------------------------------------------------------------------- /src/assets/fonts/averta-lightitalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/src/assets/fonts/averta-lightitalic.ttf -------------------------------------------------------------------------------- /src/assets/fonts/averta-regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/src/assets/fonts/averta-regular.ttf -------------------------------------------------------------------------------- /src/assets/fonts/averta-regularitalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/src/assets/fonts/averta-regularitalic.ttf -------------------------------------------------------------------------------- /src/assets/fonts/averta-semibold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/src/assets/fonts/averta-semibold.ttf -------------------------------------------------------------------------------- /src/assets/fonts/averta-semibolditalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/src/assets/fonts/averta-semibolditalic.ttf -------------------------------------------------------------------------------- /src/assets/fonts/averta-thin.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/src/assets/fonts/averta-thin.ttf -------------------------------------------------------------------------------- /src/assets/fonts/averta-thinitalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/src/assets/fonts/averta-thinitalic.ttf -------------------------------------------------------------------------------- /src/assets/icons/MaterialIcons-Regular.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/src/assets/icons/MaterialIcons-Regular.woff2 -------------------------------------------------------------------------------- /src/assets/scss/index.scss: -------------------------------------------------------------------------------- 1 | // The order of these imports is important. 2 | // It is roughly following itcss style 3 | 4 | @import "./settings/colours"; 5 | @import "./settings/sizes"; 6 | @import "./settings/font"; 7 | @import "./settings/font-face"; 8 | 9 | @import "./layout"; 10 | 11 | @import "./utility"; 12 | 13 | @import "./popper"; 14 | -------------------------------------------------------------------------------- /src/assets/scss/layout.scss: -------------------------------------------------------------------------------- 1 | @import "~@/assets/scss/settings/sizes.scss"; 2 | 3 | // All layout items are prefaced with `l-` to separate them from others 4 | 5 | .l-flex { 6 | display: flex; 7 | flex: 1; 8 | } 9 | 10 | .l-no-flex-grow { 11 | flex: 0; 12 | } 13 | 14 | .l-column { 15 | @extend .l-flex; 16 | flex-direction: column; 17 | } 18 | 19 | .l-center { 20 | @extend .l-flex; 21 | 22 | justify-content: center; 23 | align-items: center; 24 | } 25 | 26 | .l-center-vertically { 27 | @extend .l-flex; 28 | 29 | flex-direction: row; 30 | align-items: center; 31 | } 32 | 33 | .l-row { 34 | @extend .l-flex; 35 | 36 | flex-direction: row; 37 | 38 | &--bottom { 39 | align-items: flex-end; 40 | } 41 | } 42 | 43 | .l-center-text { 44 | text-align: center; 45 | } 46 | 47 | .l-start { 48 | @extend .l-flex; 49 | 50 | justify-content: flex-start; 51 | } 52 | 53 | .l-end { 54 | @extend .l-flex; 55 | 56 | justify-content: flex-end; 57 | } 58 | 59 | .l-flex-grow { 60 | flex-grow: 1; 61 | } 62 | 63 | .l-space-around { 64 | @extend .l-flex; 65 | 66 | justify-content: space-around; 67 | } 68 | 69 | .l-space-between { 70 | @extend .l-flex; 71 | 72 | justify-content: space-between; 73 | } 74 | 75 | .l-spacing-right { 76 | margin-right: $size-spacing; 77 | } 78 | -------------------------------------------------------------------------------- /src/assets/scss/popper.scss: -------------------------------------------------------------------------------- 1 | @import "./layout"; 2 | @import "./settings/colours"; 3 | 4 | // All popper classes need important because once built, the order of the css is not consistent 5 | :deep(.popper) { 6 | padding: $size-spacing !important; 7 | color: white; 8 | } 9 | 10 | :deep(.popper), 11 | :deep(.popper:hover), 12 | :deep(.popper #arrow::before), 13 | :deep(.popper:hover > #arrow::before) { 14 | background-color: $colour-background--darker-solid !important; 15 | } 16 | -------------------------------------------------------------------------------- /src/assets/scss/settings/colours.scss: -------------------------------------------------------------------------------- 1 | $colour-primary: #6f00c6; 2 | $colour-primary--light: #8f00ff; 3 | $colour-secondary: #e1e1e1; 4 | $colour-alternate: #00bf6f; 5 | $colour-text: #ffffff; 6 | $colour-text--dark: darken($colour-text, 60%); 7 | $colour-text--secondary: #ffffff66; 8 | $color-warning: #980000; 9 | $color-warning--light: #fd1717; 10 | $colour-background: #e1e1e1; 11 | $colour-background-secondary: #000000; 12 | $colour-background--light: rgba(255, 255, 255, 0.2); 13 | $colour-background--light-solid: rgb(118, 118, 118); 14 | $colour-background--transparent: transparentize($colour-background, 0.8); 15 | $colour-background--dark: rgba(132, 132, 132, 0.2); 16 | $colour-background--darker: transparentize(#1f1f1f, 0.1); 17 | $colour-background--darker-solid: rgb(31, 31, 31); 18 | $colour-background-secondary--transparent: transparentize( 19 | $colour-background-secondary, 20 | 0.8 21 | ); 22 | 23 | $colour-star: #ffce00; 24 | 25 | $background-blur: blur(10px); 26 | $background-blur--more: blur(20px); 27 | -------------------------------------------------------------------------------- /src/assets/scss/settings/font-face.scss: -------------------------------------------------------------------------------- 1 | .material-icons { 2 | font-family: "Material Icons", serif; 3 | font-weight: normal; 4 | font-style: normal; 5 | font-size: 24px; /* Preferred icon size */ 6 | display: inline-block; 7 | line-height: 1; 8 | text-transform: none; 9 | letter-spacing: normal; 10 | word-wrap: normal; 11 | white-space: nowrap; 12 | direction: ltr; 13 | 14 | /* Support for all WebKit browsers. */ 15 | -webkit-font-smoothing: antialiased; 16 | } 17 | 18 | @font-face { 19 | font-family: "Material Icons"; 20 | font-style: normal; 21 | font-weight: 400; 22 | src: url(~@/assets/icons/MaterialIcons-Regular.woff2); 23 | } 24 | 25 | html { 26 | font-family: "averta", serif; 27 | } 28 | 29 | @font-face { 30 | font-family: "averta"; 31 | font-style: normal; 32 | font-weight: 100 199; 33 | src: url(~@/assets/fonts/averta-extrathin.ttf); 34 | } 35 | 36 | @font-face { 37 | font-family: "averta"; 38 | font-style: italic; 39 | font-weight: 100 199; 40 | src: url(~@/assets/fonts/averta-extrathinitalic.ttf); 41 | } 42 | 43 | @font-face { 44 | font-family: "averta"; 45 | font-style: normal; 46 | font-weight: 200 299; 47 | src: url(~@/assets/fonts/averta-thin.ttf); 48 | } 49 | 50 | @font-face { 51 | font-family: "averta"; 52 | font-style: italic; 53 | font-weight: 200 299; 54 | src: url(~@/assets/fonts/averta-thinitalic.ttf); 55 | } 56 | 57 | @font-face { 58 | font-family: "averta"; 59 | font-style: normal; 60 | font-weight: 300 399; 61 | src: url(~@/assets/fonts/averta-light.ttf); 62 | } 63 | 64 | @font-face { 65 | font-family: "averta"; 66 | font-style: italic; 67 | font-weight: 300 399; 68 | src: url(~@/assets/fonts/averta-lightitalic.ttf); 69 | } 70 | 71 | @font-face { 72 | font-family: "averta"; 73 | font-style: normal; 74 | font-weight: 400 599; 75 | src: url(~@/assets/fonts/averta-regular.ttf); 76 | } 77 | 78 | @font-face { 79 | font-family: "averta"; 80 | font-style: italic; 81 | font-weight: 400 599; 82 | src: url(~@/assets/fonts/averta-regularitalic.ttf); 83 | } 84 | 85 | @font-face { 86 | font-family: "averta"; 87 | font-style: normal; 88 | font-weight: 600 699; 89 | src: url(~@/assets/fonts/averta-semibold.ttf); 90 | } 91 | 92 | @font-face { 93 | font-family: "averta"; 94 | font-style: italic; 95 | font-weight: 600 699; 96 | src: url(~@/assets/fonts/averta-semibolditalic.ttf); 97 | } 98 | 99 | @font-face { 100 | font-family: "averta"; 101 | font-style: normal; 102 | font-weight: 700 799; 103 | src: url(~@/assets/fonts/averta-bold.ttf); 104 | } 105 | 106 | @font-face { 107 | font-family: "averta"; 108 | font-style: italic; 109 | font-weight: 700 799; 110 | src: url(~@/assets/fonts/averta-bolditalic.ttf); 111 | } 112 | 113 | @font-face { 114 | font-family: "averta"; 115 | font-style: normal; 116 | font-weight: 800 899; 117 | src: url(~@/assets/fonts/averta-extrabold.ttf); 118 | } 119 | 120 | @font-face { 121 | font-family: "averta"; 122 | font-style: italic; 123 | font-weight: 800 899; 124 | src: url(~@/assets/fonts/averta-extrabolditalic.ttf); 125 | } 126 | 127 | @font-face { 128 | font-family: "averta"; 129 | font-style: normal; 130 | font-weight: 900 1000; 131 | src: url(~@/assets/fonts/averta-black.ttf); 132 | } 133 | 134 | @font-face { 135 | font-family: "averta"; 136 | font-style: italic; 137 | font-weight: 900 1000; 138 | src: url(~@/assets/fonts/averta-blackitalic.ttf); 139 | } 140 | -------------------------------------------------------------------------------- /src/assets/scss/settings/font.scss: -------------------------------------------------------------------------------- 1 | $font-size--x-small: 6px; 2 | $font-size--small: 10px; 3 | $font-size: 12px; 4 | $font-size--large: 14px; 5 | $font-size--x-large: 18px; 6 | 7 | $font-weight--small: 300; 8 | $font-weight: 400; 9 | $font-weight--large: 700; 10 | $font-weight--x-large: 700; 11 | 12 | $line-height--small: 12px; 13 | $line-height: 20px; 14 | $line-height--large: 22px; 15 | $line-height--x-large: 30px; 16 | 17 | $line-height__body: $line-height; 18 | $font-size__body: $font-size; 19 | 20 | $font-size-subtitle-alt: $font-size--x-large; 21 | $lint-height-subtitle-alt: $line-height--large; 22 | -------------------------------------------------------------------------------- /src/assets/scss/settings/sizes.scss: -------------------------------------------------------------------------------- 1 | $size-window-height: 580px; 2 | $size-window-width: 1000px; 3 | 4 | $size-spacing--x-large: 30px; 5 | $size-spacing--large: 15px; 6 | $size-spacing: 10px; 7 | $size-spacing--small: 7px; 8 | $size-spacing--x-small: 5px; 9 | $size-spacing--titlebar: 24px; 10 | 11 | $size-action-height: 30px; 12 | -------------------------------------------------------------------------------- /src/assets/scss/utility.scss: -------------------------------------------------------------------------------- 1 | @import "./settings/colours"; 2 | @import "./settings/sizes"; 3 | @import "./settings/font"; 4 | 5 | // Utility classes not tied to a component 6 | 7 | .u-spacing { 8 | padding: $size-spacing; 9 | } 10 | 11 | .u-list--bare { 12 | list-style: none; 13 | margin: 0; 14 | padding: 0; 15 | } 16 | 17 | .u-scroll-x { 18 | overflow-x: scroll; 19 | } 20 | 21 | .u-scroll-y { 22 | overflow-y: scroll; 23 | } 24 | 25 | .u-scroll-y-auto { 26 | overflow-y: auto; 27 | } 28 | 29 | .u-small-scrollbar { 30 | &::-webkit-scrollbar { 31 | width: 2px; 32 | background-color: transparent; 33 | } 34 | 35 | &::-webkit-scrollbar-thumb { 36 | background-color: $colour-text--secondary; 37 | border-radius: 1px; 38 | } 39 | 40 | /* Buttons */ 41 | &::-webkit-scrollbar-button:single-button { 42 | height: 0; 43 | width: 0; 44 | } 45 | } 46 | 47 | .u-text { 48 | box-sizing: border-box; 49 | font-weight: $font-weight--small; 50 | size: $font-size--large; 51 | margin: 0; 52 | } 53 | 54 | .u-disable-click-events { 55 | pointer-events: none; 56 | user-select: none; 57 | } 58 | -------------------------------------------------------------------------------- /src/assets/tools/QRes.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Wildlander-mod/Launcher/049f2bf2530826f7a62162f41b3149418fd4e259/src/assets/tools/QRes.exe -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import { app, protocol } from "electron"; 2 | import { isDevelopment } from "./main/services/config.service"; 3 | import { autoUpdater } from "electron-updater"; 4 | import { LauncherApplication } from "@/main/application"; 5 | import { logger } from "@/main/logger"; 6 | import { ErrorService } from "@/main/services/error.service"; 7 | import { WindowService } from "@/main/services/window.service"; 8 | 9 | const isSingleInstance = app.requestSingleInstanceLock(); 10 | if (!isSingleInstance) { 11 | app.quit(); 12 | } 13 | 14 | // Ensure it's easy to tell where the logs for this application start 15 | const initialLog = `| ${new Date().toLocaleString()} |`; 16 | logger.debug("-".repeat(initialLog.length)); 17 | logger.debug(initialLog); 18 | logger.debug("-".repeat(initialLog.length)); 19 | autoUpdater.logger = require("electron-log"); 20 | 21 | // Scheme must be registered before the app is ready 22 | protocol.registerSchemesAsPrivileged([ 23 | { scheme: "app", privileges: { secure: true, standard: true } }, 24 | ]); 25 | 26 | // Exit cleanly on request from parent process in development mode. 27 | if (isDevelopment) { 28 | if (process.platform === "win32") { 29 | process.on("message", (data) => { 30 | if (data === "graceful-exit") { 31 | app.quit(); 32 | } 33 | }); 34 | } else { 35 | process.on("SIGTERM", () => { 36 | app.quit(); 37 | }); 38 | } 39 | } 40 | 41 | const start = async () => { 42 | const launcherApplication = new LauncherApplication(); 43 | await launcherApplication.boot(); 44 | await launcherApplication.start(); 45 | 46 | app.on("second-instance", async () => { 47 | const windowService = await launcherApplication.getServiceByClass( 48 | WindowService 49 | ); 50 | // Someone tried to run a second instance, so focus the original window. 51 | windowService.focusWindow(); 52 | }); 53 | }; 54 | 55 | // This method will be called when Electron has finished 56 | // initialization and is ready to create browser windows. 57 | // Some APIs can only be used after this event occurs. 58 | app.on("ready", async () => { 59 | try { 60 | await start(); 61 | } catch (error) { 62 | const errorService = new ErrorService(); 63 | await errorService.handleError( 64 | "Failed to start application", 65 | (error as Error).message 66 | ); 67 | process.exit(1); 68 | } 69 | 70 | logger.debug("App started"); 71 | }); 72 | -------------------------------------------------------------------------------- /src/main/application.ts: -------------------------------------------------------------------------------- 1 | import { StartupService } from "@/main/services/startup.service"; 2 | import { Constructor } from "@loopback/context"; 3 | import { WindowService } from "@/main/services/window.service"; 4 | import { logger } from "@/main/logger"; 5 | import { BootMixin } from "@loopback/boot"; 6 | import { Application } from "@loopback/core"; 7 | import { Controller } from "@/main/decorators/controller.decorator"; 8 | import { ErrorService } from "@/main/services/error.service"; 9 | 10 | const serviceNamespace = "services"; 11 | 12 | export class LauncherApplication extends BootMixin(Application) { 13 | constructor() { 14 | super(); 15 | 16 | this.projectRoot = __dirname; 17 | 18 | this.onStart(async () => { 19 | try { 20 | await this.registerHandlers(); 21 | 22 | const startupService = await this.getServiceByClass(StartupService); 23 | startupService.registerStartupCommands(); 24 | await startupService.runStartup(); 25 | 26 | await this.startBrowser(); 27 | } catch (error) { 28 | const errorService = await this.getServiceByClass(ErrorService); 29 | await errorService.handleError( 30 | "Failed to start application", 31 | (error as Error).message 32 | ); 33 | process.exit(1); 34 | } 35 | }); 36 | } 37 | 38 | public getServiceByClass(cls: Constructor): Promise { 39 | return this.get(`${serviceNamespace}.${cls.name}`); 40 | } 41 | 42 | private async registerHandlers() { 43 | logger.silly("Registering handlers"); 44 | 45 | for (const controllerBinding of this.findByTag("controller")) { 46 | (await this.get(controllerBinding.key)).registerHandlers(); 47 | } 48 | 49 | logger.silly("Registered handlers"); 50 | } 51 | 52 | private async startBrowser() { 53 | const renderService = await this.getServiceByClass(WindowService); 54 | await renderService.createBrowserWindow(); 55 | await renderService.load("/"); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/main/controllers/config/config.controller.ts: -------------------------------------------------------------------------------- 1 | import { controller, handle } from "@/main/decorators/controller.decorator"; 2 | import { service } from "@loopback/core"; 3 | import { CONFIG_EVENTS } from "@/main/controllers/config/config.events"; 4 | import { ConfigService } from "@/main/services/config.service"; 5 | 6 | @controller 7 | export class ConfigController { 8 | constructor(@service(ConfigService) private configService: ConfigService) {} 9 | 10 | @handle(CONFIG_EVENTS.EDIT_CONFIG) 11 | editConfig() { 12 | this.configService.editPreferences(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/controllers/config/config.events.ts: -------------------------------------------------------------------------------- 1 | export const enum CONFIG_EVENTS { 2 | EDIT_CONFIG = "EDIT_CONFIG", 3 | } 4 | -------------------------------------------------------------------------------- /src/main/controllers/dialog/dialog.controller.ts: -------------------------------------------------------------------------------- 1 | import { controller, handle } from "@/main/decorators/controller.decorator"; 2 | import { DIALOG_EVENTS } from "@/main/controllers/dialog/dialog.events"; 3 | import { service } from "@loopback/core"; 4 | import { ErrorService } from "@/main/services/error.service"; 5 | import { dialog } from "electron"; 6 | 7 | @controller 8 | export class DialogController { 9 | constructor(@service(ErrorService) private errorService: ErrorService) {} 10 | 11 | @handle(DIALOG_EVENTS.ERROR) 12 | async error({ title, error }: { title: string; error: string }) { 13 | await this.errorService.handleError(title, error); 14 | } 15 | 16 | @handle(DIALOG_EVENTS.CONFIRMATION) 17 | async confirmation({ 18 | message, 19 | buttons, 20 | }: { 21 | message: string; 22 | buttons: string[]; 23 | }) { 24 | return await dialog.showMessageBox({ message, buttons }); 25 | } 26 | 27 | @handle(DIALOG_EVENTS.DIRECTORY_SELECT) 28 | directorySelect() { 29 | return dialog.showOpenDialog({ properties: ["openDirectory"] }); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/controllers/dialog/dialog.events.ts: -------------------------------------------------------------------------------- 1 | export const enum DIALOG_EVENTS { 2 | ERROR = "ERROR", 3 | CONFIRMATION = "CONFIRMATION", 4 | DIRECTORY_SELECT = "DIRECTORY_SELECT", 5 | } 6 | -------------------------------------------------------------------------------- /src/main/controllers/enb/enb.controller.ts: -------------------------------------------------------------------------------- 1 | import { controller, handle } from "@/main/decorators/controller.decorator"; 2 | import { ENB_EVENTS } from "@/main/controllers/enb/enb.events"; 3 | import { EnbService } from "@/main/services/enb.service"; 4 | import { service } from "@loopback/core"; 5 | import { FriendlyDirectoryMap } from "@/modpack-metadata"; 6 | 7 | @controller 8 | export class EnbController { 9 | constructor(@service(EnbService) private enbService: EnbService) {} 10 | 11 | @handle(ENB_EVENTS.GET_ENB_PRESETS) 12 | getEnbPresets(): Promise { 13 | return this.enbService.getENBPresets(); 14 | } 15 | 16 | @handle(ENB_EVENTS.RESTORE_ENB_PRESETS) 17 | async restoreEnbPresets(): Promise { 18 | await this.enbService.restoreENBPresets(); 19 | } 20 | 21 | @handle(ENB_EVENTS.GET_ENB_PREFERENCE) 22 | async getEnbPreference(): Promise { 23 | return await this.enbService.getEnbPreference(); 24 | } 25 | 26 | @handle(ENB_EVENTS.SET_ENB_PREFERENCE) 27 | async setEnbPreference(enb: unknown): Promise { 28 | if (typeof enb !== "string") { 29 | throw new Error(`ENB is not a string: ${enb}`); 30 | } 31 | await this.enbService.setEnb(enb); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/controllers/enb/enb.events.ts: -------------------------------------------------------------------------------- 1 | export const enum ENB_EVENTS { 2 | GET_ENB_PRESETS = "GET_ENB_PRESETS", 3 | RESTORE_ENB_PRESETS = "RESTORE_ENB_PRESETS", 4 | 5 | GET_ENB_PREFERENCE = "GET_ENB_PREFERENCE", 6 | SET_ENB_PREFERENCE = "SET_ENB_PREFERENCE", 7 | } 8 | -------------------------------------------------------------------------------- /src/main/controllers/graphics/graphics.controller.ts: -------------------------------------------------------------------------------- 1 | import { controller, handle } from "@/main/decorators/controller.decorator"; 2 | import { GRAPHICS_EVENTS } from "@/main/controllers/graphics/graphics.events"; 3 | import { GraphicsService } from "@/main/services/graphics.service"; 4 | import { service } from "@loopback/core"; 5 | 6 | @controller 7 | export class GraphicsController { 8 | constructor( 9 | @service(GraphicsService) private graphicsService: GraphicsService 10 | ) {} 11 | 12 | @handle(GRAPHICS_EVENTS.GET_GRAPHICS_PREFERENCE) 13 | getGraphicsPreference() { 14 | return this.graphicsService.getGraphicsPreference(); 15 | } 16 | 17 | @handle(GRAPHICS_EVENTS.GET_GRAPHICS) 18 | getGraphics() { 19 | return this.graphicsService.getGraphics(); 20 | } 21 | 22 | @handle(GRAPHICS_EVENTS.SET_GRAPHICS) 23 | setGraphics(graphics: string) { 24 | return this.graphicsService.setGraphics(graphics); 25 | } 26 | 27 | @handle(GRAPHICS_EVENTS.RESTORE_GRAPHICS) 28 | restoreGraphics() { 29 | return this.graphicsService.restoreGraphics(); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/controllers/graphics/graphics.events.ts: -------------------------------------------------------------------------------- 1 | export const enum GRAPHICS_EVENTS { 2 | GET_GRAPHICS_PREFERENCE = "GET_GRAPHICS_PREFERENCE", 3 | GET_GRAPHICS = "GET_GRAPHICS", 4 | SET_GRAPHICS = "SET_GRAPHICS", 5 | RESTORE_GRAPHICS = "RESTORE_GRAPHICS", 6 | } 7 | -------------------------------------------------------------------------------- /src/main/controllers/launcher/launcher.controller.ts: -------------------------------------------------------------------------------- 1 | import { controller, handle } from "@/main/decorators/controller.decorator"; 2 | import { LAUNCHER_EVENTS } from "@/main/controllers/launcher/launcher.events"; 3 | import { service } from "@loopback/core"; 4 | import { LauncherService } from "@/main/services/launcher.service"; 5 | 6 | @controller 7 | export class LauncherController { 8 | constructor( 9 | @service(LauncherService) private launcherService: LauncherService 10 | ) {} 11 | 12 | @handle(LAUNCHER_EVENTS.GET_VERSION) 13 | getVersion() { 14 | return this.launcherService.getVersion(); 15 | } 16 | 17 | @handle(LAUNCHER_EVENTS.SET_CHECK_PREREQUISITES) 18 | setCheckPrerequisites(value: boolean) { 19 | return this.launcherService.setCheckPrerequisites(value); 20 | } 21 | 22 | @handle(LAUNCHER_EVENTS.GET_CHECK_PREREQUISITES) 23 | getCheckPrerequisites() { 24 | return this.launcherService.getCheckPrerequisites(); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/main/controllers/launcher/launcher.events.ts: -------------------------------------------------------------------------------- 1 | export const enum LAUNCHER_EVENTS { 2 | GET_VERSION = " GET_VERSION", 3 | 4 | SET_CHECK_PREREQUISITES = "SET_CHECK_PREREQUISITES", 5 | 6 | GET_CHECK_PREREQUISITES = "GET_CHECK_PREREQUISITES", 7 | } 8 | -------------------------------------------------------------------------------- /src/main/controllers/modOrganizer/modOrganizer.controller.ts: -------------------------------------------------------------------------------- 1 | import { controller, handle } from "@/main/decorators/controller.decorator"; 2 | import { MOD_ORGANIZER_EVENTS } from "@/main/controllers/modOrganizer/modOrganizer.events"; 3 | import { ModOrganizerService } from "@/main/services/modOrganizer.service"; 4 | import { service } from "@loopback/core"; 5 | 6 | @controller 7 | export class ModOrganizerController { 8 | constructor( 9 | @service(ModOrganizerService) 10 | private modOrganizerService: ModOrganizerService 11 | ) {} 12 | 13 | @handle(MOD_ORGANIZER_EVENTS.LAUNCH_MO2) 14 | async launchMO2() { 15 | await this.modOrganizerService.launchMO2(); 16 | } 17 | 18 | @handle(MOD_ORGANIZER_EVENTS.LAUNCH_GAME) 19 | async launchGame() { 20 | await this.modOrganizerService.launchGame(); 21 | } 22 | 23 | @handle(MOD_ORGANIZER_EVENTS.CLOSE_MO2) 24 | async closeMO2() { 25 | await this.modOrganizerService.closeMO2(); 26 | } 27 | 28 | @handle(MOD_ORGANIZER_EVENTS.IS_MO2_RUNNING) 29 | async isMO2Running(): Promise { 30 | return this.modOrganizerService.isRunning(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/controllers/modOrganizer/modOrganizer.events.ts: -------------------------------------------------------------------------------- 1 | export const enum MOD_ORGANIZER_EVENTS { 2 | LAUNCH_GAME = "LAUNCH_GAME", 3 | LAUNCH_MO2 = "LAUNCH_MO2", 4 | CLOSE_MO2 = "CLOSE_MO2", 5 | 6 | IS_MO2_RUNNING = "IS_MO2_RUNNING", 7 | } 8 | -------------------------------------------------------------------------------- /src/main/controllers/modpack/modpack.controller.ts: -------------------------------------------------------------------------------- 1 | import { controller, handle } from "@/main/decorators/controller.decorator"; 2 | import { 3 | IsModpackValidResponse, 4 | MODPACK_EVENTS, 5 | } from "@/main/controllers/modpack/mopack.events"; 6 | import { service } from "@loopback/core"; 7 | import { ModpackService } from "@/main/services/modpack.service"; 8 | import { logger } from "@/main/logger"; 9 | import { LauncherService } from "@/main/services/launcher.service"; 10 | 11 | @controller 12 | export class ModpackController { 13 | constructor( 14 | @service(ModpackService) private modpackService: ModpackService, 15 | @service(LauncherService) private launcherService: LauncherService 16 | ) {} 17 | 18 | @handle(MODPACK_EVENTS.IS_MODPACK_SET) 19 | isModpackSet() { 20 | return this.modpackService.isModpackSet(); 21 | } 22 | 23 | @handle(MODPACK_EVENTS.IS_MODPACK_DIRECTORY_VALID) 24 | isValidModDirectory(filepath: string): IsModpackValidResponse { 25 | if (!filepath) { 26 | logger.warn("No filepath specified when checking mod directory"); 27 | return { ok: false }; 28 | } 29 | return this.modpackService.checkModpackPathIsValid(filepath); 30 | } 31 | 32 | @handle(MODPACK_EVENTS.SET_MODPACK) 33 | setModpack(filepath: string) { 34 | if (!filepath) { 35 | logger.error("No filepath specified when setting mod directory"); 36 | } 37 | return this.launcherService.setModpack(filepath); 38 | } 39 | 40 | @handle(MODPACK_EVENTS.GET_MODPACK) 41 | getModpack() { 42 | return this.modpackService.getModpackDirectory(); 43 | } 44 | 45 | @handle(MODPACK_EVENTS.GET_MODPACK_METADATA) 46 | getModpackMetadata() { 47 | return this.modpackService.getModpackMetadata(); 48 | } 49 | 50 | @handle(MODPACK_EVENTS.DELETE_MODPACK_DIRECTORY) 51 | deleteModpackDirectory() { 52 | return this.modpackService.deleteModpackDirectory(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/controllers/modpack/mopack.events.ts: -------------------------------------------------------------------------------- 1 | export const enum MODPACK_EVENTS { 2 | IS_MODPACK_SET = "IS_MODPACK_SET", 3 | IS_MODPACK_DIRECTORY_VALID = "IS_MODPACK_DIRECTORY_VALID", 4 | SET_MODPACK = "SET_MODPACK", 5 | GET_MODPACK = "GET_MODPACK", 6 | GET_MODPACK_METADATA = "GET_MODPACK_METADATA", 7 | DELETE_MODPACK_DIRECTORY = "DELETE_MODPACK_DIRECTORY", 8 | } 9 | 10 | export interface IsModpackValidResponse { 11 | ok: boolean; 12 | missingPaths?: string[]; 13 | } 14 | -------------------------------------------------------------------------------- /src/main/controllers/profile/profile.controller.ts: -------------------------------------------------------------------------------- 1 | import { controller, handle } from "@/main/decorators/controller.decorator"; 2 | import { ProfileService } from "@/main/services/profile.service"; 3 | import { service } from "@loopback/core"; 4 | import { PROFILE_EVENTS } from "@/main/controllers/profile/profile.events"; 5 | import { LauncherService } from "@/main/services/launcher.service"; 6 | 7 | @controller 8 | export class ProfileController { 9 | constructor( 10 | @service(ProfileService) private profileService: ProfileService, 11 | @service(LauncherService) private launcherService: LauncherService 12 | ) {} 13 | 14 | @handle(PROFILE_EVENTS.GET_PROFILES) 15 | getProfiles() { 16 | return this.profileService.getProfiles(); 17 | } 18 | 19 | @handle(PROFILE_EVENTS.GET_PROFILE_PREFERENCE) 20 | getProfilePreference() { 21 | return this.profileService.getProfilePreference(); 22 | } 23 | 24 | @handle(PROFILE_EVENTS.SET_PROFILE_PREFERENCE) 25 | setProfilePreference(profile: string) { 26 | return this.profileService.setProfilePreference(profile); 27 | } 28 | 29 | @handle(PROFILE_EVENTS.RESTORE_PROFILES) 30 | async restoreProfiles() { 31 | await this.profileService.restoreProfiles(); 32 | await this.launcherService.refreshModpack(); 33 | } 34 | 35 | @handle(PROFILE_EVENTS.GET_SHOW_HIDDEN_PROFILES) 36 | async getShowHiddenProfiles() { 37 | return this.profileService.getShowHiddenProfiles(); 38 | } 39 | 40 | @handle(PROFILE_EVENTS.SET_SHOW_HIDDEN_PROFILES) 41 | async setShowHiddenProfiles(show: boolean) { 42 | return this.profileService.setShowHiddenProfiles(show); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/controllers/profile/profile.events.ts: -------------------------------------------------------------------------------- 1 | export const enum PROFILE_EVENTS { 2 | GET_PROFILES = "GET_PROFILES", 3 | GET_PROFILE_PREFERENCE = "GET_PROFILE_PREFERENCE", 4 | SET_PROFILE_PREFERENCE = "SET_PROFILE_PREFERENCE", 5 | RESTORE_PROFILES = "RESTORE_PROFILES", 6 | GET_SHOW_HIDDEN_PROFILES = "GET_SHOW_HIDDEN_PROFILES", 7 | SET_SHOW_HIDDEN_PROFILES = "SET_SHOW_HIDDEN_PROFILES", 8 | } 9 | -------------------------------------------------------------------------------- /src/main/controllers/resolution/resolution.controller.ts: -------------------------------------------------------------------------------- 1 | import { controller, handle } from "@/main/decorators/controller.decorator"; 2 | import { RESOLUTION_EVENTS } from "@/main/controllers/resolution/resolution.events"; 3 | import { ResolutionService } from "@/main/services/resolution.service"; 4 | import { service } from "@loopback/core"; 5 | import { Resolution } from "@/Resolution"; 6 | 7 | @controller 8 | export class ResolutionController { 9 | constructor( 10 | @service(ResolutionService) private resolutionService: ResolutionService 11 | ) {} 12 | 13 | @handle(RESOLUTION_EVENTS.GET_RESOLUTION_PREFERENCE) 14 | getResolutionPreference() { 15 | return this.resolutionService.getResolutionPreference(); 16 | } 17 | 18 | @handle(RESOLUTION_EVENTS.SET_RESOLUTION_PREFERENCE) 19 | setResolutionPreference(resolution: Resolution) { 20 | return this.resolutionService.setResolution(resolution); 21 | } 22 | 23 | @handle(RESOLUTION_EVENTS.GET_RESOLUTIONS) 24 | getResolutions() { 25 | return this.resolutionService.getResolutions(); 26 | } 27 | 28 | @handle(RESOLUTION_EVENTS.IS_UNSUPPORTED_RESOLUTION) 29 | isUnsupportedResolution(resolution: Resolution) { 30 | return this.resolutionService.isUnsupportedResolution(resolution); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/main/controllers/resolution/resolution.events.ts: -------------------------------------------------------------------------------- 1 | export const enum RESOLUTION_EVENTS { 2 | GET_RESOLUTIONS = "GET_RESOLUTIONS", 3 | IS_UNSUPPORTED_RESOLUTION = "IS_UNSUPPORTED_RESOLUTION", 4 | GET_RESOLUTION_PREFERENCE = "GET_RESOLUTION_PREFERENCE", 5 | SET_RESOLUTION_PREFERENCE = "SET_RESOLUTION_PREFERENCE", 6 | } 7 | -------------------------------------------------------------------------------- /src/main/controllers/system/system.controller.ts: -------------------------------------------------------------------------------- 1 | import { controller, handle } from "@/main/decorators/controller.decorator"; 2 | import { service } from "@loopback/core"; 3 | import { SystemService } from "@/main/services/system.service"; 4 | import { SYSTEM_EVENTS } from "@/main/controllers/system/system.events"; 5 | import { shell } from "electron"; 6 | import { ErrorService } from "@/main/services/error.service"; 7 | 8 | @controller 9 | export class SystemController { 10 | constructor( 11 | @service(SystemService) private systemService: SystemService, 12 | @service(ErrorService) private errorService: ErrorService 13 | ) {} 14 | 15 | @handle(SYSTEM_EVENTS.OPEN_CRASH_LOGS) 16 | async openCrashLogs() { 17 | await this.systemService.openCrashLogs(); 18 | } 19 | 20 | @handle(SYSTEM_EVENTS.OPEN_APPLICATION_LOGS) 21 | async openApplicationLogs() { 22 | await this.systemService.openApplicationLogsPath(); 23 | } 24 | 25 | @handle(SYSTEM_EVENTS.OPEN_LINK_IN_BROWSER) 26 | async openLinkInBrowser(link: string) { 27 | try { 28 | await shell.openExternal(link); 29 | } catch (error) { 30 | await this.errorService.handleError( 31 | "Error opening link", 32 | (error as Error).message 33 | ); 34 | } 35 | } 36 | 37 | @handle(SYSTEM_EVENTS.CLEAR_APP_LOGS) 38 | public async clearLogFiles() { 39 | await this.systemService.clearApplicationLogs(); 40 | } 41 | 42 | @handle(SYSTEM_EVENTS.CHECK_PREREQUISITES) 43 | async checkPrerequisite() { 44 | return await this.systemService.checkPrerequisitesInstalled(); 45 | } 46 | 47 | @handle(SYSTEM_EVENTS.INSTALL_PREREQUISITES) 48 | async installPrerequisites() { 49 | return await this.systemService.installPrerequisites(); 50 | } 51 | 52 | @handle(SYSTEM_EVENTS.REBOOT) 53 | reboot() { 54 | this.systemService.reboot(); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/main/controllers/system/system.events.ts: -------------------------------------------------------------------------------- 1 | export const enum SYSTEM_EVENTS { 2 | OPEN_CRASH_LOGS = "OPEN_CRASH_LOGS", 3 | OPEN_APPLICATION_LOGS = "OPEN_APPLICATION_LOGS", 4 | OPEN_LINK_IN_BROWSER = "OPEN_LINK_IN_BROWSER", 5 | CLEAR_APP_LOGS = "CLEAR_APP_LOGS", 6 | CHECK_PREREQUISITES = "CHECK_PREREQUISITES", 7 | INSTALL_PREREQUISITES = "INSTALL_PREREQUISITES", 8 | REBOOT = "REBOOT", 9 | } 10 | -------------------------------------------------------------------------------- /src/main/controllers/update/update.events.ts: -------------------------------------------------------------------------------- 1 | export const enum UPDATE_EVENTS { 2 | UPDATE_AVAILABLE = "update-available", 3 | UPDATE_NOT_AVAILABLE = "update-not-available", 4 | UPDATE_DOWNLOADED = "update-downloaded", 5 | DOWNLOAD_PROGRESS = "download-progress", 6 | ERROR = "error", 7 | } 8 | -------------------------------------------------------------------------------- /src/main/controllers/wabbajack/wabbajack.controller.ts: -------------------------------------------------------------------------------- 1 | import { controller, handle } from "@/main/decorators/controller.decorator"; 2 | import { WABBAJACK_EVENTS } from "@/main/controllers/wabbajack/wabbajack.events"; 3 | import { service } from "@loopback/core"; 4 | import { WabbajackService } from "@/main/services/wabbajack.service"; 5 | 6 | @controller 7 | export class WabbajackController { 8 | constructor( 9 | @service(WabbajackService) private wabbajackService: WabbajackService 10 | ) {} 11 | 12 | @handle(WABBAJACK_EVENTS.GET_INSTALLED_MODPACKS) 13 | async getInstalledModpacks() { 14 | return this.wabbajackService.getInstalledCurrentModpackPaths(); 15 | } 16 | 17 | @handle(WABBAJACK_EVENTS.GET_MODPACK_VERSION) 18 | async getVersion() { 19 | return this.wabbajackService.getModpackVersion(); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/main/controllers/wabbajack/wabbajack.events.ts: -------------------------------------------------------------------------------- 1 | export const enum WABBAJACK_EVENTS { 2 | GET_INSTALLED_MODPACKS = "GET_INSTALLED_MODPACKS", 3 | GET_MODPACK_VERSION = "GET_MODPACK_VERSION", 4 | } 5 | -------------------------------------------------------------------------------- /src/main/controllers/window/window.controller.ts: -------------------------------------------------------------------------------- 1 | import { controller, handle } from "@/main/decorators/controller.decorator"; 2 | import { WindowService } from "@/main/services/window.service"; 3 | import { service } from "@loopback/core"; 4 | import { WindowEvents } from "@/main/controllers/window/window.events"; 5 | import { ConfigService } from "@/main/services/config.service"; 6 | 7 | @controller 8 | export class WindowController { 9 | constructor( 10 | @service(WindowService) private renderService: WindowService, 11 | @service(ConfigService) private configService: ConfigService 12 | ) {} 13 | 14 | @handle(WindowEvents.CLOSE) 15 | quit() { 16 | this.renderService.quit(); 17 | } 18 | 19 | @handle(WindowEvents.RELOAD) 20 | reload() { 21 | this.renderService.reload(); 22 | } 23 | 24 | @handle(WindowEvents.MINIMIZE) 25 | minimize() { 26 | this.renderService.minimize(); 27 | } 28 | 29 | @handle(WindowEvents.OPEN_LOG_PATH) 30 | async openLogPath() { 31 | await this.configService; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/controllers/window/window.events.ts: -------------------------------------------------------------------------------- 1 | export const enum WindowEvents { 2 | CLOSE = "CLOSE", 3 | RELOAD = "RELOAD", 4 | MINIMIZE = "MINIMIZE", 5 | OPEN_LOG_PATH = "OPEN_LOG_PATH", 6 | } 7 | -------------------------------------------------------------------------------- /src/main/decorators/controller.decorator.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain, IpcMainInvokeEvent } from "electron"; 2 | import { logger } from "@/main/logger"; 3 | import { Constructor } from "@loopback/context"; 4 | 5 | export interface Controller { 6 | registerHandlers: () => void; 7 | } 8 | 9 | const Handlers = "Handlers"; 10 | 11 | /** 12 | Add a method to controllers that can be called to register all IPC handlers. 13 | This is then automatically be called by the booter. 14 | e.g. 15 | @controller 16 | class ExampleController { 17 | 18 | @handler("channelName") 19 | method(){ 20 | //Do something when 'channelName' event is invoked... 21 | } 22 | 23 | } 24 | */ 25 | export function controller>(Base: T) { 26 | return { 27 | [Base.name]: class extends Base implements Controller { 28 | public registerHandlers() { 29 | const handlers = Base.prototype[Handlers]; 30 | handlers.forEach( 31 | (method: (...args: unknown[]) => unknown, channel: string) => { 32 | logger.silly(`Registered handler "${channel}"`); 33 | ipcMain.handle( 34 | channel, 35 | (event: IpcMainInvokeEvent, ...args: unknown[]) => 36 | method.apply(this, [...args, event]) 37 | ); 38 | } 39 | ); 40 | } 41 | }, 42 | }[Base.name]; 43 | } 44 | 45 | export function handle(channel: string) { 46 | return function ( 47 | target: Record, 48 | propertyKey: string, 49 | propertyDescriptor: PropertyDescriptor 50 | ) { 51 | target[Handlers] = target[Handlers] || new Map(); 52 | target[Handlers].set(channel, propertyDescriptor.value); 53 | }; 54 | } 55 | -------------------------------------------------------------------------------- /src/main/logger.ts: -------------------------------------------------------------------------------- 1 | import log from "electron-log"; 2 | 3 | // Export from here in case we want to write any wrappers around the methods or change logger 4 | export const logger = log; 5 | -------------------------------------------------------------------------------- /src/main/preload.ts: -------------------------------------------------------------------------------- 1 | import { contextBridge, ipcRenderer } from "electron"; 2 | import log from "electron-log"; 3 | 4 | // Expose protected methods that allow the renderer process to use 5 | // the ipcRenderer without exposing the entire object 6 | contextBridge.exposeInMainWorld("ipcRenderer", { 7 | invoke: (channel: string, data: unknown) => { 8 | return ipcRenderer.invoke(channel, data); 9 | }, 10 | on: (channel: string, callback: Function) => { 11 | // Deliberately strip event as it includes `sender` 12 | ipcRenderer.on(channel, (event, ...args) => callback(...args)); 13 | }, 14 | }); 15 | 16 | contextBridge.exposeInMainWorld("logger", log.functions); 17 | -------------------------------------------------------------------------------- /src/main/services/blacklist.service.ts: -------------------------------------------------------------------------------- 1 | import { BindingScope, injectable } from "@loopback/context"; 2 | import { service } from "@loopback/core"; 3 | import { SystemService } from "@/main/services/system.service"; 4 | 5 | export interface BlacklistedProgram { 6 | name: string; 7 | processName: string; 8 | } 9 | 10 | @injectable({ 11 | scope: BindingScope.SINGLETON, 12 | }) 13 | export class BlacklistService { 14 | constructor(@service(SystemService) private systemService: SystemService) {} 15 | 16 | getBlacklistedPrograms(): BlacklistedProgram[] { 17 | return [ 18 | // Webroot and Norton cause issues because the move some modpack files 19 | { 20 | name: "WebRoot Antivirus", 21 | processName: "WRCoreService.x64.exe", 22 | }, 23 | { 24 | name: "WebRoot Antivirus", 25 | processName: "WRCoreService.x86.exe", 26 | }, 27 | ]; 28 | } 29 | 30 | async blacklistedProcessesRunning() { 31 | return ( 32 | await Promise.all( 33 | this.getBlacklistedPrograms().map(async (process) => ({ 34 | ...process, 35 | running: await this.systemService.isProcessRunning( 36 | process.processName 37 | ), 38 | })) 39 | ) 40 | ).filter(({ running }) => running); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/services/config.service.ts: -------------------------------------------------------------------------------- 1 | import Store from "electron-store"; 2 | import { USER_PREFERENCE_KEYS } from "@/shared/enums/userPreferenceKeys"; 3 | import { Resolution } from "@/Resolution"; 4 | import path from "path"; 5 | import { BindingScope, injectable } from "@loopback/context"; 6 | import { logger } from "@/main/logger"; 7 | import fs from "fs"; 8 | 9 | export const appRoot = path.resolve(`${__dirname}/../../`); 10 | export const isDevelopment = path.extname(appRoot) !== ".asar"; 11 | 12 | export interface UserPreferences { 13 | [USER_PREFERENCE_KEYS.MOD_DIRECTORY]: string; 14 | [USER_PREFERENCE_KEYS.PRESET]: string; 15 | [USER_PREFERENCE_KEYS.GRAPHICS]: string; 16 | [USER_PREFERENCE_KEYS.ENB_PROFILE]: string; 17 | [USER_PREFERENCE_KEYS.PREVIOUS_ENB_PROFILE]: string; 18 | [USER_PREFERENCE_KEYS.RESOLUTION]: Resolution; 19 | [USER_PREFERENCE_KEYS.SHOW_HIDDEN_PROFILE]: boolean; 20 | [USER_PREFERENCE_KEYS.CHECK_PREREQUISITES]: boolean; 21 | } 22 | 23 | type PreferenceWithValidator = { 24 | [key in keyof UserPreferences]?: { 25 | value: UserPreferences[keyof UserPreferences]; 26 | validate?: (...args: unknown[]) => boolean | Promise; 27 | }; 28 | }; 29 | 30 | export const userPreferences = new Store({ 31 | name: "userPreferences", 32 | }); 33 | 34 | @injectable({ 35 | scope: BindingScope.SINGLETON, 36 | }) 37 | export class ConfigService { 38 | constructor(private config = userPreferences) {} 39 | 40 | skyrimDirectory() { 41 | return `${this.modDirectory()}/Stock Game`; 42 | } 43 | 44 | getLogDirectory() { 45 | return path.dirname(logger.transports?.file.getFile().path); 46 | } 47 | 48 | modDirectory() { 49 | return this.config.get(USER_PREFERENCE_KEYS.MOD_DIRECTORY); 50 | } 51 | 52 | backupDirectory() { 53 | return `${this.modDirectory()}/launcher/_backups`; 54 | } 55 | 56 | backupsExist() { 57 | return fs.existsSync(this.backupDirectory()); 58 | } 59 | 60 | getPreference( 61 | key: keyof UserPreferences 62 | ): T { 63 | return this.config.get(key) as unknown as T; 64 | } 65 | 66 | launcherDirectory() { 67 | return `${this.modDirectory()}/launcher`; 68 | } 69 | 70 | hasPreference(key: keyof UserPreferences) { 71 | return this.config.has(key); 72 | } 73 | 74 | deletePreference(key: keyof UserPreferences) { 75 | logger.debug(`Deleting preference: ${key}`); 76 | return this.config.delete(key); 77 | } 78 | 79 | setPreference(key: keyof UserPreferences | string, value: unknown) { 80 | if (typeof value === "object") { 81 | logger.debug(`Setting preference ${key} to ${JSON.stringify(value)}`); 82 | } else { 83 | logger.debug(`Setting preference ${key} to ${value}`); 84 | } 85 | return this.config.set(key, value); 86 | } 87 | 88 | /** 89 | Set the value specified if the key doesn't exist or the current value is invalid 90 | */ 91 | async setDefaultPreferences(preferences: PreferenceWithValidator) { 92 | logger.debug("Setting default user preferences"); 93 | logger.debug(`Current preferences`); 94 | logger.debug(this.getPreferences().store); 95 | for (const [key, { value, validate }] of Object.entries(preferences)) { 96 | const valid = validate ? await validate() : true; 97 | if (!valid) { 98 | logger.warn( 99 | `Current ${key} preference is invalid. Setting to default: ${value}` 100 | ); 101 | } 102 | if ((!this.config.has(key) || !valid) && value) { 103 | this.setPreference(key, value); 104 | } 105 | } 106 | logger.debug("New preferences"); 107 | logger.debug(this.getPreferences().store); 108 | } 109 | 110 | getPreferences() { 111 | return this.config; 112 | } 113 | 114 | editPreferences() { 115 | return userPreferences.openInEditor(); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/main/services/error.service.ts: -------------------------------------------------------------------------------- 1 | import { dialog } from "electron"; 2 | import { logger } from "@/main/logger"; 3 | import { BindingScope, injectable } from "@loopback/context"; 4 | 5 | @injectable({ 6 | scope: BindingScope.SINGLETON, 7 | }) 8 | export class ErrorService { 9 | async handleError(title: string, message: string) { 10 | logger.error(`${title}: ${message}`); 11 | logger.error(new Error().stack); 12 | await dialog.showErrorBox(title, message); 13 | } 14 | 15 | async handleUnknownError(error?: unknown) { 16 | logger.error( 17 | `Unknown error. The stack trace might hold more details. ${error}` 18 | ); 19 | logger.error(new Error().stack); 20 | await dialog.showErrorBox( 21 | "An unknown error has occurred", 22 | "If you require more support, please post a message in the official modpack Discord and send your launcher log files." 23 | ); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/services/game.service.ts: -------------------------------------------------------------------------------- 1 | import { BindingScope, injectable } from "@loopback/context"; 2 | import { ConfigService } from "@/main/services/config.service"; 3 | import { service } from "@loopback/core"; 4 | import fs from "fs"; 5 | import { logger } from "@/main/logger"; 6 | 7 | @injectable({ 8 | scope: BindingScope.SINGLETON, 9 | }) 10 | export class GameService { 11 | constructor(@service(ConfigService) private configService: ConfigService) {} 12 | 13 | async copySkyrimLaunchLogs() { 14 | logger.info("Copying Skyrim launch logs"); 15 | const launchLogPath = `${this.configService.skyrimDirectory()}/d3dx9_42.log`; 16 | const logPath = this.configService.getLogDirectory(); 17 | if (fs.existsSync(launchLogPath)) { 18 | return fs.promises.copyFile( 19 | launchLogPath, 20 | `${logPath}/skyrim-launch-logs.log` 21 | ); 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/main/services/launcher.service.ts: -------------------------------------------------------------------------------- 1 | import { USER_PREFERENCE_KEYS } from "@/shared/enums/userPreferenceKeys"; 2 | import { service } from "@loopback/core"; 3 | import { ModOrganizerService } from "@/main/services/modOrganizer.service"; 4 | import { ProfileService } from "@/main/services/profile.service"; 5 | import { EnbService } from "@/main/services/enb.service"; 6 | import { ConfigService } from "@/main/services/config.service"; 7 | import { ResolutionService } from "@/main/services/resolution.service"; 8 | import { ModpackService } from "@/main/services/modpack.service"; 9 | import { BindingScope, injectable } from "@loopback/context"; 10 | import { app } from "electron"; 11 | import { logger } from "@/main/logger"; 12 | import { ErrorService } from "@/main/services/error.service"; 13 | import { WindowService } from "@/main/services/window.service"; 14 | import { GraphicsService } from "@/main/services/graphics.service"; 15 | import { MigrationService } from "@/main/services/migration.service"; 16 | 17 | @injectable({ 18 | scope: BindingScope.SINGLETON, 19 | }) 20 | export class LauncherService { 21 | constructor( 22 | @service(EnbService) private enbService: EnbService, 23 | @service(ConfigService) private configService: ConfigService, 24 | @service(ResolutionService) private resolutionService: ResolutionService, 25 | @service(ModpackService) private modpackService: ModpackService, 26 | @service(ProfileService) private profileService: ProfileService, 27 | @service(ModOrganizerService) 28 | private modOrganizerService: ModOrganizerService, 29 | @service(ErrorService) private errorService: ErrorService, 30 | @service(WindowService) private windowService: WindowService, 31 | @service(GraphicsService) private graphicsService: GraphicsService, 32 | @service(MigrationService) private migrationService: MigrationService 33 | ) {} 34 | 35 | async refreshModpack() { 36 | logger.debug("Refreshing modpack"); 37 | return this.setModpack(this.modpackService.getModpackDirectory()); 38 | } 39 | 40 | async setModpack(filepath: string) { 41 | try { 42 | await this.configService.setPreference( 43 | USER_PREFERENCE_KEYS.MOD_DIRECTORY, 44 | filepath 45 | ); 46 | await this.migrationService.separateProfileFromGraphics(); 47 | await this.validateConfig(); 48 | await this.backupAssets(); 49 | await this.enbService.resetCurrentEnb(false); 50 | await this.resolutionService.setResolution( 51 | this.resolutionService.getResolutionPreference() 52 | ); 53 | await this.resolutionService.setShouldDisableUltraWidescreen(); 54 | await this.graphicsService.setGraphics( 55 | this.graphicsService.getGraphicsPreference() 56 | ); 57 | } catch (error) { 58 | if (error instanceof Error && error.message.includes("EPERM")) { 59 | await this.errorService.handleError( 60 | "Permission error", 61 | ` 62 | The launcher has been unable to create/modify some files due to a permissions error. 63 | It is strongly recommended you restart the application as an administrator. 64 | If this does not work, you will need to change the permissions of the install directory.` 65 | ); 66 | this.windowService.quit(); 67 | } else { 68 | await this.errorService.handleUnknownError(error); 69 | } 70 | } 71 | } 72 | 73 | async validateConfig() { 74 | logger.debug("Validating config..."); 75 | await this.configService.setDefaultPreferences({ 76 | [USER_PREFERENCE_KEYS.ENB_PROFILE]: { 77 | value: await this.enbService.getDefaultPreference(), 78 | validate: async () => 79 | this.enbService.isValid(await this.enbService.getEnbPreference()), 80 | }, 81 | [USER_PREFERENCE_KEYS.PRESET]: { 82 | value: await this.profileService.getDefaultPreference(), 83 | validate: async () => 84 | this.profileService.isValid( 85 | await this.profileService.getProfilePreference() 86 | ), 87 | }, 88 | [USER_PREFERENCE_KEYS.RESOLUTION]: { 89 | value: this.resolutionService.getCurrentResolution(), 90 | }, 91 | [USER_PREFERENCE_KEYS.GRAPHICS]: { 92 | value: await this.graphicsService.getDefaultPreference(), 93 | validate: async () => 94 | this.graphicsService.isValid( 95 | this.graphicsService.getGraphicsPreference() 96 | ), 97 | }, 98 | }); 99 | logger.debug("Config validated"); 100 | } 101 | 102 | async backupAssets() { 103 | await this.enbService.backupOriginalENBs(); 104 | await this.profileService.backupOriginalProfiles(); 105 | await this.graphicsService.backupOriginalGraphics(); 106 | } 107 | 108 | getVersion() { 109 | return app.getVersion(); 110 | } 111 | 112 | setCheckPrerequisites(value: boolean) { 113 | return this.configService.setPreference( 114 | USER_PREFERENCE_KEYS.CHECK_PREREQUISITES, 115 | value 116 | ); 117 | } 118 | 119 | getCheckPrerequisites() { 120 | return this.configService.getPreference( 121 | USER_PREFERENCE_KEYS.CHECK_PREREQUISITES 122 | ); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/main/services/modpack.service.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { logger } from "@/main/logger"; 3 | import { MO2Names } from "@/main/services/modOrganizer.service"; 4 | import { BindingScope, injectable } from "@loopback/context"; 5 | import modpack from "@/modpack.json"; 6 | import { IsModpackValidResponse } from "@/main/controllers/modpack/mopack.events"; 7 | import { Modpack } from "@/modpack-metadata"; 8 | import { service } from "@loopback/core"; 9 | import { ConfigService } from "@/main/services/config.service"; 10 | import { USER_PREFERENCE_KEYS } from "@/shared/enums/userPreferenceKeys"; 11 | 12 | @injectable({ 13 | scope: BindingScope.SINGLETON, 14 | }) 15 | export class ModpackService { 16 | constructor(@service(ConfigService) private configService: ConfigService) {} 17 | 18 | checkModpackPathIsValid(modpackPath: string): IsModpackValidResponse { 19 | const missingPaths = [MO2Names.MO2EXE, "profiles", "launcher"] 20 | .filter((path) => !fs.existsSync(`${modpackPath}/${path}`)) 21 | .map((path) => { 22 | logger.warn( 23 | `Selected mod directory "${modpackPath}" doesn't contain a "${path}" directory/file` 24 | ); 25 | return path; 26 | }); 27 | 28 | return { ok: missingPaths.length === 0, missingPaths }; 29 | } 30 | 31 | checkCurrentModpackPathIsValid() { 32 | return ( 33 | this.isModpackSet() && 34 | this.checkModpackPathIsValid(this.getModpackDirectory()).ok 35 | ); 36 | } 37 | 38 | getModpackDirectory() { 39 | return this.configService.getPreference( 40 | USER_PREFERENCE_KEYS.MOD_DIRECTORY 41 | ); 42 | } 43 | 44 | getModpackMetadata(): Modpack { 45 | return modpack; 46 | } 47 | 48 | deleteModpackDirectory() { 49 | return this.configService.deletePreference( 50 | USER_PREFERENCE_KEYS.MOD_DIRECTORY 51 | ); 52 | } 53 | 54 | isModpackSet(): boolean { 55 | return this.configService 56 | .getPreferences() 57 | .has(USER_PREFERENCE_KEYS.MOD_DIRECTORY); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/services/profile.service.ts: -------------------------------------------------------------------------------- 1 | import { ConfigService } from "@/main/services/config.service"; 2 | import { USER_PREFERENCE_KEYS } from "@/shared/enums/userPreferenceKeys"; 3 | import { FriendlyDirectoryMap } from "@/modpack-metadata"; 4 | import fs from "fs"; 5 | import { not as isNotJunk } from "junk"; 6 | import { logger } from "@/main/logger"; 7 | import { copy, existsSync } from "fs-extra"; 8 | import { service } from "@loopback/core"; 9 | 10 | export class ProfileService { 11 | constructor(@service(ConfigService) private configService: ConfigService) {} 12 | 13 | profileDirectory() { 14 | return `${this.configService.modDirectory()}/profiles`; 15 | } 16 | 17 | profileBackupDirectory() { 18 | return `${this.configService.backupDirectory()}/profiles`; 19 | } 20 | 21 | profileMappingFile() { 22 | return `${this.configService.launcherDirectory()}/namesMO2.json`; 23 | } 24 | 25 | getShowHiddenProfiles() { 26 | return ( 27 | this.configService.getPreference( 28 | USER_PREFERENCE_KEYS.SHOW_HIDDEN_PROFILE 29 | ) ?? false 30 | ); 31 | } 32 | 33 | setShowHiddenProfiles(show: boolean) { 34 | return this.configService.setPreference( 35 | USER_PREFERENCE_KEYS.SHOW_HIDDEN_PROFILE, 36 | show 37 | ); 38 | } 39 | 40 | async getProfiles(): Promise { 41 | // Get mapped profile names that have a mapping 42 | const mappedProfiles = await this.getMappedProfiles(); 43 | 44 | // Get any profiles that don't have a mapping 45 | const unmappedProfiles = await this.getUnmappedProfiles(mappedProfiles); 46 | 47 | return [...mappedProfiles, ...unmappedProfiles]; 48 | } 49 | 50 | getPhysicalProfiles() { 51 | return fs.promises.readdir(this.profileDirectory(), { 52 | withFileTypes: true, 53 | }); 54 | } 55 | 56 | async getMappedProfiles() { 57 | return JSON.parse( 58 | await fs.promises.readFile(this.profileMappingFile(), "utf-8") 59 | ) as FriendlyDirectoryMap[]; 60 | } 61 | 62 | async getUnmappedProfiles(mappedProfiles: FriendlyDirectoryMap[]) { 63 | return ( 64 | (await this.getPhysicalProfiles()) 65 | .filter((dirent) => dirent.isDirectory()) 66 | .map((dirent) => dirent.name) 67 | .filter(isNotJunk) 68 | .map( 69 | (preset): FriendlyDirectoryMap => ({ real: preset, friendly: preset }) 70 | ) 71 | // Remove any profiles that have a mapping 72 | .filter( 73 | (unmappedPreset) => 74 | !mappedProfiles.find( 75 | (mappedPreset: FriendlyDirectoryMap) => 76 | mappedPreset.real === unmappedPreset.real 77 | ) 78 | ) 79 | ); 80 | } 81 | 82 | async getProfileDirectories() { 83 | return (await this.getPhysicalProfiles()).map( 84 | ({ name }) => `${this.profileDirectory()}/${name}` 85 | ); 86 | } 87 | 88 | async getBackedUpProfiles() { 89 | return fs.promises.readdir(this.profileBackupDirectory()); 90 | } 91 | 92 | async getBackedUpProfileDirectories() { 93 | return (await this.getBackedUpProfiles()).map( 94 | (profile) => `${this.profileBackupDirectory()}/${profile}` 95 | ); 96 | } 97 | 98 | prependProfileDirectory(profile: string) { 99 | return `${this.profileDirectory()}/${profile}`; 100 | } 101 | 102 | /** 103 | * Return the current profile preference or the first if it is invalid 104 | */ 105 | async getProfilePreference() { 106 | return this.configService.getPreference( 107 | USER_PREFERENCE_KEYS.PRESET 108 | ); 109 | } 110 | 111 | async getDefaultPreference() { 112 | return (await this.getPhysicalProfiles())[0].name; 113 | } 114 | 115 | setProfilePreference(profile: string) { 116 | this.configService.setPreference(USER_PREFERENCE_KEYS.PRESET, profile); 117 | } 118 | 119 | async isInProfileList(profile: string) { 120 | return ( 121 | (await this.getPhysicalProfiles()).filter(({ name }) => name === profile) 122 | .length > 0 123 | ); 124 | } 125 | 126 | async isValid(profile: string) { 127 | return this.isInProfileList(profile); 128 | } 129 | 130 | async backupOriginalProfiles() { 131 | const backupExists = existsSync(this.profileBackupDirectory()); 132 | logger.debug(`Backup for profiles exists: ${backupExists}`); 133 | 134 | if (!backupExists) { 135 | logger.info("No profiles backup exists. Backing up..."); 136 | await fs.promises.mkdir(this.configService.backupDirectory(), { 137 | recursive: true, 138 | }); 139 | 140 | await copy(this.profileDirectory(), this.profileBackupDirectory()); 141 | } 142 | } 143 | 144 | async restoreProfiles() { 145 | logger.info("Restoring MO2 profiles"); 146 | await copy(this.profileBackupDirectory(), this.profileDirectory(), { 147 | overwrite: true, 148 | }); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /src/main/services/update.service.ts: -------------------------------------------------------------------------------- 1 | import { autoUpdater } from "electron-updater"; 2 | import { app } from "electron"; 3 | import path from "path"; 4 | import { isDevelopment } from "@/main/services/config.service"; 5 | import fs from "fs"; 6 | import { service } from "@loopback/core"; 7 | import { WindowService } from "@/main/services/window.service"; 8 | import { logger } from "@/main/logger"; 9 | import { BindingScope, injectable } from "@loopback/context"; 10 | import { UPDATE_EVENTS } from "@/main/controllers/update/update.events"; 11 | import { ErrorService } from "@/main/services/error.service"; 12 | 13 | @injectable({ 14 | scope: BindingScope.SINGLETON, 15 | }) 16 | export class UpdateService { 17 | private devAppUpdatePath = path.join( 18 | __dirname, 19 | "../../../dev-app-update.yml" 20 | ); 21 | 22 | constructor( 23 | @service(WindowService) private renderService: WindowService, 24 | @service(ErrorService) private errorService: ErrorService, 25 | @service(WindowService) private windowService: WindowService 26 | ) {} 27 | 28 | async update() { 29 | await this.windowService.createBrowserWindow(); 30 | await this.windowService.load("/auto-update"); 31 | 32 | return new Promise((resolve) => { 33 | // Only register if there is no update available. If there is an update, the window will close itself anyway. 34 | autoUpdater.on(UPDATE_EVENTS.UPDATE_NOT_AVAILABLE, () => { 35 | logger.debug("No update available"); 36 | resolve(); 37 | }); 38 | 39 | if (this.shouldUpdate()) { 40 | this.checkForUpdate().catch((error) => { 41 | logger.debug(`Update failed with error ${error}. Continuing anyway.`); 42 | resolve(); 43 | }); 44 | } else { 45 | resolve(); 46 | } 47 | }); 48 | } 49 | 50 | shouldUpdate() { 51 | let shouldUpdate; 52 | 53 | if (isDevelopment && fs.existsSync(this.devAppUpdatePath)) { 54 | logger.debug(`Setting auto update path to ${this.devAppUpdatePath}`); 55 | autoUpdater.updateConfigPath = this.devAppUpdatePath; 56 | shouldUpdate = true; 57 | } else if (isDevelopment) { 58 | logger.debug( 59 | "Skipping app update check because we're in development mode" 60 | ); 61 | shouldUpdate = false; 62 | } else if (app.getVersion().includes("-")) { 63 | logger.debug( 64 | "Skipping app update check because this is a pre-release version" 65 | ); 66 | shouldUpdate = false; 67 | } else { 68 | shouldUpdate = true; 69 | } 70 | 71 | return shouldUpdate; 72 | } 73 | 74 | registerEvents() { 75 | autoUpdater.on(UPDATE_EVENTS.UPDATE_AVAILABLE, () => { 76 | logger.info(`Update available`); 77 | this.renderService.getWebContents().send(UPDATE_EVENTS.UPDATE_AVAILABLE); 78 | }); 79 | 80 | autoUpdater.on(UPDATE_EVENTS.DOWNLOAD_PROGRESS, ({ percent }) => { 81 | this.renderService 82 | .getWebContents() 83 | .send(UPDATE_EVENTS.DOWNLOAD_PROGRESS, Math.floor(percent)); 84 | }); 85 | 86 | autoUpdater.on(UPDATE_EVENTS.UPDATE_DOWNLOADED, () => { 87 | logger.debug("Update downloaded"); 88 | autoUpdater.quitAndInstall(); 89 | }); 90 | 91 | autoUpdater.on(UPDATE_EVENTS.ERROR, async (error: Error) => { 92 | let message; 93 | if (error.message.includes("net::ERR_NAME_NOT_RESOLVED")) { 94 | message = `This likely means you are not connected to the internet. It is recommended you use the latest launcher version as it might contain bug fixes for the modpack itself.`; 95 | } else { 96 | message = `An unknown error has occurred. Please try relaunching the launcher.`; 97 | } 98 | 99 | await this.errorService.handleError( 100 | "Error checking for update", 101 | `Cannot check for update. ${message}` 102 | ); 103 | }); 104 | } 105 | 106 | async checkForUpdate() { 107 | this.registerEvents(); 108 | const updateCheckResult = await autoUpdater.checkForUpdates(); 109 | logger.debug("Auto update check result"); 110 | logger.debug(updateCheckResult); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/modpack.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Wildlander", 3 | "logo": "/images/logos/wildlander-full-light.svg", 4 | "backgroundImage": "/images/default-background.png", 5 | "website": "https://www.wildlandermod.com", 6 | "wiki": "https://wiki.wildlandermod.com/", 7 | "patreon": "https://www.patreon.com/dylanbperry", 8 | "roadmap": "https://airtable.com/shrvAxHcCeCqKfnGe" 9 | } 10 | -------------------------------------------------------------------------------- /src/renderer/App.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 49 | 50 | 110 | -------------------------------------------------------------------------------- /src/renderer/components/AppDropdownFileSelect.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 75 | 76 | 77 | -------------------------------------------------------------------------------- /src/renderer/components/AppModal.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 74 | 75 | 109 | -------------------------------------------------------------------------------- /src/renderer/components/AppPage.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 91 | 92 | 114 | -------------------------------------------------------------------------------- /src/renderer/components/AppPageContent.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 26 | 27 | 55 | -------------------------------------------------------------------------------- /src/renderer/components/BaseButton.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 20 | 21 | 76 | -------------------------------------------------------------------------------- /src/renderer/components/BaseImage.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 16 | -------------------------------------------------------------------------------- /src/renderer/components/BaseInput.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 41 | 42 | 58 | -------------------------------------------------------------------------------- /src/renderer/components/BaseLabel.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 17 | 18 | 34 | -------------------------------------------------------------------------------- /src/renderer/components/BaseLink.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 39 | 40 | 53 | -------------------------------------------------------------------------------- /src/renderer/components/BaseList.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 21 | 22 | 54 | -------------------------------------------------------------------------------- /src/renderer/components/Community.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 65 | 66 | 97 | -------------------------------------------------------------------------------- /src/renderer/components/ENB.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 85 | -------------------------------------------------------------------------------- /src/renderer/components/GraphicsSelection.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 71 | -------------------------------------------------------------------------------- /src/renderer/components/ImageWithText.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 27 | 28 | 35 | -------------------------------------------------------------------------------- /src/renderer/components/LauncherVersion.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 43 | 44 | 49 | -------------------------------------------------------------------------------- /src/renderer/components/MO2RunningModal.vue: -------------------------------------------------------------------------------- 1 | 17 | 18 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /src/renderer/components/ModDirectory.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 110 | -------------------------------------------------------------------------------- /src/renderer/components/NavigationItem.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 15 | 16 | 33 | -------------------------------------------------------------------------------- /src/renderer/components/News.vue: -------------------------------------------------------------------------------- 1 | 24 | 25 | 60 | 61 | 104 | -------------------------------------------------------------------------------- /src/renderer/components/Patrons.vue: -------------------------------------------------------------------------------- 1 | 37 | 38 | 78 | 79 | 133 | -------------------------------------------------------------------------------- /src/renderer/components/ProfileSelection.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 96 | -------------------------------------------------------------------------------- /src/renderer/components/Resolution.vue: -------------------------------------------------------------------------------- 1 | 32 | 33 | 119 | 120 | 127 | -------------------------------------------------------------------------------- /src/renderer/components/TheHeader.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 48 | 49 | 75 | -------------------------------------------------------------------------------- /src/renderer/components/TheTitleBar.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 36 | 37 | 73 | -------------------------------------------------------------------------------- /src/renderer/index.ts: -------------------------------------------------------------------------------- 1 | import { createApp } from "vue"; 2 | import { getRouter } from "./router"; 3 | import VueFinalModal from "vue-final-modal"; 4 | import VueClickAway from "vue3-click-away"; 5 | import "reflect-metadata"; 6 | import App from "@/renderer/App.vue"; 7 | import { registerServices } from "@/renderer/services/service-container"; 8 | 9 | const app = createApp(App); 10 | 11 | const { modpackService } = registerServices(app); 12 | 13 | app 14 | .use(getRouter(modpackService)) 15 | .use(VueFinalModal()) 16 | .use(VueClickAway) 17 | .mount("#app"); 18 | 19 | // Prevent Mouse 4 and Mouse 5 from navigating the app 20 | // Ref: https://stackoverflow.com/a/66318490 GitHub Issue 654 21 | window.addEventListener("mouseup", (e) => { 22 | if (e.button === 3 || e.button === 4) e.preventDefault(); 23 | }); 24 | -------------------------------------------------------------------------------- /src/renderer/router/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | createRouter, 3 | createWebHashHistory, 4 | NavigationGuardWithThis, 5 | RouteLocationNormalized, 6 | RouteRecordRaw, 7 | } from "vue-router"; 8 | 9 | import ViewHome from "@/renderer/views/ViewHome.vue"; 10 | import ViewCommunity from "@/renderer/views/ViewCommunity.vue"; 11 | import ViewAdvanced from "@/renderer/views/ViewAdvanced.vue"; 12 | import AutoUpdate from "@/renderer/views/AutoUpdate.vue"; 13 | import ModDirectoryView from "@/renderer/views/ModDirectory.vue"; 14 | import { ModpackService } from "@/renderer/services/modpack.service"; 15 | 16 | const HomeRouteName = "Home"; 17 | const AutoUpdateRouteName = "AutoUpdate"; 18 | const ModDirectoryRouteName = "ModDirectory"; 19 | 20 | const checkModDirectory = 21 | (modpackService: ModpackService): NavigationGuardWithThis => 22 | async () => { 23 | if ( 24 | !(await modpackService.isModDirectorySet()) || 25 | !(await modpackService.isCurrentModpackValid()) 26 | ) { 27 | await modpackService.deleteModpackDirectory(); 28 | return { 29 | name: ModDirectoryRouteName, 30 | }; 31 | } 32 | }; 33 | 34 | const getRoutes = (modpackService: ModpackService): RouteRecordRaw[] => 35 | [ 36 | { 37 | path: "/", 38 | name: HomeRouteName, 39 | component: ViewHome, 40 | beforeEnter: [checkModDirectory(modpackService)], 41 | }, 42 | { 43 | path: "/community", 44 | name: "Community", 45 | component: ViewCommunity, 46 | beforeEnter: [checkModDirectory(modpackService)], 47 | }, 48 | { 49 | path: "/advanced", 50 | name: "Advanced", 51 | component: ViewAdvanced, 52 | beforeEnter: [checkModDirectory(modpackService)], 53 | }, 54 | { 55 | path: "/auto-update", 56 | name: AutoUpdateRouteName, 57 | component: AutoUpdate, 58 | meta: { 59 | preload: true, 60 | }, 61 | }, 62 | { 63 | path: "/mod-directory", 64 | name: ModDirectoryRouteName, 65 | component: ModDirectoryView, 66 | props: true, 67 | meta: { 68 | preload: true, 69 | }, 70 | beforeEnter: async (to: RouteLocationNormalized) => { 71 | if ( 72 | (await modpackService.isModDirectorySet()) && 73 | to.name === ModDirectoryRouteName 74 | ) { 75 | // If the page is refreshed on the mod directory selection, just redirect to the home page 76 | return { name: HomeRouteName }; 77 | } 78 | }, 79 | }, 80 | ] 81 | // If preload metadata is not set, set it to false 82 | .map((route) => { 83 | return { 84 | ...route, 85 | meta: { ...route.meta, preload: route.meta?.preload ?? false }, 86 | }; 87 | }); 88 | 89 | export const getRouter = (modpackService: ModpackService) => 90 | createRouter({ 91 | history: createWebHashHistory(), 92 | routes: getRoutes(modpackService), 93 | }); 94 | -------------------------------------------------------------------------------- /src/renderer/services/cache.service.ts: -------------------------------------------------------------------------------- 1 | import { logger } from "@/main/logger"; 2 | 3 | /** 4 | * The CacheService class is a simple wrapper around window.localStorage 5 | * localStorage can only store strings so this wrapper JSON.stringifies all data 6 | */ 7 | export class CacheService { 8 | /** 9 | * Returns data from the localStorage if it exists and is newer than maxAge 10 | */ 11 | public get( 12 | key: string, 13 | maxAge?: number 14 | ): { age: number; content: T } | undefined { 15 | const now = new Date(); 16 | logger.debug( 17 | `Cache: query "${key}" with maxAge: ${maxAge || "unlimited"} seconds.` 18 | ); 19 | const rawData = window.localStorage.getItem(key); 20 | if (rawData !== null) { 21 | const data: { age: number; content: T } = JSON.parse(rawData); 22 | // Convert maxAge to ms for comparison 23 | if (!maxAge || now.getTime() - data.age < maxAge * 1000) { 24 | logger.debug(`Cache: returning data for "${key}"`); 25 | return data; 26 | } 27 | } 28 | logger.debug(`Cache: could not find "${key}" in cache or too old`); 29 | return undefined; 30 | } 31 | 32 | /** 33 | * Sets data in the localStorage, overwrites regardless of previous data presence 34 | */ 35 | public set(key: string, data: unknown) { 36 | logger.debug(`Cache: setting data for "${key}".`); 37 | window.localStorage.setItem( 38 | key, 39 | JSON.stringify({ 40 | age: new Date().getTime(), 41 | content: data, 42 | }) 43 | ); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/renderer/services/event.service.ts: -------------------------------------------------------------------------------- 1 | import mitt from "mitt"; 2 | 3 | export const ENABLE_LOADING_EVENT = "ENABLE_LOADING_EVENT"; 4 | export const DISABLE_LOADING_EVENT = "DISABLE_LOADING_EVENT"; 5 | 6 | export const EventService = mitt(); 7 | -------------------------------------------------------------------------------- /src/renderer/services/ipc.service.ts: -------------------------------------------------------------------------------- 1 | import { ipcRenderer } from "electron"; 2 | 3 | export class IpcService { 4 | public invoke(channel: string, ...args: unknown[]): Promise { 5 | return ipcRenderer.invoke(channel, ...args); 6 | } 7 | 8 | public on(channel: string, listener: (...args: unknown[]) => void) { 9 | return ipcRenderer.on(channel, listener); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/renderer/services/message.service.ts: -------------------------------------------------------------------------------- 1 | import { DIALOG_EVENTS } from "@/main/controllers/dialog/dialog.events"; 2 | import { logger } from "@/main/logger"; 3 | import { IpcService } from "@/renderer/services/ipc.service"; 4 | import { MessageBoxReturnValue } from "electron"; 5 | 6 | export class MessageService { 7 | constructor(private ipcService: IpcService) {} 8 | 9 | async error({ title, error }: { title: string; error: string }) { 10 | logger.error(`${title}: ${error}`); 11 | await this.ipcService.invoke(DIALOG_EVENTS.ERROR, { title, error }); 12 | } 13 | 14 | async confirmation(message: string, buttons: string[]) { 15 | return this.ipcService.invoke( 16 | DIALOG_EVENTS.CONFIRMATION, 17 | { 18 | message, 19 | buttons, 20 | } 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/renderer/services/modal.service.ts: -------------------------------------------------------------------------------- 1 | import { EventService } from "@/renderer/services/service-container"; 2 | import { VueFinalModalProperty } from "vue-final-modal"; 3 | 4 | export const modalOpenedEvent = "modalOpened"; 5 | 6 | export class ModalService { 7 | // maintain a list of modals that have tried to open to prevent more than one being open at once 8 | private modalQueue: string[] = []; 9 | 10 | constructor(private eventService: EventService) {} 11 | 12 | public openModal(name: string, vfm: VueFinalModalProperty) { 13 | if (this.modalQueue.length === 0) { 14 | // Currently no other modals waiting to be opened so just open the modal 15 | vfm.show(name); 16 | // Add to the modal queue until the modal is closed 17 | this.modalQueue.push(name); 18 | this.eventService.emit(modalOpenedEvent, true); 19 | } else { 20 | // There are currently modals open so just add to the queue 21 | this.modalQueue.push(name); 22 | } 23 | } 24 | 25 | public closeModal(name: string, vfm: VueFinalModalProperty) { 26 | vfm.hide(name); 27 | this.modalQueue = this.modalQueue.filter((modal) => modal !== name); 28 | this.eventService.emit(modalOpenedEvent, false); 29 | if (this.modalQueue.length > 0) { 30 | // There are still modals waiting to be opened, open the next one 31 | vfm.show(this.modalQueue[0]); 32 | this.eventService.emit(modalOpenedEvent, true); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/renderer/services/modpack.service.ts: -------------------------------------------------------------------------------- 1 | import { IpcService } from "@/renderer/services/ipc.service"; 2 | import { 3 | IsModpackValidResponse, 4 | MODPACK_EVENTS, 5 | } from "@/main/controllers/modpack/mopack.events"; 6 | 7 | export class ModpackService { 8 | constructor(private ipcService: IpcService) {} 9 | 10 | public isModDirectorySet() { 11 | return this.ipcService.invoke(MODPACK_EVENTS.IS_MODPACK_SET); 12 | } 13 | 14 | public getModpackDirectory() { 15 | return this.ipcService.invoke(MODPACK_EVENTS.GET_MODPACK); 16 | } 17 | 18 | public async isModDirectoryValid(modDirectory: string) { 19 | return this.ipcService.invoke( 20 | MODPACK_EVENTS.IS_MODPACK_DIRECTORY_VALID, 21 | modDirectory 22 | ); 23 | } 24 | 25 | public async isCurrentModpackValid() { 26 | return this.isModDirectoryValid(await this.getModpackDirectory()); 27 | } 28 | 29 | public async deleteModpackDirectory() { 30 | await this.ipcService.invoke(MODPACK_EVENTS.DELETE_MODPACK_DIRECTORY); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/renderer/services/patreon.service.ts: -------------------------------------------------------------------------------- 1 | import { CacheService } from "@/renderer/services/cache.service"; 2 | 3 | export class PatreonService { 4 | private patrons: Patron[] = []; 5 | private readonly cacheKey = "patreon.patrons"; 6 | private readonly cacheService: CacheService; 7 | 8 | constructor(cacheService: CacheService) { 9 | this.cacheService = cacheService; 10 | } 11 | 12 | /** 13 | * Taken from https://stackoverflow.com/a/12646864/3379536 14 | */ 15 | private static shuffleArray(array: Patron[]) { 16 | for (let i = array.length - 1; i > 0; i--) { 17 | const j = Math.floor(Math.random() * (i + 1)); 18 | [array[i], array[j]] = [array[j], array[i]]; 19 | } 20 | return array; 21 | } 22 | 23 | public async getPatrons(shuffle = true): Promise { 24 | if (this.patrons.length === 0) { 25 | const cache = this.cacheService.get( 26 | this.cacheKey, 27 | 60 * 60 * 24 28 | ); 29 | if (cache?.content) { 30 | this.patrons = cache.content; 31 | } 32 | } 33 | 34 | try { 35 | if (this.patrons.length > 0) { 36 | return this.patrons; 37 | } 38 | 39 | const response = await fetch("https://ultsky.phinocio.com/api/patreon"); 40 | const data = (await response.json()) as { patrons: Patron[] }; 41 | // Shuffle the array to show different Patrons each time 42 | this.patrons = shuffle 43 | ? PatreonService.shuffleArray(data.patrons) 44 | : data.patrons; 45 | this.cacheService.set(this.cacheKey, this.patrons); 46 | return this.patrons; 47 | } catch (error) { 48 | throw new Error(`Failed to get Patrons: ${error}`); 49 | } 50 | } 51 | } 52 | 53 | export interface Patron { 54 | name: string; 55 | tier: "Patron" | "Super Patron"; 56 | } 57 | -------------------------------------------------------------------------------- /src/renderer/services/posts.service.ts: -------------------------------------------------------------------------------- 1 | import { CacheService } from "@/renderer/services/cache.service"; 2 | import { logger } from "@/main/logger"; 3 | 4 | export class PostsService { 5 | private posts: Post[] | undefined; 6 | private readonly cacheKey = "patreon.posts"; 7 | private readonly cacheService: CacheService; 8 | private readonly remoteEndpoint = "https://ultsky.phinocio.com/api"; 9 | 10 | constructor(cacheService: CacheService) { 11 | this.cacheService = cacheService; 12 | } 13 | 14 | /** 15 | * 16 | * @param afterFetch - optional method to be called after fetching data from the API. Only called if the cache is used. 17 | */ 18 | public async getPosts(afterFetch?: (posts: Post[]) => void) { 19 | logger.info("Getting posts"); 20 | // If posts are already available, just return them. 21 | if (this.posts) { 22 | logger.debug("Posts already in memory, returning"); 23 | return this.posts; 24 | } 25 | 26 | const cache = this.cacheService.get(this.cacheKey); 27 | if (cache?.content) { 28 | logger.debug(`Posts in cache with age: ${cache.age}`); 29 | this.posts = cache.content; 30 | 31 | // If the cache is outdated, fetch the most recent data but still return the cache 32 | if (cache.age && (await this.checkIfCacheOutdated(cache.age / 1000))) { 33 | logger.debug( 34 | "Cache outdated after checking remote. Fetching up to date posts." 35 | ); 36 | new Promise(() => { 37 | this.fetchPosts().then((posts) => { 38 | if (afterFetch) { 39 | logger.debug("Calling optional method after fetching posts"); 40 | afterFetch(posts); 41 | logger.debug("Finished calling optional method"); 42 | } 43 | }); 44 | }); 45 | } 46 | 47 | return this.posts; 48 | } 49 | 50 | try { 51 | this.posts = await this.fetchPosts(); 52 | return this.posts; 53 | } catch (error) { 54 | throw new Error(`Failed to get News: ${error}`); 55 | } 56 | } 57 | 58 | async fetchPosts() { 59 | const response = await fetch(`${this.remoteEndpoint}/patreon`); 60 | const posts = (await response.json()).posts as Post[]; 61 | this.cacheService.set(this.cacheKey, posts); 62 | return posts; 63 | } 64 | 65 | async checkIfCacheOutdated(comparisonTime: number): Promise { 66 | const response = await fetch(`${this.remoteEndpoint}/last-updated`); 67 | const { last_updated: lastUpdated } = 68 | (await response.json()) as LastUpdatedResponse; 69 | return comparisonTime < lastUpdated; 70 | } 71 | } 72 | 73 | export interface Post { 74 | title: string; 75 | content: string; 76 | published: string; 77 | url: string; 78 | tags: string[]; 79 | } 80 | 81 | interface LastUpdatedResponse { 82 | last_updated: number; 83 | } 84 | -------------------------------------------------------------------------------- /src/renderer/services/service-container.ts: -------------------------------------------------------------------------------- 1 | import { App, inject, InjectionKey } from "vue"; 2 | import { PatreonService } from "./patreon.service"; 3 | import { PostsService } from "@/renderer/services/posts.service"; 4 | import { Emitter, EventType } from "mitt"; 5 | import { ModalService } from "@/renderer/services/modal.service"; 6 | import { MessageService } from "@/renderer/services/message.service"; 7 | import { EventService } from "@/renderer/services/event.service"; 8 | import { IpcService } from "@/renderer/services/ipc.service"; 9 | import { ModpackService } from "@/renderer/services/modpack.service"; 10 | import { CacheService } from "@/renderer/services/cache.service"; 11 | 12 | export type EventService = Emitter>; 13 | 14 | /* 15 | * IoC container to handle injecting services into Vue components 16 | */ 17 | 18 | /** 19 | * Create a new typed binding key 20 | */ 21 | export function createBinding(key: string): InjectionKey { 22 | return Symbol(key); 23 | } 24 | 25 | /** 26 | * Available keys to access services from the IoC container 27 | */ 28 | export const SERVICE_BINDINGS = { 29 | PATRON_SERVICE: createBinding("keys.services.patron"), 30 | NEWS_SERVICE: createBinding("keys.services.news"), 31 | EVENT_SERVICE: createBinding("keys.services.event"), 32 | MODAL_SERVICE: createBinding("keys.services.modal"), 33 | MESSAGE_SERVICE: createBinding("keys.services.message"), 34 | IPC_SERVICE: createBinding("keys.services.ipc"), 35 | MODPACK_SERVICE: createBinding("keys.services.modpack"), 36 | }; 37 | 38 | /** 39 | * Register all services 40 | */ 41 | export function registerServices(app: App) { 42 | const ipcService = new IpcService(); 43 | const modpackService = new ModpackService(ipcService); 44 | const cacheService = new CacheService(); 45 | app.provide(SERVICE_BINDINGS.IPC_SERVICE, ipcService); 46 | app.provide( 47 | SERVICE_BINDINGS.PATRON_SERVICE, 48 | new PatreonService(cacheService) 49 | ); 50 | app.provide(SERVICE_BINDINGS.NEWS_SERVICE, new PostsService(cacheService)); 51 | app.provide(SERVICE_BINDINGS.MESSAGE_SERVICE, new MessageService(ipcService)); 52 | app.provide(SERVICE_BINDINGS.EVENT_SERVICE, EventService); 53 | app.provide(SERVICE_BINDINGS.MODAL_SERVICE, new ModalService(EventService)); 54 | app.provide(SERVICE_BINDINGS.MODPACK_SERVICE, modpackService); 55 | 56 | return { ipcService, modpackService }; 57 | } 58 | 59 | /** 60 | * Used when it makes sense to error if the injection doesn't exist 61 | * Useful for services as the application likely won't work without these 62 | */ 63 | export function injectStrict(key: InjectionKey, fallback?: T) { 64 | const resolved = inject(key, fallback); 65 | if (!resolved) { 66 | throw new Error(`Could not resolve ${key.description}`); 67 | } 68 | 69 | return resolved; 70 | } 71 | -------------------------------------------------------------------------------- /src/renderer/views/AutoUpdate.vue: -------------------------------------------------------------------------------- 1 | 19 | 20 | 56 | 57 | 64 | -------------------------------------------------------------------------------- /src/renderer/views/ModDirectory.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 51 | 52 | 65 | -------------------------------------------------------------------------------- /src/renderer/views/ViewCommunity.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | -------------------------------------------------------------------------------- /src/renderer/views/ViewHome.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /src/shared/enums/userPreferenceKeys.ts: -------------------------------------------------------------------------------- 1 | export enum USER_PREFERENCE_KEYS { 2 | MOD_DIRECTORY = "MOD_DIRECTORY", 3 | PRESET = "PRESET", 4 | GRAPHICS = "GRAPHICS", 5 | ENB_PROFILE = "ENB_PROFILE", 6 | PREVIOUS_ENB_PROFILE = "PREVIOUS_ENB_PROFILE", 7 | RESOLUTION = "RESOLUTION", 8 | SHOW_HIDDEN_PROFILE = "SHOW_HIDDEN_PROFILE", 9 | 10 | CHECK_PREREQUISITES = "CHECK_PREREQUISITES", 11 | } 12 | -------------------------------------------------------------------------------- /src/shared/util/asyncFilter.ts: -------------------------------------------------------------------------------- 1 | // An async version of array filter 2 | // Taken from: https://stackoverflow.com/a/46842181/3379536 3 | export async function asyncFilter( 4 | arr: T[], 5 | callback: (item: T) => Promise 6 | ) { 7 | const fail = Symbol(); 8 | return ( 9 | await Promise.all( 10 | arr.map(async (item) => ((await callback(item)) ? item : fail)) 11 | ) 12 | ).filter((i) => i !== fail); 13 | } 14 | -------------------------------------------------------------------------------- /src/types/ModOrganizer.ini.d.ts: -------------------------------------------------------------------------------- 1 | import { IIniObject } from "js-ini/lib/interfaces/ini-object"; 2 | 3 | export interface ModOrganizerIni extends IIniObject { 4 | General: object; 5 | customExecutables: { 6 | size: number; 7 | [key: string]: unknown; 8 | }; 9 | Settings: object; 10 | } 11 | -------------------------------------------------------------------------------- /src/types/Resolution.d.ts: -------------------------------------------------------------------------------- 1 | export interface Resolution { 2 | height: number; 3 | width: number; 4 | } 5 | -------------------------------------------------------------------------------- /src/types/additional-instructions.d.ts: -------------------------------------------------------------------------------- 1 | interface AdditionalInstructionBase { 2 | // The version the instruction applies to 3 | version?: string; 4 | } 5 | 6 | export type Target = string | string[]; 7 | 8 | export interface PluginOrModInstruction extends AdditionalInstructionBase { 9 | type: "enb" | "resolution-ratio"; 10 | target: Target; 11 | } 12 | 13 | // Instruction will be run when enb is changed 14 | // The `target` is the enb that the change will be applied on 15 | interface PluginInstruction extends PluginOrModInstruction { 16 | action: "disable-plugin" | "enable-plugin"; 17 | plugin: string; 18 | } 19 | 20 | // Instruction will be run when enb is changed 21 | // The `target` is the enb that the change will be applied on 22 | interface ModInstruction extends PluginOrModInstruction { 23 | action: "disable-mod" | "enable-mod"; 24 | mod: string; 25 | } 26 | 27 | // A single entry of `disable-ultra-widescreen` is enough for the instruction to return true 28 | interface DisableUltraWidescreenInstruction extends AdditionalInstructionBase { 29 | type?: string; 30 | action: "disable-ultra-widescreen"; 31 | } 32 | 33 | export type AdditionalInstruction = 34 | | PluginInstruction 35 | | ModInstruction 36 | | DisableUltraWidescreenInstruction; 37 | 38 | export type AdditionalInstructions = AdditionalInstruction[]; 39 | -------------------------------------------------------------------------------- /src/types/fetch-installed-software.d.ts: -------------------------------------------------------------------------------- 1 | declare module "fetch-installed-software" { 2 | interface Response { 3 | RegistryDirName: string; 4 | DisplayIcon?: string; 5 | DisplayName?: string; 6 | HelpLink?: string; 7 | InstallLocation?: string; 8 | Publisher?: string; 9 | UninstallString?: string; 10 | URLInfoAbout?: string; 11 | NoRepair?: string; 12 | NoModify?: string; 13 | } 14 | 15 | export const getAllInstalledSoftware: () => Promise; 16 | } 17 | -------------------------------------------------------------------------------- /src/types/modpack-metadata.d.ts: -------------------------------------------------------------------------------- 1 | export interface Modpack { 2 | name: string; 3 | logo: string; 4 | backgroundImage?: string; 5 | website: string; 6 | wiki: string; 7 | patreon: string; 8 | roadmap: string; 9 | } 10 | 11 | export interface FriendlyDirectoryMap { 12 | real: string; 13 | friendly: string; 14 | hidden?: boolean; 15 | } 16 | -------------------------------------------------------------------------------- /src/types/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | declare module "*.vue" { 3 | import type { DefineComponent } from "vue"; 4 | const component: DefineComponent<{}, {}, any>; 5 | export default component; 6 | } 7 | -------------------------------------------------------------------------------- /src/types/vu3-popper.d.ts: -------------------------------------------------------------------------------- 1 | declare module "vue3-popper" { 2 | import { Component } from "vue"; 3 | const file: Component; 4 | export default file; 5 | } 6 | -------------------------------------------------------------------------------- /src/types/wabbajack.d.ts: -------------------------------------------------------------------------------- 1 | export interface WabbajackModpackMetadata { 2 | [key: string]: { 3 | title?: string; 4 | version?: string; 5 | installPath: string; 6 | lastUpdated?: Date; 7 | }; 8 | } 9 | 10 | export interface WabbajackV2SettingsFile { 11 | $type: string; 12 | 13 | [key: string]: { 14 | $type: string; 15 | Metadata: unknown; 16 | ModList: { 17 | $type: string; 18 | Archives: Array; 19 | Author: string; 20 | Description: string; 21 | Directives: Array; 22 | GameType: string; 23 | Image: string; 24 | ModManager: 0; 25 | Name: string; 26 | Readme: string; 27 | WabbajackVersion: string; 28 | Website: string; 29 | Version: string; 30 | IsNSFW: boolean; 31 | }; 32 | InstallationPath: string; 33 | DownloadPath: string; 34 | WabbajackPath: string; 35 | InstalledAt: string; 36 | }; 37 | } 38 | 39 | export interface WabbajackInstallSettings { 40 | ModListLocation: string; 41 | InstallLocation: string; 42 | DownloadLoadction: string; 43 | Metadata?: { 44 | title: string; 45 | description: string; 46 | author: string; 47 | maintainers: string[]; 48 | game: string; 49 | official: boolean; 50 | tags: string[]; 51 | nsfw: boolean; 52 | utility_list: boolean; 53 | image_contains_title: boolean; 54 | force_down: boolean; 55 | links: { 56 | image: string; 57 | readme: string; 58 | download: string; 59 | machineURL: string; 60 | discordURL: string; 61 | }; 62 | download_metadata: { 63 | Hash: string; 64 | Size: number; 65 | NumberOfArchives: number; 66 | SizeOfArchives: number; 67 | NumberOfInstalledFiles: number; 68 | SizeOfInstalledFiles: number; 69 | }; 70 | version: string; 71 | dateCreated: string; 72 | dateUpdated: string; 73 | repositoryName: string; 74 | }; 75 | } 76 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2021", 4 | "module": "CommonJS", 5 | "outDir": "dist", 6 | "strict": true, 7 | "jsx": "preserve", 8 | "importHelpers": true, 9 | "noImplicitAny": true, 10 | "noImplicitReturns": false, 11 | "noImplicitThis": true, 12 | "moduleResolution": "node", 13 | "experimentalDecorators": true, 14 | "emitDecoratorMetadata": true, 15 | "skipLibCheck": true, 16 | "esModuleInterop": true, 17 | "allowSyntheticDefaultImports": true, 18 | "sourceMap": true, 19 | "resolveJsonModule": true, 20 | "baseUrl": ".", 21 | "types": [ 22 | "webpack-env", 23 | "mocha" 24 | ], 25 | "paths": { 26 | "@/*": [ 27 | "src/*", 28 | "src/types/*" 29 | ] 30 | }, 31 | "lib": [ 32 | "esnext", 33 | "dom", 34 | "dom.iterable", 35 | "scripthost" 36 | ] 37 | }, 38 | "include": [ 39 | "src/**/*.ts", 40 | "src/**/*.tsx", 41 | "src/**/*.vue" 42 | ], 43 | "exclude": [ 44 | "node_modules" 45 | ] 46 | } 47 | -------------------------------------------------------------------------------- /vue.config.js: -------------------------------------------------------------------------------- 1 | const dependencies = require("./package.json").dependencies; 2 | 3 | module.exports = { 4 | configureWebpack: { 5 | externals: { 6 | electron: "window", 7 | "@/main/logger": "window", 8 | }, 9 | }, 10 | pluginOptions: { 11 | electronBuilder: { 12 | nodeIntegration: false, 13 | externals: Object.keys(dependencies), 14 | bundleMainProcess: false, 15 | mainProcessFile: "dist/main.js", 16 | rendererProcessFile: "src/renderer/index.ts", 17 | outputDir: "dist", 18 | builderOptions: { 19 | productName: "Wildlander Launcher", 20 | icon: "public/images/logos/wildlander-icon-light.png", 21 | publish: { 22 | provider: "github", 23 | releaseType: "release", 24 | }, 25 | extraMetadata: { 26 | name: "Wildlander Launcher", 27 | }, 28 | files: [ 29 | "**", 30 | { 31 | from: "../main", 32 | to: "./main", 33 | filter: ["!*.map*"], 34 | }, 35 | { 36 | from: "../shared", 37 | to: "./shared", 38 | filter: ["!*.map*"], 39 | }, 40 | { 41 | from: "../", 42 | to: ".", 43 | filter: [ 44 | "preload.*", 45 | "modpack.json", 46 | "additional-instructions.json", 47 | ], 48 | }, 49 | ], 50 | extraResources: [ 51 | { 52 | from: "./src/assets/tools", 53 | to: "tools", 54 | filter: ["**/*"], 55 | }, 56 | ], 57 | nsis: { 58 | deleteAppDataOnUninstall: true, 59 | }, 60 | }, 61 | }, 62 | }, 63 | }; 64 | -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | module.exports = function (w) { 2 | return { 3 | files: [ 4 | "src/**/*.ts", 5 | "src/**/*.js", 6 | "src/**/*.json", 7 | "!src/__tests__/unit/**/*.ts", 8 | "tsconfig.json", 9 | ], 10 | tests: ["src/__tests__/unit/**/*.ts"], 11 | 12 | testFramework: "mocha", 13 | 14 | compilers: { 15 | "**/*.ts": w.compilers.typeScript({ 16 | noResolve: false, 17 | }), 18 | }, 19 | 20 | env: { 21 | type: "node", 22 | }, 23 | 24 | setup: function () { 25 | // Enable TypeScript aliases 26 | if (global._tsconfigPathsRegistered) return; 27 | // eslint-disable-next-line @typescript-eslint/no-var-requires 28 | const tsConfigPaths = require("tsconfig-paths"); 29 | // eslint-disable-next-line @typescript-eslint/no-var-requires 30 | const tsconfig = require("./tsconfig.json"); 31 | tsConfigPaths.register({ 32 | baseUrl: tsconfig.compilerOptions.baseUrl, 33 | paths: tsconfig.compilerOptions.paths, 34 | }); 35 | global._tsconfigPathsRegistered = true; 36 | 37 | // Ensure MockFs has fully reset before starting 38 | require("mock-fs").restore(); 39 | }, 40 | }; 41 | }; 42 | --------------------------------------------------------------------------------