├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.md │ └── feature-request.md └── workflows │ ├── build.yml │ ├── codeql.yml │ └── dist.yml ├── .gitignore ├── LICENSE ├── README.md ├── SECURITY.md ├── assets ├── img │ ├── cobblestone.webp │ ├── crafter.webp │ ├── crafting-table.png │ ├── fabric.webp │ ├── forge.svg │ ├── liteloader.webp │ ├── neoforged.webp │ ├── oak-planks.webp │ ├── optifine.webp │ ├── quilt.webp │ ├── rift.webp │ └── tnt.webp ├── logo-legacy.png └── logo.png ├── build-src ├── defines.ts ├── gen-compat-table.ts ├── instrumented-test.ts ├── resources.ts ├── run-build.ts ├── util.ts ├── vendor.ts └── vite-config.ts ├── build.ts ├── bun.lock ├── bunfig.toml ├── config.ts ├── docs └── BUILDING.md ├── jsr-export.ts ├── jsr.json ├── pack.ts ├── package.json ├── public └── i18n │ ├── en │ ├── common.yml │ ├── pages.yml │ └── setup.yml │ └── zh-CN │ ├── common.yml │ ├── pages.yml │ └── setup.yml ├── resources ├── icons │ ├── icon.icns │ ├── icon.ico │ ├── icon.png │ └── icon@2x.png └── steve.png ├── src ├── main │ ├── api │ │ ├── auth.ts │ │ ├── conf.ts │ │ ├── ext.ts │ │ ├── game.ts │ │ ├── i18n.ts │ │ ├── install.ts │ │ ├── launcher.ts │ │ ├── loader.ts │ │ ├── modpack.ts │ │ ├── mpm.ts │ │ ├── sources.ts │ │ └── window.ts │ ├── auth │ │ ├── manage.ts │ │ ├── skin.ts │ │ ├── temp.ts │ │ ├── types.ts │ │ ├── vanilla-oauth.ts │ │ ├── vanilla.ts │ │ └── yggdrasil.ts │ ├── cache │ │ └── cache.ts │ ├── compress │ │ └── lzma.ts │ ├── conf │ │ └── conf.ts │ ├── container │ │ ├── inspect.ts │ │ ├── manage.ts │ │ └── spec.ts │ ├── except │ │ ├── common.ts │ │ ├── exception.ts │ │ └── net.ts │ ├── fs │ │ └── paths.ts │ ├── game │ │ ├── manage.ts │ │ └── spec.ts │ ├── install │ │ ├── except.ts │ │ ├── fabric.ts │ │ ├── forge-compat.ts │ │ ├── forge.ts │ │ ├── installers.ts │ │ ├── liteloader.ts │ │ ├── natives-lint.ts │ │ ├── neoforged.ts │ │ ├── quilt.ts │ │ ├── rift.ts │ │ ├── smelt-legacy.ts │ │ ├── smelt.ts │ │ ├── unfine.ts │ │ └── vanilla.ts │ ├── ipc │ │ ├── channels.ts │ │ ├── checked.ts │ │ └── typed.ts │ ├── jrt │ │ ├── install.ts │ │ └── linux-arm.ts │ ├── launch │ │ ├── args.ts │ │ ├── bl.ts │ │ ├── log-parser.ts │ │ ├── proc.ts │ │ ├── types.ts │ │ └── venv.ts │ ├── main-env.d.ts │ ├── main.ts │ ├── migrate │ │ └── game.ts │ ├── modpack │ │ ├── common.ts │ │ ├── curse.ts │ │ ├── modrinth.ts │ │ └── tools.ts │ ├── mpm │ │ ├── curse.ts │ │ ├── lockfile.ts │ │ ├── modrinth.ts │ │ ├── pm.ts │ │ └── spec.ts │ ├── net │ │ ├── aria2.ts │ │ ├── dlx.ts │ │ ├── mirrors.ts │ │ ├── netx.ts │ │ ├── nextdl.ts │ │ └── ws-rpc.ts │ ├── profile │ │ ├── linker.ts │ │ ├── loader.ts │ │ ├── lwjgl-arm-patch.ts │ │ ├── maven-name.ts │ │ ├── native-lib.ts │ │ ├── profile-adaptor.ts │ │ ├── rules.ts │ │ └── version-profile.ts │ ├── readyboom │ │ └── accounts.ts │ ├── registry │ │ └── registry.ts │ ├── security │ │ ├── encrypt.ts │ │ ├── hash-worker.ts │ │ └── hash.ts │ ├── sys │ │ ├── boot.ts │ │ ├── cleaner.ts │ │ ├── os.ts │ │ ├── ua.ts │ │ ├── update.ts │ │ └── window-control.ts │ └── util │ │ ├── fs.ts │ │ ├── i18n.ts │ │ ├── misc.ts │ │ ├── module.ts │ │ └── progress.ts ├── preload │ ├── message.ts │ ├── preload-env.d.ts │ └── preload.ts ├── refs │ ├── compliance-levels.json │ ├── default-vm-args.json │ ├── jrt-linux-arm.json │ ├── jrt-versions.json │ ├── legacy-assets.json │ ├── legacy-forge-compat.json │ ├── legacy-forge-libs.json │ ├── lwjgl-artifacts.json │ └── rift-compat.json ├── renderer │ ├── App.tsx │ ├── components │ │ ├── compound │ │ │ ├── AddonSearchList.tsx │ │ │ └── ExtendedAccountPicker.tsx │ │ ├── display │ │ │ ├── AddonMetaDisplay.tsx │ │ │ ├── Alert.tsx │ │ │ ├── ExceptionDisplay.tsx │ │ │ ├── GameTypeIcon.tsx │ │ │ ├── SkinAvatar.tsx │ │ │ ├── TipPicker.tsx │ │ │ └── WizardCard.tsx │ │ ├── input │ │ │ ├── AccountPicker.tsx │ │ │ ├── CardRadio.tsx │ │ │ ├── Editable.tsx │ │ │ ├── FileSelectInput.tsx │ │ │ ├── PlayerNameInput.tsx │ │ │ └── StringArrayInput.tsx │ │ ├── misc │ │ │ ├── AnimatedRoute.tsx │ │ │ └── Navigator.tsx │ │ └── modal │ │ │ ├── AccountSelectorDialog.tsx │ │ │ ├── ConfirmPopup.tsx │ │ │ ├── DialogProvider.tsx │ │ │ ├── MessageBox.tsx │ │ │ └── YggdrasilFormDialog.tsx │ ├── fonts.css │ ├── global.css │ ├── i18n │ │ └── i18n.ts │ ├── index.html │ ├── main.tsx │ ├── pages │ │ ├── about │ │ │ ├── AboutView.tsx │ │ │ ├── AppInfo.tsx │ │ │ ├── FeaturesInfo.tsx │ │ │ └── PackagesInfo.tsx │ │ ├── create-from-modpack │ │ │ └── CreateGameFromModpackView.tsx │ │ ├── create-game-wizard │ │ │ ├── CreateGameWizardView.tsx │ │ │ ├── FinishView.tsx │ │ │ ├── PickAccountView.tsx │ │ │ ├── PickModLoaderView.tsx │ │ │ └── PickVersionView.tsx │ │ ├── create-game │ │ │ ├── AssetsLevelSelector.tsx │ │ │ ├── ContainerSelector.tsx │ │ │ ├── CreateGameView.tsx │ │ │ ├── ModLoaderSelector.tsx │ │ │ ├── ModLoaderVersionSelector.tsx │ │ │ └── VersionSelector.tsx │ │ ├── game-detail │ │ │ ├── AccountPanel.tsx │ │ │ ├── AddonsPanel.tsx │ │ │ ├── AdvancedPanel.tsx │ │ │ ├── GameDetailView.tsx │ │ │ ├── GameProfileProvider.tsx │ │ │ ├── LaunchPanel.tsx │ │ │ ├── LocalAddonsPanel.tsx │ │ │ └── ProfilePanel.tsx │ │ ├── games │ │ │ ├── GameCard.tsx │ │ │ ├── GameCardActions.tsx │ │ │ └── GamesView.tsx │ │ ├── import-game │ │ │ ├── ImportGameView.tsx │ │ │ └── ImportGameWarningDialog.tsx │ │ ├── monitor-list │ │ │ └── MonitorListView.tsx │ │ ├── monitor │ │ │ ├── GameProcessProvider.tsx │ │ │ ├── LogsDisplay.tsx │ │ │ ├── MemoryUsageChart.tsx │ │ │ ├── MonitorActions.tsx │ │ │ ├── MonitorView.tsx │ │ │ ├── PerformanceDisplay.tsx │ │ │ ├── StatusDisplay.tsx │ │ │ └── use-color.ts │ │ ├── pages.ts │ │ ├── settings │ │ │ ├── DevTab.tsx │ │ │ ├── LaunchTab.tsx │ │ │ ├── NetworkTab.tsx │ │ │ ├── PreferencesTab.tsx │ │ │ ├── PrivacyTab.tsx │ │ │ ├── SettingsEntry.tsx │ │ │ ├── SettingsView.tsx │ │ │ └── StorageTab.tsx │ │ └── setup │ │ │ ├── AccountInitView.tsx │ │ │ ├── AnalyticsView.tsx │ │ │ ├── FinishView.tsx │ │ │ ├── GamePathSetupView.tsx │ │ │ ├── LanguageView.tsx │ │ │ ├── LicenseView.tsx │ │ │ ├── MirrorView.tsx │ │ │ ├── SetupView.tsx │ │ │ ├── WelcomeView.tsx │ │ │ └── ZoomFactorView.tsx │ ├── services │ │ ├── accounts.ts │ │ ├── conf.ts │ │ ├── games.ts │ │ ├── install.ts │ │ ├── mpm.ts │ │ ├── proc.ts │ │ └── sources.ts │ ├── store │ │ ├── accounts.ts │ │ ├── conf.ts │ │ ├── games.ts │ │ ├── install-progress.ts │ │ ├── mpm.ts │ │ └── store.ts │ ├── theme.tsx │ └── util │ │ ├── misc.ts │ │ └── nav.ts └── shared-env.d.ts ├── tailwind.config.ts ├── test ├── electron-mock.ts ├── instrumented │ ├── cache.ts │ ├── entry.ts │ ├── hash.ts │ ├── install.ts │ ├── jrt.ts │ ├── net.ts │ ├── reg.ts │ └── tools.ts ├── main │ ├── conf.test.ts │ ├── fs.test.ts │ └── profile.test.ts └── resources │ ├── 1.12.2.json │ ├── 1.16.5.json │ ├── 1.20.1.json │ ├── 1.7.10.json │ ├── Circular.json │ ├── Fabric-1.20.1.json │ ├── Forge-1.12.2.json │ └── Forge-1.7.10.json ├── themes.ts ├── themes ├── advanced.ts ├── amazing-grace.ts ├── hoshi.ts ├── overworld.ts ├── sakura-dark.ts ├── sakura-light.ts └── twikie.ts └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [**] 4 | insert_final_newline = true 5 | charset = utf-8 6 | indent_style = space 7 | indent_size = 4 8 | trim_trailing_whitespace = true 9 | 10 | [**.{md,yml,yaml}] 11 | indent_size = 2 12 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Something is not working 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Before we start, are you using the latest version of Alicorn?** 11 | 12 | Yes. (Please note that only the latest version is supported.) 13 | 14 | **First, what's wrong?** 15 | 16 | A clear and concise description of what the bug is. 17 | 18 | **How does it appear? What operations are needed to reproduce it?** 19 | 20 | 1. Go to '...' 21 | 2. Click on '...' 22 | 3. Scroll down to '...' 23 | 4. See error 24 | 25 | **What should happen instead?** 26 | 27 | A clear and concise description of what you expected to happen. 28 | 29 | **Do you have any screenshots or additional information?** 30 | 31 | Please embed them if there are any. 32 | 33 | **Please tell us about your system.** 34 | 35 | - OS: 36 | - Possible Conflicting Software: 37 | 38 | **Please read the following checklist before submitting.** 39 | 40 | - [ ] I've included necessary information in above content. 41 | - [ ] I'm not including any personal information. / I'm aware of the possible consequences of leaking them. 42 | - [ ] I'm using the latest version of Alicorn. 43 | - [ ] This is not a feature request. 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: I want a new feature to exist 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Before we start, are you using the latest version of Alicorn?** 11 | 12 | Yes. (Please note that only the latest version is supported.) 13 | 14 | **What feature do you want to land in Alicorn?** 15 | 16 | Please describe your proposal in brief. 17 | 18 | **Please tell us why it cannot be replaced by existing features.** 19 | 20 | We will prioritize such features comparing to those which can be replaced. 21 | 22 | **Please read the following checklist before submitting.** 23 | 24 | - [ ] I've included necessary information in above content. 25 | - [ ] I'm not including any personal information. / I'm aware of the possible consequences of leaking them. 26 | - [ ] I'm using the latest version of Alicorn. 27 | - [ ] I'm not requesting a feature that allows someone to launch the game without a premium account. 28 | - [ ] This is not a bug report. 29 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Node.js CI 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | jobs: 10 | build: 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | os: [ windows-latest, macos-latest, ubuntu-latest ] 15 | 16 | runs-on: ${{ matrix.os }} 17 | 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: oven-sh/setup-bun@v2 21 | with: 22 | bun-version: "1.2.5" 23 | - name: Install Dependencies 24 | run: bun install 25 | - name: Check Types 26 | run: bun type-check 27 | - name: Run Unit Tests 28 | run: bun test 29 | - name: Deactivate AppImage Restrictions 30 | if: ${{ matrix.os == 'ubuntu-latest' }} 31 | run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 32 | - name: Run Instrumented Tests 33 | run: bun itest medium 34 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL Advanced" 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | schedule: 9 | - cron: '0 0 * * 0' 10 | 11 | jobs: 12 | analyze: 13 | name: Analyze 14 | runs-on: ubuntu-latest 15 | permissions: 16 | security-events: write 17 | 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | include: 22 | - language: javascript-typescript 23 | build-mode: none 24 | 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | 29 | - name: Initialize CodeQL 30 | uses: github/codeql-action/init@v3 31 | with: 32 | languages: ${{ matrix.language }} 33 | build-mode: ${{ matrix.build-mode }} 34 | 35 | - name: Perform CodeQL Analysis 36 | uses: github/codeql-action/analyze@v3 37 | with: 38 | category: "/language:${{matrix.language}}" 39 | -------------------------------------------------------------------------------- /.github/workflows/dist.yml: -------------------------------------------------------------------------------- 1 | name: Distribution 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | workflow_dispatch: { } 7 | 8 | jobs: 9 | build-windows: 10 | runs-on: windows-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v4 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 23 17 | - name: Install Dependencies 18 | run: |- 19 | npm install 20 | - name: Pack 21 | run: npx cross-env ALICORN_PACK_PLATFORMS=win32 npm run dist 22 | - name: List Contents 23 | run: dir dist 24 | - uses: actions/upload-artifact@v4 25 | with: 26 | name: win32-x64-msi 27 | path: "./dist/Alicorn.x64.msi" 28 | compression-level: 0 29 | - uses: actions/upload-artifact@v4 30 | with: 31 | name: win32-arm64-msi 32 | path: "./dist/Alicorn.arm64.msi" 33 | compression-level: 0 34 | 35 | build-macos: 36 | runs-on: macos-latest 37 | 38 | steps: 39 | - uses: actions/checkout@v4 40 | - uses: actions/setup-node@v4 41 | with: 42 | node-version: 23 43 | - name: Install Dependencies 44 | run: |- 45 | npm install 46 | - name: Pack 47 | run: npm run dist 48 | - name: List Contents 49 | run: ls -lh --color=auto ./dist 50 | - uses: actions/upload-artifact@v4 51 | with: 52 | name: win32-x64 53 | path: "./dist/Alicorn Launcher-win32-x64.zip" 54 | compression-level: 0 55 | - uses: actions/upload-artifact@v4 56 | with: 57 | name: win32-arm64 58 | path: "./dist/Alicorn Launcher-win32-arm64.zip" 59 | compression-level: 0 60 | - uses: actions/upload-artifact@v4 61 | with: 62 | name: darwin-x64 63 | path: "./dist/Alicorn Launcher-darwin-x64.dmg" 64 | compression-level: 0 65 | - uses: actions/upload-artifact@v4 66 | with: 67 | name: darwin-arm64 68 | path: "./dist/Alicorn Launcher-darwin-arm64.dmg" 69 | compression-level: 0 70 | - uses: actions/upload-artifact@v4 71 | with: 72 | name: linux-x64 73 | path: "./dist/Alicorn Launcher-linux-x64.tar.gz" 74 | compression-level: 0 75 | - uses: actions/upload-artifact@v4 76 | with: 77 | name: linux-arm64 78 | path: "./dist/Alicorn Launcher-linux-arm64.tar.gz" 79 | compression-level: 0 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | 106 | # Vercel 107 | .vercel 108 | 109 | # Vendor files 110 | vendor 111 | 112 | # Build output 113 | build 114 | out 115 | 116 | # IDEs 117 | .vscode/settings.json 118 | .idea 119 | 120 | # Yarn 121 | .yarn 122 | 123 | # Test Env 124 | emulated 125 | 126 | # Scratch Files 127 | .scratch/ 128 | .vite-cache/ 129 | 130 | # Vim temporary files 131 | *~ 132 | *.swp 133 | *.swo 134 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | Alicorn provides no security updates for versions except the latest stable release. 6 | 7 | As Alicorn is backward-compatible at the same major version, users at the latest major version should update to receive security updates. 8 | 9 | Users running previous major versions should consider upgrading to the latest version. 10 | 11 | ## Reporting a Vulnerability 12 | 13 | To report a vulnerability that's safe to be published, open an issue. 14 | 15 | To report a vulnerability secretly, contact [skjsjhb@outlook.com](mailto:skjsjhb@outlook.com). 16 | 17 | We check the inbox frequently, yet provide no guarantee for the response time. 18 | -------------------------------------------------------------------------------- /assets/img/cobblestone.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Andy-K-Sparklight/Alicorn/bcd243c10748e4e82dc50ea26dd3e7735d73a202/assets/img/cobblestone.webp -------------------------------------------------------------------------------- /assets/img/crafter.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Andy-K-Sparklight/Alicorn/bcd243c10748e4e82dc50ea26dd3e7735d73a202/assets/img/crafter.webp -------------------------------------------------------------------------------- /assets/img/crafting-table.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Andy-K-Sparklight/Alicorn/bcd243c10748e4e82dc50ea26dd3e7735d73a202/assets/img/crafting-table.png -------------------------------------------------------------------------------- /assets/img/fabric.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Andy-K-Sparklight/Alicorn/bcd243c10748e4e82dc50ea26dd3e7735d73a202/assets/img/fabric.webp -------------------------------------------------------------------------------- /assets/img/forge.svg: -------------------------------------------------------------------------------- 1 | 2 | 7 | 11 | 12 | -------------------------------------------------------------------------------- /assets/img/liteloader.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Andy-K-Sparklight/Alicorn/bcd243c10748e4e82dc50ea26dd3e7735d73a202/assets/img/liteloader.webp -------------------------------------------------------------------------------- /assets/img/neoforged.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Andy-K-Sparklight/Alicorn/bcd243c10748e4e82dc50ea26dd3e7735d73a202/assets/img/neoforged.webp -------------------------------------------------------------------------------- /assets/img/oak-planks.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Andy-K-Sparklight/Alicorn/bcd243c10748e4e82dc50ea26dd3e7735d73a202/assets/img/oak-planks.webp -------------------------------------------------------------------------------- /assets/img/optifine.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Andy-K-Sparklight/Alicorn/bcd243c10748e4e82dc50ea26dd3e7735d73a202/assets/img/optifine.webp -------------------------------------------------------------------------------- /assets/img/quilt.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Andy-K-Sparklight/Alicorn/bcd243c10748e4e82dc50ea26dd3e7735d73a202/assets/img/quilt.webp -------------------------------------------------------------------------------- /assets/img/rift.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Andy-K-Sparklight/Alicorn/bcd243c10748e4e82dc50ea26dd3e7735d73a202/assets/img/rift.webp -------------------------------------------------------------------------------- /assets/img/tnt.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Andy-K-Sparklight/Alicorn/bcd243c10748e4e82dc50ea26dd3e7735d73a202/assets/img/tnt.webp -------------------------------------------------------------------------------- /assets/logo-legacy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Andy-K-Sparklight/Alicorn/bcd243c10748e4e82dc50ea26dd3e7735d73a202/assets/logo-legacy.png -------------------------------------------------------------------------------- /assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Andy-K-Sparklight/Alicorn/bcd243c10748e4e82dc50ea26dd3e7735d73a202/assets/logo.png -------------------------------------------------------------------------------- /build-src/defines.ts: -------------------------------------------------------------------------------- 1 | import UserAgent from "user-agents"; 2 | import { type BuildConfig } from "~/config"; 3 | 4 | export type OSName = "windows" | "osx" | "linux" 5 | 6 | function genBuildDefines(config: BuildConfig) { 7 | const { 8 | enableBMCLAPI, 9 | enableNativeLZMA, 10 | devServerPort, 11 | variant: { mode, platform, arch, testLevel } 12 | } = config; 13 | 14 | const osNames: Record = { 15 | win32: "windows", 16 | darwin: "osx", 17 | linux: "linux" 18 | }; 19 | 20 | const fakeUAs = Array(20).fill(0).map(() => new UserAgent().toString()); 21 | 22 | return { 23 | AL_DEV: mode === "development", 24 | AL_TEST: mode === "test", 25 | AL_PLATFORM: platform, 26 | AL_OS: osNames[platform], 27 | AL_ARCH: arch, 28 | AL_ENABLE_BMCLAPI: enableBMCLAPI, 29 | AL_DEV_SERVER_PORT: devServerPort, 30 | AL_ENABLE_NATIVE_LZMA: enableNativeLZMA, 31 | AL_FAKE_UAS: fakeUAs, 32 | AL_TEST_LEVEL: testLevel 33 | }; 34 | } 35 | 36 | function transformBuildDefines(def: BuildDefines): Record { 37 | const o: Record = {}; 38 | 39 | for (const [k, v] of Object.entries(def)) { 40 | o["import.meta.env." + k] = JSON.stringify(v); 41 | } 42 | 43 | return o; 44 | } 45 | 46 | export function createBuildDefines(config: BuildConfig): Record { 47 | return transformBuildDefines(genBuildDefines(config)); 48 | } 49 | 50 | export type BuildDefines = ReturnType; 51 | -------------------------------------------------------------------------------- /build-src/instrumented-test.ts: -------------------------------------------------------------------------------- 1 | import consola from "consola"; 2 | import fs from "fs-extra"; 3 | import child_process from "node:child_process"; 4 | import path from "node:path"; 5 | import { pEvent } from "p-event"; 6 | import { type TestSummary } from "~/test/instrumented/tools"; 7 | 8 | export async function startInstrumentedTest() { 9 | consola.start("start: instrumented tests"); 10 | 11 | const xvfbExec = path.resolve(import.meta.dirname, "..", "node_modules", "xvfb-maybe", "src", "xvfb-maybe.js"); 12 | const electronExec = path.resolve(import.meta.dirname, "..", "node_modules", "electron", "cli.js"); 13 | const cwd = path.resolve(import.meta.dirname, "..", "build", "test"); 14 | const proc = child_process.fork(xvfbExec, [electronExec, "--trace-warnings", "."], { cwd }); 15 | 16 | await pEvent(proc, "exit"); 17 | const f = path.join(cwd, "test-summary.json"); 18 | await printTestSummary(f); 19 | } 20 | 21 | async function printTestSummary(f: string): Promise { 22 | const d = await fs.readJSON(f) as TestSummary; 23 | 24 | for (const s of d.suites) { 25 | if (s.passed) { 26 | consola.success(`${s.name} - PASSED`); 27 | } else { 28 | consola.error(`${s.name} - FAILED`); 29 | consola.error(s.message); 30 | } 31 | } 32 | 33 | if (d.allPassed) { 34 | consola.success("done: all tests have passed."); 35 | process.exit(0); 36 | } else { 37 | consola.error("failed: there are failed tasks, check the output above."); 38 | process.exit(1); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /build-src/resources.ts: -------------------------------------------------------------------------------- 1 | import consola from "consola"; 2 | import fs from "fs-extra"; 3 | import path from "node:path"; 4 | import type { BuildConfig } from "~/config"; 5 | import { linkAll } from "./util"; 6 | import { vendor } from "./vendor"; 7 | 8 | export async function processResources(cfg: BuildConfig): Promise { 9 | const { outputDir } = cfg; 10 | 11 | consola.start("res: linking app resources"); 12 | await linkAll("resources", outputDir); 13 | await emitPackageJson(outputDir); 14 | 15 | consola.start("res: processing vendored files"); 16 | await vendor.prepareAssets(cfg, path.join(outputDir, "vendor")); 17 | 18 | consola.start("res: linking native addons"); 19 | const platform = cfg.variant.platform + "-" + cfg.variant.arch; 20 | 21 | if (cfg.enableNativeLZMA) { 22 | try { 23 | await linkAll(`node_modules/lzma-native/prebuilds/${platform}`, path.join(outputDir, `natives/lzma-native/prebuilds/${platform}`)); 24 | } catch (e) { 25 | consola.error("Unable to link lzma-native prebuilt binaries. (Is it supported?)"); 26 | throw e; 27 | } 28 | } 29 | } 30 | 31 | async function emitPackageJson(outDir: string) { 32 | const src = await fs.readJSON(path.resolve(import.meta.dirname, "..", "package.json")); 33 | 34 | const output = { 35 | name: src.name, 36 | author: src.author, 37 | main: "boot.js", 38 | type: "module", 39 | version: src.version 40 | }; 41 | 42 | await fs.outputJSON(path.join(outDir, "package.json"), output); 43 | } 44 | -------------------------------------------------------------------------------- /build-src/util.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import path from "node:path"; 3 | 4 | export async function linkAll(src: string, dst: string): Promise { 5 | const st = await fs.stat(src); 6 | if (st.isFile()) { 7 | await fs.link(src, dst); 8 | return; 9 | } 10 | 11 | if (st.isDirectory()) { 12 | const files = await fs.readdir(src); 13 | await fs.ensureDir(dst); 14 | 15 | for (const f of files) { 16 | await linkAll(path.join(src, f), path.join(dst, f)); 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /build-src/vite-config.ts: -------------------------------------------------------------------------------- 1 | import react from "@vitejs/plugin-react"; 2 | import reactSWC from "@vitejs/plugin-react-swc"; 3 | import path from "node:path"; 4 | import tailwindcss from "tailwindcss"; 5 | import { defineConfig, PluginOption } from "vite"; 6 | import removeConsole from "vite-plugin-remove-console"; 7 | import tsConfigPaths from "vite-tsconfig-paths"; 8 | 9 | export default defineConfig(({ command }) => { 10 | const isDev = command === "serve"; 11 | 12 | process.env.NODE_ENV = isDev ? "development" : "production"; 13 | 14 | return { 15 | root: path.resolve(import.meta.dirname, "..", "src", "renderer"), 16 | appType: "mpa", 17 | base: "", 18 | publicDir: path.resolve(import.meta.dirname, "..", "public"), 19 | cacheDir: path.resolve(import.meta.dirname, "..", ".vite-cache"), 20 | build: { 21 | // Output directory will be specified when building 22 | emptyOutDir: true, 23 | chunkSizeWarningLimit: 1024, 24 | rollupOptions: { 25 | output: { 26 | manualChunks: isDev ? undefined : { 27 | heroui: ["@heroui/react"], 28 | theme: ["@heroui/theme"], 29 | lucide: ["lucide-react"] 30 | } 31 | } 32 | } 33 | }, 34 | plugins: [ 35 | isDev ? 36 | reactSWC() : 37 | // Use React Compiler in production for better performance 38 | react({ 39 | babel: { 40 | plugins: [ 41 | ["babel-plugin-react-compiler", { target: "19" }] 42 | ] 43 | } 44 | }), 45 | tsConfigPaths(), 46 | i18nHotReload(), 47 | removeConsole() 48 | ], 49 | define: {}, 50 | css: { 51 | postcss: { 52 | plugins: [tailwindcss()] 53 | } 54 | }, 55 | server: { 56 | warmup: { 57 | clientFiles: ["index.html"] 58 | } 59 | }, 60 | esbuild: { 61 | legalComments: "none" 62 | } 63 | }; 64 | }); 65 | 66 | function i18nHotReload(): PluginOption { 67 | return { 68 | name: "i18n-hot-reload", 69 | handleHotUpdate({ file, server }) { 70 | if (file.includes("i18n") && file.endsWith(".yml")) { 71 | server.ws.send({ 72 | type: "custom", 73 | event: "locales-update" 74 | }); 75 | } 76 | } 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /build.ts: -------------------------------------------------------------------------------- 1 | import consola from "consola"; 2 | import os from "node:os"; 3 | import { build } from "./build-src/run-build"; 4 | import type { BuildMode, TestLevel } from "./config"; 5 | 6 | const mode = process.argv[2] || "development"; 7 | if (!["development", "production", "test"].includes(mode)) { 8 | consola.error(`Unknown build mode: ${mode}`); 9 | process.exit(1); 10 | } 11 | 12 | let testLevel = process.argv[3] || "lite"; 13 | 14 | if (!["lite", "medium", "full"].includes(testLevel)) { 15 | consola.error(`Unknown test level: ${testLevel}`); 16 | process.exit(1); 17 | } 18 | 19 | await build({ 20 | mode: mode as BuildMode, 21 | platform: os.platform(), 22 | arch: os.arch(), 23 | testLevel: testLevel as TestLevel 24 | }); 25 | -------------------------------------------------------------------------------- /bunfig.toml: -------------------------------------------------------------------------------- 1 | [test] 2 | preload = ["./test/electron-mock.ts"] 3 | -------------------------------------------------------------------------------- /config.ts: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | 3 | export type BuildMode = "development" | "production" | "test" 4 | export type TestLevel = "lite" | "medium" | "full"; 5 | 6 | 7 | export interface BuildVariant { 8 | mode: BuildMode; 9 | platform: string; 10 | arch: string; 11 | testLevel: TestLevel; 12 | } 13 | 14 | export function createBuildConfig(variant: BuildVariant) { 15 | const { platform, arch, mode } = variant; 16 | 17 | return { 18 | // Build variant object. 19 | variant, 20 | 21 | // Output directory 22 | outputDir: path.resolve(import.meta.dirname, "build", mode), 23 | 24 | // BMCLAPI provides mirrors to speed up resources delivering in some regions. 25 | // Make sure that the users read . 26 | enableBMCLAPI: true, 27 | 28 | // Decompression of LZMA is handled by lzma-native by default, yet not available on all platforms. 29 | // Disabling this option enforces Alicorn to fall back to a pure JavaScript implementation. 30 | // JavaScript version can be slower and does not support streaming. 31 | // This option is (by default) disabled for win32-arm64 and enabled for other platforms. 32 | enableNativeLZMA: !(platform === "win32" && arch === "arm64"), 33 | 34 | // Port to be used when hosting HMR content for renderer. 35 | devServerPort: 9000 36 | }; 37 | } 38 | 39 | export type BuildConfig = ReturnType; 40 | -------------------------------------------------------------------------------- /jsr-export.ts: -------------------------------------------------------------------------------- 1 | import pkg from "./package.json" with { type: "json" }; 2 | 3 | export default { 4 | codename: pkg.codename, 5 | version: pkg.version 6 | }; 7 | -------------------------------------------------------------------------------- /jsr.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@skjsjhb/alicorn-launcher", 3 | "version": "3.0.2", 4 | "license": "GPL-3.0-or-later", 5 | "exports": "./jsr-export.ts" 6 | } 7 | -------------------------------------------------------------------------------- /resources/icons/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Andy-K-Sparklight/Alicorn/bcd243c10748e4e82dc50ea26dd3e7735d73a202/resources/icons/icon.icns -------------------------------------------------------------------------------- /resources/icons/icon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Andy-K-Sparklight/Alicorn/bcd243c10748e4e82dc50ea26dd3e7735d73a202/resources/icons/icon.ico -------------------------------------------------------------------------------- /resources/icons/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Andy-K-Sparklight/Alicorn/bcd243c10748e4e82dc50ea26dd3e7735d73a202/resources/icons/icon.png -------------------------------------------------------------------------------- /resources/icons/icon@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Andy-K-Sparklight/Alicorn/bcd243c10748e4e82dc50ea26dd3e7735d73a202/resources/icons/icon@2x.png -------------------------------------------------------------------------------- /resources/steve.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Andy-K-Sparklight/Alicorn/bcd243c10748e4e82dc50ea26dd3e7735d73a202/resources/steve.png -------------------------------------------------------------------------------- /src/main/api/auth.ts: -------------------------------------------------------------------------------- 1 | import { accounts } from "@/main/auth/manage"; 2 | import { skin } from "@/main/auth/skin"; 3 | import { VanillaAccount, type VanillaAccountProps } from "@/main/auth/vanilla"; 4 | import { YggdrasilAccount, type YggdrasilAccountProps } from "@/main/auth/yggdrasil"; 5 | import { games } from "@/main/game/manage"; 6 | import { addCheckedHandler } from "@/main/ipc/checked"; 7 | import { reg } from "@/main/registry/registry"; 8 | import { alter } from "@/main/util/misc"; 9 | 10 | export type GameAuthResult = true | { host: string, email: string } 11 | 12 | addCheckedHandler("gameAuth", async (gameId, pwd) => { 13 | const g = games.get(gameId); 14 | const aid = g.launchHint.accountId === "new" ? "" : g.launchHint.accountId; 15 | const a = aid ? accounts.get(aid) : new VanillaAccount(); 16 | 17 | if (a instanceof YggdrasilAccount) { 18 | if (pwd) { 19 | await a.login(pwd); 20 | } else { 21 | try { 22 | await a.refresh(); 23 | } catch { 24 | return { host: a.host, email: a.email }; 25 | } 26 | } 27 | } else { 28 | await a.refresh(); 29 | } 30 | 31 | if (!aid) { 32 | games.add(alter(g, g => g.launchHint.accountId = a.uuid)); 33 | } 34 | 35 | // Update account 36 | accounts.add(a); 37 | return true; 38 | }); 39 | 40 | addCheckedHandler("listAccounts", () => reg.accounts.entries().map(([k, v]) => ({ ...v, uuid: k }))); 41 | 42 | addCheckedHandler("createVanillaAccount", async () => { 43 | const a = new VanillaAccount(); 44 | await a.refresh(); 45 | accounts.add(a); 46 | return a.toProps() as VanillaAccountProps; 47 | }); 48 | 49 | addCheckedHandler("createYggdrasilAccount", async (host, email, pwd) => { 50 | const a = new YggdrasilAccount(host, email); 51 | await a.login(pwd); 52 | accounts.add(a); 53 | return a.toProps() as YggdrasilAccountProps; 54 | }); 55 | 56 | addCheckedHandler("getAccountSkin", async accountId => { 57 | try { 58 | const a = accounts.get(accountId); 59 | return await skin.getSkin(a); 60 | } catch (e) { 61 | console.error("Unable to query skin"); 62 | console.error(e); 63 | return ""; 64 | } 65 | }); 66 | 67 | addCheckedHandler("getAccountSkinAvatar", async accountId => { 68 | try { 69 | const a = accounts.get(accountId); 70 | return await skin.getSkinAvatar(a); 71 | } catch (e) { 72 | console.error("Unable to query skin"); 73 | console.error(e); 74 | return ["", ""] as const; 75 | } 76 | }); 77 | -------------------------------------------------------------------------------- /src/main/api/conf.ts: -------------------------------------------------------------------------------- 1 | import { conf, type UserConfig } from "@/main/conf/conf"; 2 | import { addCheckedHandler } from "@/main/ipc/checked"; 3 | import { ipcMain } from "@/main/ipc/typed"; 4 | 5 | addCheckedHandler("getConfig", () => conf()); 6 | 7 | ipcMain.on("updateConfig", (_, c: UserConfig) => conf.update(c)); 8 | -------------------------------------------------------------------------------- /src/main/api/ext.ts: -------------------------------------------------------------------------------- 1 | import { addCheckedHandler } from "@/main/ipc/checked"; 2 | import { ipcMain } from "@/main/ipc/typed"; 3 | import { dialog, shell } from "electron"; 4 | 5 | ipcMain.on("openUrl", (_, url: string) => { 6 | void shell.openExternal(url, { activate: true }); 7 | }); 8 | 9 | addCheckedHandler("selectDir", async () => { 10 | const { filePaths } = await dialog.showOpenDialog({ 11 | properties: ["openDirectory", "createDirectory", "promptToCreate", "dontAddToRecent"] 12 | }); 13 | 14 | return filePaths[0] ?? ""; 15 | }); 16 | 17 | addCheckedHandler("selectFile", async (filters) => { 18 | const { filePaths } = await dialog.showOpenDialog({ 19 | properties: ["openFile", "dontAddToRecent"], 20 | filters 21 | }); 22 | 23 | return filePaths[0] ?? ""; 24 | }); 25 | -------------------------------------------------------------------------------- /src/main/api/i18n.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from "@/main/ipc/typed"; 2 | import { i18nMain } from "@/main/util/i18n"; 3 | 4 | ipcMain.on("languageChange", (_, lang) => { 5 | i18nMain.language = lang; 6 | }); 7 | -------------------------------------------------------------------------------- /src/main/api/launcher.ts: -------------------------------------------------------------------------------- 1 | import { games } from "@/main/game/manage"; 2 | import { addCheckedHandler } from "@/main/ipc/checked"; 3 | import { ipcMain } from "@/main/ipc/typed"; 4 | import { bl } from "@/main/launch/bl"; 5 | import type { GameProcessLog } from "@/main/launch/log-parser"; 6 | import { MessagePortMain } from "electron"; 7 | import type EventEmitter from "node:events"; 8 | 9 | export interface LaunchGameResult { 10 | id: string; 11 | pid: number; 12 | } 13 | 14 | addCheckedHandler("launch", async gameId => { 15 | const launchHint = games.get(gameId).launchHint; 16 | 17 | const g = await bl.launch(launchHint); 18 | 19 | return { 20 | id: g.id, 21 | pid: g.pid() 22 | }; 23 | }); 24 | 25 | ipcMain.on("subscribeGameEvents", (e, gid: string) => { 26 | const g = bl.getInstance(gid); 27 | const [port] = e.ports; 28 | forwardGameEvents(g.emitter, port); 29 | 30 | g.emitter.once("end", () => port.close()); 31 | }); 32 | 33 | ipcMain.on("stopGame", (_, procId: string) => { 34 | bl.getInstance(procId).stop(); 35 | }); 36 | 37 | ipcMain.on("removeGame", (_, procId: string) => { 38 | bl.removeInstance(procId); 39 | }); 40 | 41 | export type GameProcEvent = 42 | { 43 | type: "end" | "crash" | "exit"; 44 | } | 45 | { 46 | type: "stdout" | "stderr"; 47 | data: string; 48 | } | 49 | { 50 | type: "log"; 51 | log: GameProcessLog 52 | } | 53 | { 54 | type: "memUsageUpdate", 55 | mem: number 56 | } 57 | 58 | function forwardGameEvents(src: EventEmitter, dst: MessagePortMain) { 59 | function send(data: GameProcEvent) { 60 | dst.postMessage(data); 61 | } 62 | 63 | src.on("end", () => send({ type: "end" })); 64 | src.on("crash", () => send({ type: "crash" })); 65 | src.on("exit", () => send({ type: "exit" })); 66 | src.on("stdout", data => send({ type: "stdout", data })); 67 | src.on("stderr", data => send({ type: "stderr", data })); 68 | src.on("log", log => send({ type: "log", log })); 69 | src.on("memUsageUpdate", mem => send({ type: "memUsageUpdate", mem })); 70 | } 71 | -------------------------------------------------------------------------------- /src/main/api/loader.ts: -------------------------------------------------------------------------------- 1 | import "./conf"; 2 | import "./ext"; 3 | import "./window"; 4 | import "./game"; 5 | import "./launcher"; 6 | import "./auth"; 7 | import "./sources"; 8 | import "./install"; 9 | import "./i18n"; 10 | import "./mpm"; 11 | import "./modpack"; 12 | -------------------------------------------------------------------------------- /src/main/api/modpack.ts: -------------------------------------------------------------------------------- 1 | import { addCheckedHandler } from "@/main/ipc/checked"; 2 | import { modpacks } from "@/main/modpack/common"; 3 | 4 | addCheckedHandler("readModpack", async fp => { 5 | try { 6 | return await modpacks.loadPackMeta(fp); 7 | } catch (e) { 8 | console.error(`Unable to read modpack meta from ${fp}`); 9 | console.error(e); 10 | return null; 11 | } 12 | }); 13 | 14 | addCheckedHandler("deployModpack", async fp => { 15 | await modpacks.deploy(fp); 16 | }); 17 | -------------------------------------------------------------------------------- /src/main/api/sources.ts: -------------------------------------------------------------------------------- 1 | import { vanillaInstaller } from "@/main/install/vanilla"; 2 | import { addCheckedHandler } from "@/main/ipc/checked"; 3 | 4 | addCheckedHandler("getVersionManifest", () => vanillaInstaller.getManifest()); 5 | -------------------------------------------------------------------------------- /src/main/api/window.ts: -------------------------------------------------------------------------------- 1 | import { ipcMain } from "@/main/ipc/typed"; 2 | import { BrowserWindow } from "electron"; 3 | 4 | ipcMain.on("showWindow", (e) => BrowserWindow.fromWebContents(e.sender)?.show()); 5 | ipcMain.on("hideWindow", (e) => BrowserWindow.fromWebContents(e.sender)?.hide()); 6 | ipcMain.on("minimizeWindow", (e) => BrowserWindow.fromWebContents(e.sender)?.minimize()); 7 | ipcMain.on("closeWindow", (e) => BrowserWindow.fromWebContents(e.sender)?.close()); 8 | 9 | ipcMain.on("setZoom", (e, v) => { 10 | const rv = v / 100; 11 | e.sender.setZoomFactor(rv); 12 | }); 13 | -------------------------------------------------------------------------------- /src/main/auth/manage.ts: -------------------------------------------------------------------------------- 1 | import { TemporalAccount } from "@/main/auth/temp"; 2 | import type { Account, AccountProps } from "@/main/auth/types"; 3 | import { VanillaAccount } from "@/main/auth/vanilla"; 4 | import { YggdrasilAccount } from "@/main/auth/yggdrasil"; 5 | import { reg } from "@/main/registry/registry"; 6 | import { windowControl } from "@/main/sys/window-control"; 7 | 8 | const accountMap = new Map(); 9 | 10 | function loadFromProps(props: AccountProps): Account { 11 | switch (props.type) { 12 | case "vanilla": 13 | return VanillaAccount.fromProps(props); 14 | case "local": 15 | return TemporalAccount.fromProps(props); 16 | case "yggdrasil": 17 | return YggdrasilAccount.fromProps(props); 18 | } 19 | } 20 | 21 | function get(accountId: string): Account { 22 | // Single-instance of accounts are important to avoid conflict during authentication 23 | let a = accountMap.get(accountId); 24 | if (!a) { 25 | a = loadFromProps(reg.accounts.get(accountId)); 26 | accountMap.set(accountId, a); 27 | } 28 | 29 | return a; 30 | } 31 | 32 | function add(account: Account) { 33 | reg.accounts.add(account.uuid, account.toProps()); 34 | accountMap.set(account.uuid, account); 35 | windowControl.getMainWindow()?.webContents.send("accountChanged"); 36 | } 37 | 38 | export const accounts = { 39 | get, add 40 | }; 41 | -------------------------------------------------------------------------------- /src/main/auth/skin.ts: -------------------------------------------------------------------------------- 1 | import type { Account } from "@/main/auth/types"; 2 | import { VanillaAccount } from "@/main/auth/vanilla"; 3 | import { YggdrasilAccount } from "@/main/auth/yggdrasil"; 4 | import { paths } from "@/main/fs/paths"; 5 | import { netx } from "@/main/net/netx"; 6 | import { nativeImage } from "electron"; 7 | import fs from "fs-extra"; 8 | 9 | interface SkinQueryResponse { 10 | properties: { name: string, value: string }[]; 11 | } 12 | 13 | interface SkinPayload { 14 | textures: { 15 | SKIN: { 16 | url: string; 17 | } 18 | }; 19 | } 20 | 21 | async function loadSkin(url: string): Promise { 22 | const res = await netx.json(url) as SkinQueryResponse; 23 | 24 | const v = res.properties.find(p => p.name === "textures")?.value; 25 | if (!v) return ""; 26 | const payload = JSON.parse(Buffer.from(v, "base64").toString()) as SkinPayload; 27 | return payload.textures.SKIN.url; 28 | } 29 | 30 | async function getVanillaSkin(uuid: string): Promise { 31 | const url = `https://sessionserver.mojang.com/session/minecraft/profile/${uuid}`; 32 | return await loadSkin(url); 33 | } 34 | 35 | async function getYggdrasilSkin(a: YggdrasilAccount): Promise { 36 | const url = `${a.host}/sessionserver/session/minecraft/profile/${a.uuid}`; 37 | return await loadSkin(url); 38 | } 39 | 40 | 41 | async function getSkin(a: Account): Promise { 42 | if (a instanceof VanillaAccount) { 43 | return getVanillaSkin(a.uuid); 44 | } 45 | 46 | if (a instanceof YggdrasilAccount) { 47 | return getYggdrasilSkin(a); 48 | } 49 | 50 | return ""; 51 | } 52 | 53 | const skinCache = new Map(); 54 | 55 | /** 56 | * Fetches the skin and crops the head and helm textures. Returns an array of data URLs. 57 | */ 58 | async function getSkinAvatar(a: Account): Promise<[string, string]> { 59 | let dat = skinCache.get(a.uuid); 60 | 61 | if (!dat) { 62 | const url = await getSkin(a); 63 | 64 | if (url) { 65 | const res = await netx.request(url); 66 | dat = Buffer.from(await res.arrayBuffer()); 67 | } else { 68 | // Loads a fallback image 69 | dat = await fs.readFile(paths.app.to("steve.png")); 70 | } 71 | 72 | skinCache.set(a.uuid, dat); 73 | } 74 | 75 | const img = nativeImage.createFromBuffer(dat); 76 | const headFront = img.crop({ x: 8, y: 8, width: 8, height: 8 }); 77 | const helmFront = img.crop({ x: 40, y: 8, width: 8, height: 8 }); 78 | 79 | return [headFront.toDataURL(), helmFront.toDataURL()]; 80 | } 81 | 82 | export const skin = { getSkin, getSkinAvatar }; 83 | -------------------------------------------------------------------------------- /src/main/auth/temp.ts: -------------------------------------------------------------------------------- 1 | import { Account, type AccountProps, AuthCredentials } from "@/main/auth/types"; 2 | import crypto from "node:crypto"; 3 | 4 | /** 5 | * Provided to the user as an alternative authenticate method when failed to log-in. 6 | * 7 | * We can assume that the user owns a valid premium account as it's already checked during setup. 8 | * Therefore, this class simply emulates the behavior from the vanilla launcher (so-called "offline mode"). 9 | */ 10 | export class TemporalAccount implements Account { 11 | uuid; 12 | #name: string; 13 | 14 | static fromProps(props: TemporalAccountProps): TemporalAccount { 15 | return new TemporalAccount(props.playerName); 16 | } 17 | 18 | constructor(name: string) { 19 | this.#name = name; 20 | this.uuid = offlineUUIDOf(name); 21 | } 22 | 23 | credentials(): AuthCredentials { 24 | return { 25 | playerName: this.#name, 26 | uuid: this.uuid, 27 | accessToken: "0", 28 | xboxId: "0", 29 | userType: "mojang" 30 | }; 31 | } 32 | 33 | async refresh(): Promise {} 34 | 35 | toProps(): AccountProps { 36 | return { 37 | type: "local", 38 | playerName: this.#name 39 | }; 40 | } 41 | } 42 | 43 | /** 44 | * Calculates a Spigot-compatible UUID of an offline player with the given name. 45 | */ 46 | function offlineUUIDOf(name: string): string { 47 | const bytes = crypto.createHash("md5").update(`OfflinePlayer:${name}`).digest(); 48 | bytes[6] &= 0x0f; 49 | bytes[6] |= 0x30; 50 | bytes[8] &= 0x3f; 51 | bytes[8] |= 0x80; 52 | return bytes.toString("hex").toLowerCase(); 53 | } 54 | 55 | export interface TemporalAccountProps { 56 | type: "local"; 57 | playerName: string; 58 | } 59 | -------------------------------------------------------------------------------- /src/main/auth/types.ts: -------------------------------------------------------------------------------- 1 | import type { TemporalAccountProps } from "@/main/auth/temp"; 2 | import type { VanillaAccountProps } from "@/main/auth/vanilla"; 3 | import type { YggdrasilAccountProps } from "@/main/auth/yggdrasil"; 4 | import { AbstractException } from "@/main/except/exception"; 5 | import type { RegistryTransformer } from "@/main/registry/registry"; 6 | 7 | export interface AuthCredentials { 8 | accessToken: string; 9 | uuid: string; 10 | xboxId: string; 11 | playerName: string; 12 | userType: string; 13 | } 14 | 15 | export interface Account { 16 | uuid: string; 17 | 18 | /** 19 | * Refresh the account. 20 | */ 21 | refresh(): Promise; 22 | 23 | /** 24 | * Export credentials. 25 | */ 26 | credentials(): AuthCredentials; 27 | 28 | /** 29 | * Create props for serialization. 30 | */ 31 | toProps(): AccountProps; 32 | } 33 | 34 | export type AccountProps = TemporalAccountProps | VanillaAccountProps | YggdrasilAccountProps; 35 | 36 | export type DetailedAccountProps = AccountProps & { uuid: string; } 37 | 38 | export class AuthFailedException extends AbstractException<"auth-failed"> { 39 | #cause?: unknown; 40 | 41 | constructor(cause?: unknown) { 42 | super("auth-failed", {}, cause); 43 | this.#cause = cause; 44 | } 45 | 46 | toString(): string { 47 | return `Authentication failed: ${this.#cause}`; 48 | } 49 | } 50 | 51 | export const ACCOUNT_REG_VERSION = 0; 52 | export const ACCOUNT_REG_TRANS: RegistryTransformer[] = []; 53 | -------------------------------------------------------------------------------- /src/main/compress/lzma.ts: -------------------------------------------------------------------------------- 1 | import { paths } from "@/main/fs/paths"; 2 | import { unwrapESM } from "@/main/util/module"; 3 | import fs from "fs-extra"; 4 | import type lzmaJS from "lzma"; 5 | import type lzmaNative from "lzma-native"; 6 | import path from "node:path"; 7 | import { pipeline } from "stream/promises"; 8 | import pkg from "~/package.json"; 9 | 10 | let lzmaNativeMod: typeof lzmaNative; 11 | let lzmaJSMod: typeof lzmaJS; 12 | 13 | 14 | async function init() { 15 | if (lzmaNativeMod || lzmaJSMod) return; // Already loaded 16 | 17 | if (import.meta.env.AL_ENABLE_NATIVE_LZMA) { 18 | // lzma-native uses node-gyp to resolve the prebuilt native modules 19 | // The path is not recognized nor bundled by ESBuild 20 | // By assigning to the magic variable we can override the resolution base directory 21 | const canonicalName = pkg.name.toUpperCase().replaceAll("-", "_") + "_PREBUILD"; 22 | const ap = process.env[canonicalName]; 23 | process.env[canonicalName] = paths.app.to("natives/lzma-native"); 24 | lzmaNativeMod = (await unwrapESM(import("lzma-native"))).default; 25 | process.env[canonicalName] = ap; 26 | } else { 27 | // @ts-expect-error A workaround for using modules not exported 28 | const m = await unwrapESM(import("lzma/src/lzma_worker-min.js")); 29 | lzmaJSMod = m.default.LZMA_WORKER as typeof lzmaJS; 30 | } 31 | } 32 | 33 | 34 | /** 35 | * Inflate a file known to be of LZMA format. 36 | */ 37 | async function inflate(src: string, dst: string) { 38 | await fs.ensureDir(path.dirname(dst)); 39 | 40 | if (import.meta.env.AL_ENABLE_NATIVE_LZMA) { 41 | // Native impl 42 | const rs = fs.createReadStream(src); 43 | const ts = lzmaNativeMod.createDecompressor(); 44 | const ws = fs.createWriteStream(dst); 45 | 46 | await pipeline(rs, ts, ws); 47 | } else { 48 | // JS impl 49 | const dat = await fs.readFile(src); 50 | const { promise, resolve, reject } = Promise.withResolvers(); 51 | 52 | lzmaJSMod.decompress(dat, (r, e) => { 53 | if (e) { 54 | reject(e); 55 | } else { 56 | resolve(Buffer.from(r)); 57 | } 58 | }); 59 | 60 | await fs.writeFile(dst, await promise); 61 | } 62 | } 63 | 64 | export const lzma = { inflate, init }; 65 | -------------------------------------------------------------------------------- /src/main/container/inspect.ts: -------------------------------------------------------------------------------- 1 | import type { Container } from "@/main/container/spec"; 2 | import fs from "fs-extra"; 3 | 4 | /** 5 | * Creates game content directory of the given scope. 6 | */ 7 | async function createContentDir(c: Container, scope: string): Promise { 8 | const d = c.content(scope); 9 | if (await fs.pathExists(d)) return; 10 | 11 | await fs.ensureDir(d); 12 | } 13 | 14 | 15 | export const containerInspector = { 16 | createContentDir 17 | }; 18 | -------------------------------------------------------------------------------- /src/main/except/common.ts: -------------------------------------------------------------------------------- 1 | import { AbstractException, coerceErrorMessage } from "@/main/except/exception"; 2 | 3 | export class UnknownException extends AbstractException<"unknown"> { 4 | #exs: string; 5 | 6 | constructor(ex: unknown) { 7 | const e = coerceErrorMessage(ex); 8 | super("unknown", { err: e }); 9 | this.#exs = e; 10 | } 11 | 12 | toString(): string { 13 | return this.#exs; 14 | } 15 | } 16 | 17 | export class CancelledException extends AbstractException<"cancelled"> { 18 | constructor() { 19 | super("cancelled", {}); 20 | } 21 | 22 | toString(): string { 23 | return "Operation cancelled"; 24 | } 25 | } 26 | 27 | export class NoSuchElementException extends AbstractException<"no-such-element"> { 28 | #id: string; 29 | 30 | constructor(id: string) { 31 | super("no-such-element", { id }); 32 | this.#id = id; 33 | } 34 | 35 | toString(): string { 36 | return `Element not found: ${this.#id}`; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/except/exception.ts: -------------------------------------------------------------------------------- 1 | import { UnknownException } from "@/main/except/common"; 2 | 3 | interface CatchyExceptionType { 4 | unknown: { err: string }; 5 | cancelled: {}; 6 | "no-handler-registered": { method: string }; 7 | "no-such-element": { id: string }; 8 | "net-request-failed": { url: string, code?: number }; 9 | "net-mirrors-all-failed": { url: string }; 10 | "launch-spawn-failed": { err: string }; 11 | "download-failed": { url: string }; 12 | "profile-link-failed": { id: string }; 13 | "auth-failed": {}; 14 | "unavailable-mod-loader": { version: string }; 15 | "optifine-install-failed": { code: number }; 16 | "jrt-install-failed": { component: string }; 17 | "forge-install-failed": {}; 18 | } 19 | 20 | export type SerializedException = { 21 | _ALICORN_CHECKED_EXCEPTION: true; 22 | name: K; 23 | props: CatchyExceptionType[K]; 24 | stack?: string; 25 | cause?: SerializedException; 26 | } 27 | 28 | interface SerializableException { 29 | serialize(): SerializedException; 30 | 31 | toJSON(): string; 32 | 33 | toString(): string; 34 | } 35 | 36 | function getStack() { 37 | const ex = new Error().stack?.split("\n"); 38 | if (!ex) return ""; 39 | return ex.slice(3).join("\n"); // Drop caller stack and skip `AbstractException` for a shorter stacktrace 40 | } 41 | 42 | export class AbstractException implements SerializableException { 43 | #except: SerializedException; 44 | 45 | constructor(name: K, props: CatchyExceptionType[K], cause?: unknown) { 46 | const ex = cause === undefined ? undefined : (cause instanceof AbstractException ? cause : new UnknownException(cause)); 47 | this.#except = { 48 | _ALICORN_CHECKED_EXCEPTION: true, 49 | name, 50 | props, 51 | cause: ex?.serialize(), 52 | stack: getStack() 53 | }; 54 | } 55 | 56 | serialize(): SerializedException { 57 | return this.#except; 58 | } 59 | 60 | toJSON(): string { 61 | return JSON.stringify(this.serialize()); 62 | } 63 | 64 | toString(): string { 65 | return "Abstract (uninitialized) exception. This method should be overridden to suppress this message."; 66 | } 67 | } 68 | 69 | export function coerceErrorMessage(ex: unknown) { 70 | if (typeof ex === "object" && ex !== null) { 71 | if ("message" in ex && typeof ex.message === "string") return ex.message; 72 | } 73 | 74 | if (typeof ex === "string") return ex; 75 | 76 | return String(ex); 77 | } 78 | -------------------------------------------------------------------------------- /src/main/except/net.ts: -------------------------------------------------------------------------------- 1 | import { AbstractException } from "@/main/except/exception"; 2 | 3 | export class NetRequestFailedException extends AbstractException<"net-request-failed"> { 4 | #url: string; 5 | #code?: number; 6 | 7 | constructor(url: string, code?: number) { 8 | super("net-request-failed", { url, code }); 9 | this.#url = url; 10 | this.#code = code; 11 | } 12 | 13 | toString(): string { 14 | return `Request failed for ${this.#url}: ${this.#code}`; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/main/game/manage.ts: -------------------------------------------------------------------------------- 1 | import type { GameProfile } from "@/main/game/spec"; 2 | import { reg } from "@/main/registry/registry"; 3 | import { windowControl } from "@/main/sys/window-control"; 4 | 5 | function emitChange() { 6 | windowControl.getMainWindow()?.webContents.send("gameChanged"); 7 | } 8 | 9 | /** 10 | * Add or update the given profile. 11 | */ 12 | function add(profile: GameProfile) { 13 | reg.games.add(profile.id, profile); 14 | emitChange(); 15 | } 16 | 17 | function remove(id: string) { 18 | reg.games.remove(id); 19 | emitChange(); 20 | } 21 | 22 | /** 23 | * Checks whether the specified game is being installed on the same container with at least one other game. 24 | */ 25 | function queryShared(id: string): string[] { 26 | const g = get(id); 27 | 28 | if (!g) return []; 29 | 30 | return reg.games.getAll() 31 | .filter(gp => gp.id !== g.id && gp.launchHint.containerId === g.launchHint.containerId) 32 | .map(gp => gp.id); 33 | } 34 | 35 | function get(id: string) { 36 | return reg.games.get(id); 37 | } 38 | 39 | export const games = { 40 | add, remove, queryShared, get, genId 41 | }; 42 | 43 | function genId(): string { 44 | let i = 1; 45 | while (true) { 46 | const st = "#" + i; 47 | if (!reg.games.has(st)) { 48 | return st; 49 | } 50 | i++; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/game/spec.ts: -------------------------------------------------------------------------------- 1 | import type { InstallerProps } from "@/main/install/installers"; 2 | import type { LaunchHint } from "@/main/launch/types"; 3 | import type { RegistryTransformer } from "@/main/registry/registry"; 4 | 5 | export type GameAssetsLevel = "full" | "video-only"; 6 | 7 | export interface GameProfile { 8 | /** 9 | * Game identifier. 10 | */ 11 | id: string; 12 | 13 | /** 14 | * User-defined name. 15 | */ 16 | name: string; 17 | 18 | /** 19 | * Time last accessed. 20 | */ 21 | time: number; 22 | 23 | /** 24 | * Whether the game has been installed. 25 | */ 26 | installed: boolean; 27 | 28 | /** 29 | * Props for installing the game. 30 | */ 31 | installProps: InstallerProps; 32 | 33 | /** 34 | * Game related versions. 35 | */ 36 | versions: { game: string } & Record; 37 | 38 | /** 39 | * Assets level for downloading partial assets. 40 | */ 41 | assetsLevel: GameAssetsLevel; 42 | 43 | /** 44 | * Launch hint object. 45 | */ 46 | launchHint: LaunchHint; 47 | 48 | /** 49 | * User preference object. 50 | */ 51 | user: { 52 | /** 53 | * The priority of the pinned game. 54 | */ 55 | pinTime?: number; 56 | }; 57 | 58 | /** 59 | * Whether the game is locked for compatibility. 60 | */ 61 | locked?: boolean; 62 | 63 | /** 64 | * Type of the game core. 65 | */ 66 | type: GameCoreType; 67 | } 68 | 69 | export type GameCoreType = 70 | "vanilla-snapshot" | 71 | "vanilla-release" | 72 | "vanilla-old-alpha" | 73 | "vanilla-old-beta" | 74 | "forge" | 75 | "fabric" | 76 | "quilt" | 77 | "neoforged" | 78 | "rift" | 79 | "liteloader" | 80 | "optifine" | 81 | "unknown" 82 | 83 | export const GAME_REG_VERSION = 3; 84 | export const GAME_REG_TRANS: RegistryTransformer[] = [ 85 | // v1: patch the `installerProps` key 86 | (s) => { 87 | s.installProps = { 88 | type: "vanilla" 89 | }; 90 | 91 | return s; 92 | }, 93 | 94 | // v2: copy `profileId` to `installProps` 95 | (s) => { 96 | if (!s.installProps.gameVersion) { 97 | s.installProps.gameVersion = s.launchHint.profileId; 98 | } 99 | 100 | return s; 101 | }, 102 | 103 | // v3: append `user` key 104 | (s) => { 105 | if (!s.user) { 106 | s.user = {}; 107 | } 108 | 109 | return s; 110 | } 111 | ]; 112 | -------------------------------------------------------------------------------- /src/main/install/except.ts: -------------------------------------------------------------------------------- 1 | import { AbstractException } from "@/main/except/exception"; 2 | 3 | export class UnavailableModLoaderException extends AbstractException<"unavailable-mod-loader"> { 4 | #version: string; 5 | 6 | constructor(version: string) { 7 | super("unavailable-mod-loader", { version }); 8 | this.#version = version; 9 | } 10 | 11 | toString(): string { 12 | return `No mod loader available for game version ${this.#version}`; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/main/install/fabric.ts: -------------------------------------------------------------------------------- 1 | import type { Container } from "@/main/container/spec"; 2 | import { UnavailableModLoaderException } from "@/main/install/except"; 3 | import { netx } from "@/main/net/netx"; 4 | import { progress, type ProgressController } from "@/main/util/progress"; 5 | import fs from "fs-extra"; 6 | 7 | const FABRIC_META_API = "https://meta.fabricmc.net/v2"; 8 | 9 | interface LoaderEntry { 10 | loader: FabricLoaderVersion; 11 | } 12 | 13 | interface FabricLoaderVersion { 14 | version: string; 15 | stable: boolean; 16 | } 17 | 18 | let availableGameVersions: string[]; 19 | 20 | async function getAvailableGameVersions() { 21 | if (!availableGameVersions) { 22 | const url = FABRIC_META_API + "/versions/game"; 23 | const vs = await netx.json(url); 24 | availableGameVersions = vs.map((v: { version: string }) => v.version); 25 | } 26 | 27 | return availableGameVersions; 28 | } 29 | 30 | async function queryLoaderVersions(gameVersion: string): Promise { 31 | const url = FABRIC_META_API + `/versions/loader/${gameVersion}`; 32 | 33 | const entries = await netx.json(url) as LoaderEntry[]; 34 | return entries.map(e => e.loader); 35 | } 36 | 37 | async function retrieveProfile( 38 | gameVersion: string, 39 | loaderVersion: string, 40 | container: Container, 41 | controller?: ProgressController 42 | ): Promise { 43 | controller?.onProgress?.(progress.indefinite("fabric.resolve")); 44 | 45 | if (!loaderVersion) { 46 | const versions = await queryLoaderVersions(gameVersion); 47 | let sv = versions.find(v => v.stable)?.version; 48 | if (!sv) { 49 | // There are no stable versions, use the first one instead 50 | sv = versions[0]?.version; 51 | } 52 | if (!sv) throw new UnavailableModLoaderException(gameVersion); 53 | 54 | loaderVersion = sv; 55 | } 56 | 57 | console.debug(`Fetching Fabric profile for ${gameVersion} / ${loaderVersion}`); 58 | const url = FABRIC_META_API + `/versions/loader/${gameVersion}/${loaderVersion}/profile/json`; 59 | 60 | controller?.signal?.throwIfAborted(); 61 | 62 | const fbp = await netx.json(url); 63 | if (!("id" in fbp) || typeof fbp.id !== "string") throw new UnavailableModLoaderException(gameVersion); 64 | 65 | console.debug(`Writing profile with ID: ${fbp.id}`); 66 | await fs.outputJSON(container.profile(fbp.id), fbp); 67 | 68 | return fbp.id; 69 | } 70 | 71 | async function prefetch() { 72 | try { 73 | await getAvailableGameVersions(); 74 | } catch {} 75 | } 76 | 77 | export const fabricInstaller = { 78 | prefetch, 79 | queryLoaderVersions, 80 | getAvailableGameVersions, 81 | retrieveProfile 82 | }; 83 | -------------------------------------------------------------------------------- /src/main/install/natives-lint.ts: -------------------------------------------------------------------------------- 1 | import type { Container } from "@/main/container/spec"; 2 | import { nativeLib } from "@/main/profile/native-lib"; 3 | import { filterRules } from "@/main/profile/rules"; 4 | import type { VersionProfile } from "@/main/profile/version-profile"; 5 | import fs from "fs-extra"; 6 | import StreamZip from "node-stream-zip"; 7 | import path from "node:path"; 8 | 9 | async function unpackOne(lib: string, out: string, exclude?: string[]): Promise { 10 | const filter = (p: string) => !(p.endsWith("/") || exclude?.some(e => p.startsWith(e))); 11 | let f: StreamZip.StreamZipAsync | null = null; 12 | try { 13 | f = new StreamZip.async({ file: lib }); 14 | const files = Object.values(await f.entries()).filter(ent => !ent.isDirectory && filter(ent.name)); 15 | 16 | await Promise.all(files.map(async ent => { 17 | const t = path.join(out, ent.name); 18 | console.debug(`Extracting native artifact: ${lib} (${ent.name}) -> ${t}`); 19 | 20 | await fs.ensureDir(path.dirname(t)); 21 | await f!.extract(ent, t); 22 | })); 23 | } finally { 24 | await f?.close(); 25 | } 26 | } 27 | 28 | async function unpack(profile: VersionProfile, container: Container, features: Set): Promise { 29 | const nativesRoot = container.nativesRoot(profile.version || profile.id); 30 | const sources = profile.libraries.filter(l => filterRules(l.rules, features) && nativeLib.isNative(l)); 31 | 32 | await Promise.all(sources.map(async s => { 33 | const name = nativeLib.getArtifactName(s); 34 | if (name) { 35 | await unpackOne(container.nativeLibrary(s.name, name), nativesRoot, s.extract?.exclude); 36 | } 37 | })); 38 | } 39 | 40 | export const nativesLint = { unpack }; 41 | -------------------------------------------------------------------------------- /src/main/ipc/channels.ts: -------------------------------------------------------------------------------- 1 | import type { UserConfig } from "@/main/conf/conf"; 2 | import type { MpmManifest } from "@/main/mpm/spec"; 3 | 4 | /** 5 | * Events sent from renderer to main. 6 | */ 7 | export type IpcCallEvents = { 8 | updateConfig: (c: UserConfig) => void; 9 | showWindow: () => void; 10 | hideWindow: () => void; 11 | closeWindow: () => void; 12 | minimizeWindow: () => void; 13 | setZoom: (value: number) => void; 14 | openUrl: (url: string) => void; 15 | stopGame: (id: string) => void; 16 | removeGame: (id: string) => void; 17 | destroyGame: (id: string) => void; 18 | revealGameContent: (id: string, scope: string) => void; 19 | languageChange: (lang: string) => void; 20 | cancelInstall: (id: string) => void; 21 | } 22 | 23 | /** 24 | * Events sent from renderer to main with message port forwarding. 25 | */ 26 | export type IpcMessageEvents = { 27 | installGame: (gameId: string) => void; 28 | subscribeGameEvents: (gid: string) => void; 29 | } 30 | 31 | /** 32 | * Events sent from main to renderer. 33 | */ 34 | export type IpcPushEvents = { 35 | gameChanged: () => void; 36 | accountChanged: () => void; 37 | configChanged: (c: UserConfig) => void; 38 | appUpgraded: (version: string) => void; 39 | devToolsOpened: () => void; 40 | mpmManifestChanged: (id: string, mf: MpmManifest) => void; 41 | } 42 | 43 | export type IpcCommands = {} 44 | -------------------------------------------------------------------------------- /src/main/jrt/linux-arm.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Add the missing JRT on ARM64 GNU/Linux. 3 | */ 4 | import { paths } from "@/main/fs/paths"; 5 | import { jrt, JrtInstallationFailedException } from "@/main/jrt/install"; 6 | import { dlx } from "@/main/net/dlx"; 7 | import { unwrapESM } from "@/main/util/module"; 8 | import fs from "fs-extra"; 9 | import path from "node:path"; 10 | import * as zlib from "node:zlib"; 11 | import { pipeline } from "stream/promises"; 12 | import tar from "tar-fs"; 13 | 14 | async function installComponent(component: string) { 15 | const urls = await unwrapESM(import("@/refs/jrt-linux-arm.json")) as unknown as Record; 16 | 17 | const root = jrt.getInstallPath(component); 18 | 19 | try { 20 | await jrt.verify(root); 21 | return; 22 | } catch {} 23 | 24 | if (!(component in urls)) { 25 | throw new JrtInstallationFailedException(component); 26 | } 27 | 28 | const u = urls[component]; 29 | 30 | if (u.startsWith("reuse:")) { 31 | const target = u.slice("reuse:".length); 32 | await installComponent(target); 33 | await fs.ensureSymlink(jrt.getInstallPath(target), root); 34 | return; 35 | } 36 | 37 | const tarFile = paths.temp.to(`jrt-${component}.tar.gz`); 38 | 39 | console.log(`Fetching package: ${u} -> ${tarFile}`); 40 | await dlx.getAll([ 41 | { 42 | url: u, 43 | path: tarFile, 44 | fastLink: false 45 | } 46 | ]); 47 | 48 | 49 | console.log(`Unpacking ${tarFile}`); 50 | await fs.ensureDir(root); 51 | const rs = fs.createReadStream(tarFile); 52 | const ts = zlib.createGunzip(); 53 | const ws = tar.extract(root); 54 | await pipeline(rs, ts, ws); 55 | await fs.remove(tarFile); 56 | 57 | // There is a nested folder inside the tar file and we must extract it 58 | const pkgName = (await fs.readdir(root)).find(f => f.includes("jdk") || f.includes("jre")); 59 | 60 | if (!pkgName) throw "Unable to locate unpacked JRT directory"; 61 | 62 | const pkgPath = path.join(path.dirname(root), pkgName); 63 | await fs.move(path.join(root, pkgName), pkgPath); 64 | await fs.remove(root); 65 | await fs.rename(pkgPath, root); 66 | 67 | console.log(`Verifying installation: ${root}`); 68 | await jrt.verify(root); 69 | } 70 | 71 | export const jrtLinuxArm = { installComponent }; 72 | -------------------------------------------------------------------------------- /src/main/launch/log-parser.ts: -------------------------------------------------------------------------------- 1 | import { XMLParser } from "fast-xml-parser"; 2 | import lazyValue from "lazy-value"; 3 | 4 | export interface GameProcessLog { 5 | index: number; // An absolute index of the log 6 | time: number; 7 | logger: string; 8 | level: string; 9 | thread: string; 10 | message: string; 11 | throwable?: string; 12 | } 13 | 14 | const parser = lazyValue(() => new XMLParser({ ignoreAttributes: false, removeNSPrefix: true })); 15 | 16 | function compatFragment(src: string): string { 17 | return ` 18 | 19 | 20 | ${src} 21 | 22 | `; 23 | } 24 | 25 | function isPossiblyXmlLog(src: string): boolean { 26 | return src.replaceAll("\n", "").trim().startsWith(" ss.includes(lv))?.toUpperCase() ?? "INFO"; 32 | return { 33 | index, 34 | time: Date.now(), 35 | logger: "Unknown", 36 | level, 37 | thread: "Unknown", 38 | message: src.trim(), 39 | throwable: undefined 40 | }; 41 | } 42 | 43 | /** 44 | * Parse the XML-formatted log output as JS objects. 45 | */ 46 | function parse(src: string, index: number): GameProcessLog[] { 47 | if (!isPossiblyXmlLog(src)) { 48 | return [parseAsRaw(src, index)]; 49 | } 50 | 51 | const dom = parser().parse(compatFragment(src)); 52 | let events = dom?.["Events"]?.["Event"]; 53 | 54 | if (typeof events !== "object") return []; 55 | 56 | if (!Array.isArray(events)) { 57 | events = [events]; 58 | } 59 | 60 | events = events.filter((e: unknown) => typeof e === "object"); 61 | 62 | return events.map((e: any) => { 63 | return { 64 | index: index++, 65 | message: e["Message"] ?? "", 66 | throwable: e["Throwable"], 67 | logger: e["@_logger"] ?? "", 68 | time: parseInt(e["@_timestamp"] ?? "0"), 69 | level: e["@_level"] ?? "INFO", 70 | thread: e["@_thread"] ?? "" 71 | } satisfies GameProcessLog; 72 | }); 73 | } 74 | 75 | export const logParser = { parse }; 76 | -------------------------------------------------------------------------------- /src/main/launch/types.ts: -------------------------------------------------------------------------------- 1 | import { AuthCredentials } from "@/main/auth/types"; 2 | import { Container } from "@/main/container/spec"; 3 | import { VersionProfile } from "@/main/profile/version-profile"; 4 | 5 | /** 6 | * Launch hints are the next generation way of managing the launch process. 7 | * User is capable for customizing the detailed launch options without creating new containers or create additional files. 8 | */ 9 | export interface LaunchHint { 10 | containerId: string; 11 | profileId: string; 12 | 13 | /** 14 | * Account identifier. An empty string indicates a new account should be created when launching. 15 | */ 16 | accountId: string; 17 | pref: Partial; 18 | 19 | /** 20 | * Whether the profile should be run in virtual environment. 21 | */ 22 | venv?: boolean; 23 | } 24 | 25 | /** 26 | * Launch preferences defined by the user. 27 | */ 28 | export interface LaunchPref { 29 | /** 30 | * Memory limitations. 31 | * 32 | * A value between 0.0 and 1.0 is seen as a percentage of the available memory. 33 | * Values higher are interpreted as MiBs. 34 | */ 35 | memory: { 36 | min: number; 37 | max: number; 38 | }; 39 | 40 | /** 41 | * Window size configuration. 42 | */ 43 | window: { 44 | width: number; 45 | height: number; 46 | }; 47 | 48 | /** 49 | * Per-hint additional arguments. 50 | */ 51 | args: { 52 | vm: string[]; 53 | game: string[]; 54 | }; 55 | 56 | /** 57 | * Alternative JRT executable. 58 | */ 59 | alterJRTExec: string; 60 | } 61 | 62 | export interface LaunchInit { 63 | profile: VersionProfile; 64 | container: Container; 65 | jrtExec: string; 66 | credentials: AuthCredentials; 67 | enabledFeatures: Set; 68 | assetsShouldMap: boolean; 69 | pref: Partial; 70 | extraVMArgs: string[]; 71 | extraClasspath: string[]; 72 | altMainClass?: string; 73 | authlibInjectorHost?: string; 74 | authlibInjectorPrefetch?: string; 75 | } 76 | -------------------------------------------------------------------------------- /src/main/main-env.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | ALICORN_CONFIG_PATH?: string; 5 | } 6 | } 7 | } 8 | 9 | 10 | export {}; 11 | -------------------------------------------------------------------------------- /src/main/modpack/common.ts: -------------------------------------------------------------------------------- 1 | import { curseModpack } from "@/main/modpack/curse"; 2 | import { modrinthModpack } from "@/main/modpack/modrinth"; 3 | import StreamZip from "node-stream-zip"; 4 | 5 | export interface ModpackMetaSlim { 6 | name: string; 7 | author: string; 8 | gameVersion: string; 9 | version: string; 10 | } 11 | 12 | async function loadPackMeta(fp: string): Promise { 13 | let zip: StreamZip.StreamZipAsync | null = null; 14 | 15 | try { 16 | zip = new StreamZip.async({ file: fp }); 17 | const entries = Object.keys(await zip.entries()); 18 | 19 | if (entries.includes("manifest.json")) { 20 | // Curseforge 21 | return await curseModpack.readMetadata(zip); 22 | } 23 | 24 | if (entries.includes("modrinth.index.json")) { 25 | // Modrinth 26 | return await modrinthModpack.readMetadata(zip); 27 | } 28 | 29 | } finally { 30 | zip?.close(); 31 | } 32 | 33 | return null; 34 | } 35 | 36 | async function deploy(fp: string): Promise { 37 | let zip: StreamZip.StreamZipAsync | null = null; 38 | 39 | try { 40 | zip = new StreamZip.async({ file: fp }); 41 | const entries = Object.keys(await zip.entries()); 42 | 43 | if (entries.includes("manifest.json")) { 44 | // Curseforge 45 | await curseModpack.deploy(fp); 46 | } 47 | 48 | if (entries.includes("modrinth.index.json")) { 49 | // Modrinth 50 | await modrinthModpack.deploy(fp); 51 | } 52 | 53 | } finally { 54 | zip?.close(); 55 | } 56 | } 57 | 58 | export const modpacks = { loadPackMeta, deploy }; 59 | -------------------------------------------------------------------------------- /src/main/modpack/tools.ts: -------------------------------------------------------------------------------- 1 | import type { Container } from "@/main/container/spec"; 2 | import { progress, type ProgressController } from "@/main/util/progress"; 3 | import fs from "fs-extra"; 4 | import StreamZip from "node-stream-zip"; 5 | import os from "node:os"; 6 | import path from "node:path"; 7 | import pLimit from "p-limit"; 8 | 9 | 10 | async function applyOverrides(zip: StreamZip.StreamZipAsync, prefix: string, root: string, control?: ProgressController) { 11 | const { onProgress } = control ?? {}; 12 | const entries = await zip.entries(); 13 | const limit = pLimit(os.availableParallelism()); 14 | const ps = Object.values(entries) 15 | .filter(ent => ent.name.startsWith(prefix) && ent.isFile) 16 | .map(ent => limit(async () => { 17 | const fp = path.join(root, ent.name.slice(prefix.length)); 18 | 19 | console.debug(`Extracting override file: ${ent.name} -> ${fp}`); 20 | 21 | await fs.ensureDir(path.dirname(fp)); 22 | await fs.remove(fp); 23 | await zip!.extract(ent, fp); 24 | })); 25 | 26 | await Promise.all(progress.countPromises(ps, progress.makeNamed(onProgress, "modpack.unpack-files"))); 27 | } 28 | 29 | async function copyPack(fp: string, container: Container): Promise { 30 | const pt = path.join(container.gameDir(), "_modpack.zip"); 31 | await fs.ensureDir(path.dirname(pt)); 32 | await fs.copyFile(fp, pt); 33 | return pt; 34 | } 35 | 36 | export const modpackTools = { applyOverrides, copyPack }; 37 | -------------------------------------------------------------------------------- /src/main/mpm/lockfile.ts: -------------------------------------------------------------------------------- 1 | import { containers } from "@/main/container/manage"; 2 | import { games } from "@/main/game/manage"; 3 | import type { MpmManifest } from "@/main/mpm/spec"; 4 | import { windowControl } from "@/main/sys/window-control"; 5 | import { isENOENT } from "@/main/util/fs"; 6 | import fs from "fs-extra"; 7 | 8 | async function loadManifest(gameId: string): Promise { 9 | try { 10 | const game = games.get(gameId); 11 | const c = containers.get(game.launchHint.containerId); 12 | return await fs.readJSON(c.mpmLockfile()); 13 | } catch (e) { 14 | if (isENOENT(e)) { 15 | return { 16 | userPrompt: [], 17 | resolved: [] 18 | }; 19 | } else { 20 | throw e; 21 | } 22 | } 23 | } 24 | 25 | function notifyManifestChanged(gameId: string, manifest: MpmManifest) { 26 | windowControl.getMainWindow()?.webContents.send("mpmManifestChanged", gameId, manifest); 27 | } 28 | 29 | async function saveManifest(gameId: string, manifest: MpmManifest) { 30 | notifyManifestChanged(gameId, manifest); 31 | const game = games.get(gameId); 32 | const c = containers.get(game.launchHint.containerId); 33 | await fs.outputJSON(c.mpmLockfile(), manifest); 34 | } 35 | 36 | export const mpmLock = { loadManifest, saveManifest }; 37 | -------------------------------------------------------------------------------- /src/main/mpm/spec.ts: -------------------------------------------------------------------------------- 1 | export interface MpmAddonMeta { 2 | id: string; 3 | vendor: string; 4 | title: string; 5 | author: string; 6 | description: string; 7 | icon: string; 8 | type: MpmAddonType; 9 | } 10 | 11 | export type MpmAddonType = "mods" | "resourcepacks" | "shaderpacks" | "modpack"; 12 | 13 | export class MpmPackageSpecifier { 14 | id: string; 15 | type: MpmAddonType; 16 | vendor: string; 17 | version: string; // An empty string for arbitrary version 18 | 19 | constructor(s: string) { 20 | const [vendor, type, id, version] = s.split(":"); 21 | this.id = id || ""; 22 | this.type = type as MpmAddonType || "mods"; 23 | this.vendor = vendor || ""; 24 | this.version = version || ""; 25 | } 26 | 27 | toString() { 28 | return `${this.vendor}:${this.type}:${this.id}:${this.version}`; 29 | } 30 | } 31 | 32 | export interface MpmPackageDependency { 33 | type: "require" | "conflict"; 34 | spec: string; 35 | } 36 | 37 | export interface MpmFile { 38 | url: string; 39 | sha1?: string; 40 | size?: number; 41 | fileName: string; 42 | } 43 | 44 | export interface MpmPackage { 45 | id: string; 46 | vendor: string; 47 | version: string; 48 | versionName: string; 49 | spec: string; 50 | files: MpmFile[]; 51 | dependencies: MpmPackageDependency[]; 52 | meta: MpmAddonMeta; 53 | } 54 | 55 | export interface MpmContext { 56 | gameVersion: string; 57 | loader: string; 58 | } 59 | 60 | export interface MpmManifest { 61 | userPrompt: string[]; 62 | resolved: MpmPackage[]; 63 | } 64 | -------------------------------------------------------------------------------- /src/main/net/netx.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utilities related to network access. 3 | */ 4 | import { conf } from "@/main/conf/conf"; 5 | import { AbstractException } from "@/main/except/exception"; 6 | import { NetRequestFailedException } from "@/main/except/net"; 7 | import { mirror } from "@/main/net/mirrors"; 8 | import { net, Session } from "electron"; 9 | 10 | /** 11 | * Fetches the content of the given URL using any available mirror. 12 | */ 13 | async function request(url: string, body?: any, session?: Session): Promise { 14 | let lastError; 15 | 16 | const urls = mirror.apply(url); 17 | const jsonHeader = body === undefined ? undefined : { "Content-Type": "application/json" }; 18 | 19 | for (const u of urls) { 20 | let code: number | undefined = undefined; 21 | try { 22 | const signal = AbortSignal.timeout(conf().net.requestTimeout); 23 | const r = await (session ?? net).fetch(u, { 24 | headers: { ...jsonHeader }, 25 | method: body === undefined ? "GET" : "POST", 26 | body: body === undefined ? undefined : JSON.stringify(body), 27 | signal 28 | }); 29 | if (r.ok) return r; 30 | code = r.status; 31 | } catch (e) { 32 | console.error(`[NETX] Unable to fetch from: ${u}`); 33 | console.error(e); 34 | } 35 | lastError = new NetRequestFailedException(u, code); 36 | } 37 | 38 | throw new NetMirrorsAllFailedException(url, lastError); 39 | } 40 | 41 | async function json(url: string, body?: any, session?: Session): Promise { 42 | const r = await request(url, body, session); 43 | 44 | try { 45 | return await r.json(); 46 | } catch (e) { 47 | console.error(`[NETX] Unable to fetch JSON from: ${r.url}`); 48 | console.error(e); 49 | } 50 | 51 | throw new NetMirrorsAllFailedException(url); 52 | } 53 | 54 | 55 | class NetMirrorsAllFailedException extends AbstractException<"net-mirrors-all-failed"> { 56 | #url: string; 57 | 58 | constructor(url: string, cause?: unknown) { 59 | super("net-mirrors-all-failed", { url }, cause); 60 | this.#url = url; 61 | } 62 | 63 | toString(): string { 64 | return `All mirrors have failed: ${this.#url}`; 65 | } 66 | } 67 | 68 | export const netx = { 69 | request, json 70 | }; 71 | -------------------------------------------------------------------------------- /src/main/net/ws-rpc.ts: -------------------------------------------------------------------------------- 1 | import Emittery from "emittery"; 2 | import { nanoid } from "nanoid"; 3 | import { pEvent } from "p-event"; 4 | 5 | export class WebSocketJsonRpcClient { 6 | // This implementation uses third-party WebSocket implementation 7 | // Migrate to Node.js native WebSocket when Electron supports Node.js 22.x 8 | #ws: WebSocket; 9 | 10 | // Map from request ID to callbacks. 11 | #callbacks = new Map void>(); 12 | 13 | #emitter = new Emittery(); 14 | 15 | constructor(ws: WebSocket) { 16 | this.#ws = ws; 17 | 18 | this.#ws.addEventListener("error", (e) => { 19 | console.error(`Error in WebSocket JSON-RPC client`); 20 | console.error(e); 21 | }); 22 | 23 | this.#ws.onmessage = (e) => { 24 | const d = JSON.parse(e.data.toString()); 25 | 26 | if (d.id) { 27 | // Handles response 28 | const cb = this.#callbacks.get(d.id); 29 | if (!cb) return; 30 | 31 | this.#callbacks.delete(d.id); 32 | 33 | cb(d.error, d.result); 34 | } else { 35 | // Dispatches event 36 | const { method, params: [event] } = d; 37 | void this.#emitter.emit(method, event); 38 | } 39 | }; 40 | } 41 | 42 | async wait(): Promise { 43 | await pEvent(this.#ws, "open"); 44 | } 45 | 46 | on(channel: string, cb: (res: any) => void) { 47 | this.#emitter.on(channel, cb); 48 | } 49 | 50 | async request(method: string, params: unknown[] = []): Promise { 51 | const id = nanoid(); 52 | 53 | const body = JSON.stringify({ 54 | jsonrpc: "2.0", 55 | id, method, params 56 | }); 57 | 58 | this.#ws.send(body); 59 | 60 | const { promise, resolve, reject } = Promise.withResolvers(); 61 | 62 | this.#callbacks.set(id, (e, d) => { 63 | if (e) reject(JSON.stringify(e)); 64 | else resolve(d); 65 | }); 66 | 67 | return promise; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/main/profile/linker.ts: -------------------------------------------------------------------------------- 1 | import { patchProfile } from "@/main/profile/profile-adaptor"; 2 | import type { VersionProfile } from "@/main/profile/version-profile"; 3 | import { mergician } from "mergician"; 4 | 5 | /** 6 | * Links the given profiles. 7 | * 8 | * This method does a formal deep merge on the given objects with only the key 'inheritsFrom' parsed. 9 | * 10 | * @param id The ID of the profile to be linked. 11 | * @param provider A function which this method calls to retrieve extra profiles for linking. 12 | */ 13 | export async function linkProfile(id: string, provider: (id: string) => unknown | Promise): Promise { 14 | let circular = new Set(); 15 | let obj = { inheritsFrom: id, version: id }; 16 | 17 | while (obj.inheritsFrom) { 18 | const nextID = obj.inheritsFrom; 19 | if (circular.has(nextID)) throw `Circular dependency detected: ${nextID}`; 20 | circular.add(nextID); 21 | 22 | const s = await provider(nextID); 23 | 24 | if (typeof s === "object" && s !== null && "inheritsFrom" in s && typeof s.inheritsFrom === "string") { 25 | obj.inheritsFrom = s.inheritsFrom; // Pass the inheritance relationship on 26 | } else { 27 | obj.inheritsFrom = ""; // Terminates the inheritance 28 | } 29 | 30 | obj = mergician({ 31 | prependArrays: true 32 | })(s, obj); 33 | 34 | obj.version = nextID; 35 | } 36 | 37 | await patchProfile(obj); 38 | 39 | if ("libraries" in obj && Array.isArray(obj.libraries)) { 40 | obj.libraries = combineLibraries(obj.libraries); 41 | } 42 | 43 | if (obj.inheritsFrom || !("id" in obj)) { 44 | throw `Link unsatisfied: ${id} has no complete inheritance`; 45 | } 46 | 47 | return obj as VersionProfile; 48 | } 49 | 50 | function combineLibraries(libs: unknown[]): unknown[] { 51 | const out: { name: string } [] = []; 52 | 53 | outer: for (const lib of libs.toReversed()) { 54 | if (typeof lib === "object" && lib && "name" in lib && typeof lib.name === "string") { 55 | for (const [index, existingLib] of out.entries()) { 56 | if (existingLib.name === lib.name && existingLib !== lib) { 57 | // Merge lib -> existingLib 58 | out[index] = mergician(existingLib, lib) as { name: string }; 59 | continue outer; 60 | } 61 | } 62 | out.unshift(lib as { name: string }); 63 | } 64 | } 65 | 66 | return out; 67 | } 68 | -------------------------------------------------------------------------------- /src/main/profile/loader.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Utilities for loading profiles. 3 | */ 4 | import { Container } from "@/main/container/spec"; 5 | import { AbstractException } from "@/main/except/exception"; 6 | import { linkProfile } from "@/main/profile/linker"; 7 | import { VersionProfile } from "@/main/profile/version-profile"; 8 | import fs from "fs-extra"; 9 | 10 | class ProfileLinkFailedException extends AbstractException<"profile-link-failed"> { 11 | #id: string; 12 | 13 | constructor(id: string) { 14 | super("profile-link-failed", { id }); 15 | this.#id = id; 16 | } 17 | 18 | toString(): string { 19 | return `Failed to link profile ${this.#id}`; 20 | } 21 | } 22 | 23 | async function fromContainer(id: string, container: Container): Promise { 24 | try { 25 | return await linkProfile(id, i => fs.readJSON(container.profile(i))); 26 | } catch (e) { 27 | throw new ProfileLinkFailedException(id); 28 | } 29 | } 30 | 31 | 32 | async function assetIndexShouldMap(assetIndexId: string, container: Container): Promise { 33 | const a = await fs.readJSON(container.assetIndex(assetIndexId)); 34 | return "map_to_resources" in a && !!a["map_to_resources"]; 35 | } 36 | 37 | async function isLegacyAssets(assetIndexId: string): Promise { 38 | // This module fails to unwrap as its export value is an array 39 | // We need to unwrap manually 40 | const legacyAssets = (await import("@/refs/legacy-assets.json")).default as string[]; 41 | return legacyAssets.includes(assetIndexId); 42 | } 43 | 44 | 45 | export const profileLoader = { 46 | fromContainer, assetIndexShouldMap, isLegacyAssets 47 | }; 48 | -------------------------------------------------------------------------------- /src/main/profile/lwjgl-arm-patch.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Add the missing ARM64 artifacts for LWJGL 3.3.x on GNU/Linux. 3 | */ 4 | import { MavenName } from "@/main/profile/maven-name"; 5 | import type { Library, LibraryArtifact } from "@/main/profile/version-profile"; 6 | import { unwrapESM } from "@/main/util/module"; 7 | import os from "node:os"; 8 | 9 | async function patchLibraries(libraries: unknown[]) { 10 | if (os.arch() !== "arm64") return; // Skip patching 11 | 12 | const lwjglArtifacts = await unwrapESM(import("@/refs/lwjgl-artifacts.json")) as Record; 13 | const availableArtifacts = new Set(Object.keys(lwjglArtifacts)); 14 | 15 | const injectLibs = new Set(); 16 | 17 | for (const lib of libraries) { 18 | if (typeof lib === "object" && lib && "name" in lib && typeof lib.name === "string") { 19 | const m = new MavenName(lib.name); 20 | const name = [m.group, m.artifact, m.version, "natives-linux-arm64"].join(":"); 21 | 22 | if (availableArtifacts.has(name)) { 23 | injectLibs.add(name); 24 | } 25 | } 26 | } 27 | 28 | const addLibs: Library[] = [...injectLibs.values()].map(libName => { 29 | console.debug(`Adding LWJGL library: ${libName}`); 30 | 31 | const artifact = lwjglArtifacts[libName]; 32 | 33 | return { 34 | downloads: { 35 | artifact 36 | }, 37 | name: libName, 38 | rules: [ 39 | { 40 | action: "allow", 41 | os: { 42 | name: "linux" 43 | } 44 | } 45 | ] 46 | }; 47 | }); 48 | 49 | libraries.push(...addLibs); 50 | } 51 | 52 | export const lwjglARMPatch = { patchLibraries }; 53 | -------------------------------------------------------------------------------- /src/main/profile/maven-name.ts: -------------------------------------------------------------------------------- 1 | import { isTruthy } from "@/main/util/misc"; 2 | 3 | /** 4 | * Parses a library name of Maven standard. 5 | */ 6 | export class MavenName { 7 | group: string; 8 | artifact: string; 9 | version: string; 10 | classifier: string; 11 | ext: string; 12 | 13 | constructor(name: string) { 14 | const [main, ext] = name.split("@"); 15 | const [group, artifact, version, classifier] = main.split(":"); 16 | if (!group || !artifact || !version) throw `Invalid Maven library name: ${name}`; 17 | this.group = group; 18 | this.artifact = artifact; 19 | this.version = version; 20 | this.classifier = classifier || ""; 21 | this.ext = ext || "jar"; 22 | } 23 | 24 | /** 25 | * Converts the Maven name to a path seperated by slashes (/). 26 | */ 27 | toPath(): string { 28 | const groupPath = this.group.replaceAll(".", "/"); 29 | const jarName = [this.artifact, this.version, this.classifier].filter(isTruthy).join("-") + "." + this.ext; 30 | 31 | return [groupPath, this.artifact, this.version, jarName].join("/"); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/main/profile/native-lib.ts: -------------------------------------------------------------------------------- 1 | import type { Library, LibraryArtifact } from "@/main/profile/version-profile"; 2 | import { getOSBits, getOSName } from "@/main/sys/os"; 3 | 4 | /** 5 | * Check whether the given library is a native library (which requires unpacking). 6 | */ 7 | function isNative(l: Library): boolean { 8 | return l.natives !== undefined && getOSName() in l.natives; 9 | } 10 | 11 | /** 12 | * Gets the name of the native artifact. 13 | */ 14 | function getArtifactName(l: Library): string | null { 15 | const index = l.natives?.[getOSName()]; 16 | return index?.replaceAll("${arch}", getOSBits()) ?? null; 17 | } 18 | 19 | /** 20 | * Gets the artifact of the native library. 21 | */ 22 | function getArtifact(l: Library): LibraryArtifact | null { 23 | const name = getArtifactName(l); 24 | 25 | if (name) { 26 | return l.downloads?.classifiers?.[name] ?? null; 27 | } 28 | 29 | return null; 30 | } 31 | 32 | export const nativeLib = { isNative, getArtifactName, getArtifact }; 33 | -------------------------------------------------------------------------------- /src/main/profile/rules.ts: -------------------------------------------------------------------------------- 1 | import type { Rule } from "@/main/profile/version-profile"; 2 | import { getOSName } from "@/main/sys/os"; 3 | import os from "node:os"; 4 | 5 | /** 6 | * Filter the rules based on OS information and given feature set. 7 | */ 8 | export function filterRules(r: Rule[] | undefined, features: Set): boolean { 9 | if (!r || r.length === 0) return true; 10 | const match = r.findLast(it => { 11 | const criteria = []; 12 | if (it.os) { 13 | const { name, version, arch } = it.os; 14 | if (name) criteria.push(name === getOSName()); 15 | if (version) criteria.push(new RegExp(version).test(os.release())); 16 | if (arch) criteria.push(arch === os.arch()); 17 | } 18 | 19 | if (it.features) { 20 | for (const [f, v] of Object.entries(it.features)) { 21 | criteria.push(features.has(f) === v); 22 | } 23 | } 24 | 25 | return criteria.every(Boolean); 26 | }); 27 | 28 | if (match) return match.action === "allow"; 29 | 30 | return false; 31 | } 32 | -------------------------------------------------------------------------------- /src/main/readyboom/accounts.ts: -------------------------------------------------------------------------------- 1 | import { accounts } from "@/main/auth/manage"; 2 | import { VanillaAccount } from "@/main/auth/vanilla"; 3 | import { conf } from "@/main/conf/conf"; 4 | import { reg } from "@/main/registry/registry"; 5 | 6 | async function keepAccountsAlive() { 7 | if (!conf().runtime.readyboom) return; 8 | 9 | const a = reg.accounts.entries().map(e => e[0]); 10 | await Promise.allSettled(a.map(async id => { 11 | const ac = accounts.get(id); 12 | 13 | if (ac instanceof VanillaAccount) { 14 | console.debug(`Refreshing account ${id}`); 15 | await ac.refreshQuietly(); 16 | console.debug(`Account is now alive: ${id}`); 17 | accounts.add(ac); 18 | } 19 | })); 20 | } 21 | 22 | export const rbAccounts = { 23 | keepAccountsAlive 24 | }; 25 | -------------------------------------------------------------------------------- /src/main/security/encrypt.ts: -------------------------------------------------------------------------------- 1 | import { safeStorage } from "electron"; 2 | 3 | export function doEncrypt(src: string): string { 4 | if (!safeStorage.isEncryptionAvailable()) { 5 | return src; 6 | } 7 | 8 | return safeStorage.encryptString(src).toString("base64"); 9 | } 10 | 11 | export function doDecrypt(buf: string): string { 12 | if (!safeStorage.isEncryptionAvailable()) { 13 | return buf; 14 | } 15 | 16 | return safeStorage.decryptString(Buffer.from(buf, "base64")); 17 | } 18 | -------------------------------------------------------------------------------- /src/main/security/hash-worker.ts: -------------------------------------------------------------------------------- 1 | import { createHash } from "node:crypto"; 2 | import fs from "node:fs"; 3 | import { pEvent } from "p-event"; 4 | import workerPool from "workerpool"; 5 | 6 | async function hash(path: string, algorithm: string): Promise { 7 | const hash = createHash(algorithm); 8 | const rs = fs.createReadStream(path); 9 | rs.on("data", (chunk) => hash.update(chunk)); 10 | 11 | await pEvent(rs, "end"); 12 | return hash.digest("hex").toLowerCase(); 13 | } 14 | 15 | workerPool.worker({ hash }); 16 | -------------------------------------------------------------------------------- /src/main/security/hash.ts: -------------------------------------------------------------------------------- 1 | import { paths } from "@/main/fs/paths"; 2 | import lazyValue from "lazy-value"; 3 | import workerPool from "workerpool"; 4 | 5 | let pool = lazyValue(() => workerPool.pool(paths.app.to("hash-worker.js"))); 6 | 7 | async function checkFile(pt: string, algorithm: string, expectHash: string): Promise { 8 | return await forFile(pt, algorithm) === expectHash.trim().toLowerCase(); 9 | } 10 | 11 | async function forFile(pt: string, algorithm: string): Promise { 12 | const h = await pool().exec("hash", [pt, algorithm]); 13 | 14 | if (!h) throw `Failed to hash file: ${pt}`; 15 | return h; 16 | } 17 | 18 | export const hash = { 19 | forFile, checkFile 20 | }; 21 | -------------------------------------------------------------------------------- /src/main/sys/boot.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A standalone script bundled with Alicorn. Does the following jobs: 3 | * 4 | * 1. Finds an existing copy of app bundle (with later compatible version) at specified path. 5 | * 2. If the bundle could not be found, boots using built-in app resources. 6 | * 3. If a bundle is found, loads main module from it. 7 | */ 8 | import { update } from "@/main/sys/update"; 9 | import fs from "fs-extra"; 10 | import path from "node:path"; 11 | import { pathToFileURL } from "node:url"; 12 | import * as semver from "semver"; 13 | import pkg from "~/package.json"; 14 | 15 | 16 | async function findModulePath(): Promise { 17 | const d = update.getVariableAppDir(); 18 | const files = await fs.readdir(d); 19 | 20 | for (const v of files) { 21 | if (semver.satisfies(v, "^" + pkg.version) && semver.gt(v, pkg.version)) { 22 | try { 23 | const lock = await fs.readFile(path.join(d, v, "install.lock")); 24 | 25 | if (lock.toString() === "OK") { 26 | console.debug("Found compatible module: " + v); 27 | return path.join(d, v); 28 | } 29 | } catch {} 30 | } 31 | } 32 | 33 | return null; 34 | } 35 | 36 | async function boot() { 37 | console.log(`Alicorn BL ${pkg.version}`); 38 | 39 | if (!import.meta.env.AL_DEV && !import.meta.env.AL_TEST) { 40 | try { 41 | const mp = await findModulePath(); 42 | if (mp) { 43 | console.debug("Booting from " + mp); 44 | await import(pathToFileURL(path.join(mp, "main.js")).toString()); 45 | return; 46 | } 47 | } catch {} 48 | } 49 | 50 | console.debug("Booting using built-in module."); 51 | await import(pathToFileURL(path.join(import.meta.dirname, "main.js")).toString()); 52 | } 53 | 54 | // Using await here to prevent Electron being initialized too early 55 | // This ensures that app will only emit "ready" event after main script has been executed. 56 | await boot(); 57 | -------------------------------------------------------------------------------- /src/main/sys/cleaner.ts: -------------------------------------------------------------------------------- 1 | import { paths } from "@/main/fs/paths"; 2 | import { reg } from "@/main/registry/registry"; 3 | import { app } from "electron"; 4 | import fs from "fs-extra"; 5 | import path from "node:path"; 6 | 7 | async function removeUnusedOAuthPartitions() { 8 | try { 9 | const root = path.join(app.getPath("userData"), "Partitions"); 10 | const files = await fs.readdir(root); 11 | const existingParts = new Set( 12 | reg.accounts.getAll() 13 | .filter(a => a.type === "vanilla") 14 | .map(a => a.partitionId.toLowerCase()) 15 | ); 16 | for (const f of files) { 17 | if (f.startsWith("ms-auth-") && !existingParts.has(f.toLowerCase())) { 18 | console.debug(`Removing unused OAuth partition: ${f}`); 19 | await fs.remove(path.join(root, f)); 20 | } 21 | } 22 | } catch {} 23 | } 24 | 25 | async function removeTempPath() { 26 | const fp = paths.temp.to("."); 27 | await fs.remove(fp); 28 | } 29 | 30 | export const cleaner = { removeUnusedOAuthPartitions, removeTempPath }; 31 | -------------------------------------------------------------------------------- /src/main/sys/os.ts: -------------------------------------------------------------------------------- 1 | import os from "node:os"; 2 | 3 | export type OSName = "windows" | "osx" | "linux"; 4 | 5 | /** 6 | * Gets the canonical name of the OS. 7 | */ 8 | export function getOSName(): OSName { 9 | switch (os.platform()) { 10 | case "win32": 11 | return "windows"; 12 | case "darwin": 13 | return "osx"; 14 | default: 15 | return "linux"; 16 | } 17 | } 18 | 19 | /** 20 | * Gets the bits of the current OS. 21 | * 22 | * This method only affects Windows platform (used to select Twitch binaries). 23 | */ 24 | export function getOSBits(): string { 25 | if (os.arch() === "ia32") return "32"; 26 | return "64"; 27 | } 28 | 29 | /** 30 | * Gets the executable suffix of this OS. 31 | */ 32 | export function getExecutableExt(): string { 33 | if (os.platform() === "win32") return ".exe"; 34 | return ""; 35 | } 36 | -------------------------------------------------------------------------------- /src/main/sys/ua.ts: -------------------------------------------------------------------------------- 1 | import { conf } from "@/main/conf/conf"; 2 | import pkg from "~/package.json"; 3 | 4 | const ALICORN_CANONICAL_UA = `Alicorn/${pkg.version}`; 5 | 6 | export function getCanonicalUA(): string { 7 | if (conf().analytics.hideUA) { 8 | const fakeUAs = import.meta.env.AL_FAKE_UAS; 9 | return fakeUAs[Math.floor(Math.random() * fakeUAs.length)]; 10 | } else { 11 | return ALICORN_CANONICAL_UA; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/main/sys/window-control.ts: -------------------------------------------------------------------------------- 1 | import { type BrowserWindow, screen } from "electron"; 2 | 3 | /** 4 | * Gets an optimal window size for the main window. 5 | * 6 | * This function sizes the window based on the size of the primary display, with the fixed aspect ratio 16:10 and scale 7 | * factor 0.6. 8 | */ 9 | function optimalSize(): [number, number] { 10 | let { width, height } = screen.getPrimaryDisplay().workAreaSize; 11 | const ratio = width / height; 12 | const expRatio = 16 / 10; 13 | if (ratio > expRatio) { 14 | width = height * expRatio; 15 | } else { 16 | height = width / expRatio; 17 | } 18 | 19 | const scaleFactor = 0.8; 20 | 21 | return [Math.round(width * scaleFactor), Math.round(height * scaleFactor)]; 22 | } 23 | 24 | let mainWindow: BrowserWindow | null = null; 25 | 26 | function setMainWindow(w: typeof mainWindow) { 27 | mainWindow = w; 28 | } 29 | 30 | function getMainWindow() { 31 | return mainWindow; 32 | } 33 | 34 | export const windowControl = { optimalSize, setMainWindow, getMainWindow }; 35 | -------------------------------------------------------------------------------- /src/main/util/fs.ts: -------------------------------------------------------------------------------- 1 | export function isENOENT(e: unknown): boolean { 2 | return typeof e === "object" && e !== null && "code" in e && e.code === "ENOENT"; 3 | } 4 | -------------------------------------------------------------------------------- /src/main/util/i18n.ts: -------------------------------------------------------------------------------- 1 | export const i18nMain = { 2 | language: navigator.language 3 | }; 4 | -------------------------------------------------------------------------------- /src/main/util/misc.ts: -------------------------------------------------------------------------------- 1 | import { produce } from "immer"; 2 | 3 | type Truthy = T extends false | "" | 0 | null | undefined ? never : T; 4 | 5 | export function isTruthy(value: T): value is Truthy { 6 | return !!value; 7 | } 8 | 9 | /** 10 | * Clones the given object, modify it with the given function, then return the cloned object. 11 | */ 12 | export function alter(obj: T, fn: (o: T) => void): T { 13 | const wfn = (o: T) => { 14 | fn(o); 15 | }; 16 | return produce(obj, wfn); 17 | } 18 | 19 | /** 20 | * Removes duplicates from the given array based on the given key selector. 21 | */ 22 | export function uniqueBy(arr: T[], keySelector?: (o: T) => unknown): T[] { 23 | return Array.from(new Map(arr.map(o => [keySelector ? keySelector(o) : o, o])).values()); 24 | } 25 | -------------------------------------------------------------------------------- /src/main/util/module.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * A workaround for esbuild module transform when using ESM output. 3 | */ 4 | export async function unwrapESM(mod: Promise): Promise { 5 | return unwrap(await mod); 6 | } 7 | 8 | function unwrap(mod: T): T { 9 | if (typeof mod !== "object" || mod === null) return mod; 10 | const m = mod as any; 11 | const ent = Object.keys(m); 12 | if (ent.length === 1 && ent[0] === "default" && m.default.__esModule) { 13 | return m.default; 14 | } 15 | return mod; 16 | } 17 | 18 | -------------------------------------------------------------------------------- /src/preload/message.ts: -------------------------------------------------------------------------------- 1 | import { pEvent } from "p-event"; 2 | 3 | /** 4 | * The global type of any message dispatched on the window object. 5 | * 6 | * All messages sent over the window object must implement this interface to avoid possible interference. 7 | */ 8 | interface WindowMessageContent { 9 | channel: string; 10 | detail?: T; 11 | } 12 | 13 | /** 14 | * Retrieves a port sent from the isolated world. 15 | */ 16 | export async function retrievePort(nonce: string): Promise { 17 | const pe = await pEvent(window, "message", { 18 | rejectionEvents: [], 19 | filter(e: MessageEvent) { 20 | return e.data.channel === `port:${nonce}`; 21 | } 22 | }); 23 | 24 | return pe.ports[0]; 25 | } 26 | 27 | /** 28 | * Sends a port to the main world. 29 | */ 30 | export function exposePort(nonce: string, port: MessagePort): void { 31 | window.postMessage({ channel: `port:${nonce}` } satisfies WindowMessageContent, "*", [port]); 32 | } 33 | -------------------------------------------------------------------------------- /src/preload/preload-env.d.ts: -------------------------------------------------------------------------------- 1 | import { type NativeAPI } from "./preload"; 2 | 3 | declare global { 4 | const native: NativeAPI; 5 | } -------------------------------------------------------------------------------- /src/refs/default-vm-args.json: -------------------------------------------------------------------------------- 1 | { 2 | "vmArgs": [ 3 | { 4 | "rules": [ 5 | { 6 | "action": "allow", 7 | "os": { 8 | "name": "osx" 9 | } 10 | } 11 | ], 12 | "value": [ 13 | "-XstartOnFirstThread" 14 | ] 15 | }, 16 | { 17 | "rules": [ 18 | { 19 | "action": "allow", 20 | "os": { 21 | "arch": "x86" 22 | } 23 | } 24 | ], 25 | "value": "-Xss1M" 26 | }, 27 | "-Djava.library.path=${natives_directory}", 28 | "-cp", 29 | "${classpath}" 30 | ] 31 | } -------------------------------------------------------------------------------- /src/refs/jrt-linux-arm.json: -------------------------------------------------------------------------------- 1 | { 2 | "jre-legacy": "https://github.com/adoptium/temurin8-binaries/releases/download/jdk8u442-b06/OpenJDK8U-jre_aarch64_linux_hotspot_8u442b06.tar.gz", 3 | "java-runtime-alpha": "reuse:java-runtime-gamma", 4 | "java-runtime-beta": "reuse:java-runtime-gamma", 5 | "java-runtime-delta": "https://github.com/adoptium/temurin21-binaries/releases/download/jdk-21.0.6%2B7/OpenJDK21U-jre_aarch64_linux_hotspot_21.0.6_7.tar.gz", 6 | "java-runtime-gamma": "https://github.com/adoptium/temurin17-binaries/releases/download/jdk-17.0.14%2B7/OpenJDK17U-jre_aarch64_linux_hotspot_17.0.14_7.tar.gz", 7 | "java-runtime-gamma-snapshot": "reuse:java-runtime-gamma" 8 | } 9 | -------------------------------------------------------------------------------- /src/refs/legacy-assets.json: -------------------------------------------------------------------------------- 1 | [ 2 | "legacy", 3 | "pre-1.6" 4 | ] 5 | -------------------------------------------------------------------------------- /src/refs/legacy-forge-compat.json: -------------------------------------------------------------------------------- 1 | { 2 | "mod-loader": { 3 | "1.2.4": "https://github.com/skjsjhb/ModLoader-Binaries/releases/download/1.0/ModLoader.1.2.4.zip", 4 | "1.2.3": "https://github.com/skjsjhb/ModLoader-Binaries/releases/download/1.0/ModLoader.1.2.3.zip", 5 | "1.1": "https://github.com/skjsjhb/ModLoader-Binaries/releases/download/1.0/ModLoader.1.1.zip" 6 | }, 7 | "damt": { 8 | "name": "damt:damt:0.1", 9 | "downloads": { 10 | "artifact": { 11 | "url": "https://github.com/skjsjhb/ModLoader-Binaries/releases/download/1.0/damt-0.1.jar" 12 | } 13 | } 14 | }, 15 | "damt-arg": "--tweakClass damt.ModLoaderTweaker", 16 | "venv": [ 17 | "1.5.2", 18 | "1.5.1", 19 | "1.5", 20 | "1.4.7", 21 | "1.4.6", 22 | "1.4.5", 23 | "1.4.4", 24 | "1.4.3", 25 | "1.4.2", 26 | "1.4.1", 27 | "1.4", 28 | "1.3.2" 29 | ], 30 | "stripSignature": [ 31 | "1.5.2" 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /src/refs/legacy-forge-libs.json: -------------------------------------------------------------------------------- 1 | { 2 | "argo-small-3.2.jar": "https://github.com/skjsjhb/ffind/releases/download/1.0/argo-small-3.2.jar", 3 | "guava-14.0-rc3.jar": "https://repo1.maven.org/maven2/com/google/guava/guava/14.0-rc3/guava-14.0-rc3.jar", 4 | "asm-all-4.1.jar": "https://github.com/skjsjhb/ffind/releases/download/1.0/asm-all-4.1.jar", 5 | "argo-2.25.jar": "https://repo1.maven.org/maven2/net/sourceforge/argo/argo/2.25/argo-2.25.jar", 6 | "guava-12.0.1.jar": "https://repo1.maven.org/maven2/com/google/guava/guava/12.0.1/guava-12.0.1.jar", 7 | "asm-all-4.0.jar": "https://github.com/skjsjhb/ffind/releases/download/1.0/asm-all-4.0.jar", 8 | "bcprov-jdk15on-147.jar": "https://repo1.maven.org/maven2/org/bouncycastle/bcprov-jdk15on/1.47/bcprov-jdk15on-1.47.jar", 9 | "bcprov-jdk15on-148.jar": "https://repo1.maven.org/maven2/org/bouncycastle/bcprov-jdk15on/1.48/bcprov-jdk15on-1.48.jar", 10 | "scala-library.jar": "https://github.com/skjsjhb/ffind/releases/download/1.0/scala-library.jar", 11 | "deobfuscation_data_1.5.zip": "https://github.com/skjsjhb/ffind/releases/download/1.0/deobfuscation_data_1.5.zip", 12 | "deobfuscation_data_1.5.1.zip": "https://github.com/skjsjhb/ffind/releases/download/1.0/deobfuscation_data_1.5.1.zip", 13 | "deobfuscation_data_1.5.2.zip": "https://github.com/skjsjhb/ffind/releases/download/1.0/deobfuscation_data_1.5.2.zip" 14 | } 15 | -------------------------------------------------------------------------------- /src/refs/rift-compat.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultVersion": "1.0.4-87", 3 | "versions": [ 4 | "1.13" 5 | ], 6 | "libs": [ 7 | { 8 | "name": "org.dimdev:mixin:0.7.11-SNAPSHOT", 9 | "downloads": { 10 | "artifact": { 11 | "url": "https://github.com/skjsjhb/lost-libraries/releases/download/1.0/mixin-0.7.11-evil.jar", 12 | "sha1": "64f9be9dcab3fec97fe4d571f5a602ede704ff49" 13 | } 14 | } 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /src/renderer/components/compound/ExtendedAccountPicker.tsx: -------------------------------------------------------------------------------- 1 | import { AccountPicker } from "@components/input/AccountPicker"; 2 | import { YggdrasilFormDialog } from "@components/modal/YggdrasilFormDialog"; 3 | import { Button, cn } from "@heroui/react"; 4 | import { UserPlus2Icon } from "lucide-react"; 5 | import { nanoid } from "nanoid"; 6 | import React, { type HTMLProps, useState } from "react"; 7 | import { useTranslation } from "react-i18next"; 8 | 9 | interface ExtendedAccountPickerProps extends HTMLProps { 10 | accountId: string | null; 11 | onAccountChange: (accountId: string) => void; 12 | } 13 | 14 | 15 | export function ExtendedAccountPicker({ accountId, onAccountChange, className }: ExtendedAccountPickerProps) { 16 | const { t } = useTranslation("common", { keyPrefix: "account-picker" }); 17 | const [formOpen, setFormOpen] = useState(false); 18 | const [formKey, setFormKey] = useState(""); 19 | 20 | function openForm() { 21 | setFormKey(nanoid()); 22 | setFormOpen(true); 23 | } 24 | 25 | return
26 | 27 | 30 | setFormOpen(false)}/> 31 |
; 32 | 33 | } 34 | -------------------------------------------------------------------------------- /src/renderer/components/display/Alert.tsx: -------------------------------------------------------------------------------- 1 | import { Alert as RawAlert } from "@heroui/react"; 2 | import { useEffect, useRef } from "react"; 3 | 4 | type AlertProps = Parameters[0]; 5 | 6 | /** 7 | * A patch on the alert component to remove the title. 8 | */ 9 | export function Alert(props: AlertProps) { 10 | const ref = useRef(null); 11 | const { classNames, ...rest } = props; 12 | 13 | useEffect(() => { 14 | if (ref.current) { 15 | ref.current.removeAttribute("title"); 16 | } 17 | }, [ref.current]); 18 | 19 | return ; 27 | } 28 | -------------------------------------------------------------------------------- /src/renderer/components/display/GameTypeIcon.tsx: -------------------------------------------------------------------------------- 1 | import type { GameCoreType } from "@/main/game/spec"; 2 | import cobblestone from "@assets/img/cobblestone.webp"; 3 | import crafter from "@assets/img/crafter.webp"; 4 | import craftingTable from "@assets/img/crafting-table.png"; 5 | import fabric from "@assets/img/fabric.webp"; 6 | import forge from "@assets/img/forge.svg"; 7 | import liteloader from "@assets/img/liteloader.webp"; 8 | import neoforged from "@assets/img/neoforged.webp"; 9 | import oakPlanks from "@assets/img/oak-planks.webp"; 10 | import optifine from "@assets/img/optifine.webp"; 11 | import quilt from "@assets/img/quilt.webp"; 12 | import rift from "@assets/img/rift.webp"; 13 | import tnt from "@assets/img/tnt.webp"; 14 | import { cn } from "@heroui/react"; 15 | import React from "react"; 16 | 17 | interface GameTypeIconProps extends React.HTMLProps { 18 | gameType: GameCoreType; 19 | wrapperClassName?: string; 20 | } 21 | 22 | const imageMap: Record = { 23 | "vanilla-release": craftingTable, 24 | "vanilla-snapshot": crafter, 25 | "vanilla-old-alpha": oakPlanks, 26 | "vanilla-old-beta": cobblestone, 27 | forge, fabric, quilt, rift, neoforged, liteloader, optifine, 28 | "unknown": tnt 29 | }; 30 | 31 | 32 | /** 33 | * Displays a suitable icon for the given game loader type. 34 | */ 35 | export function GameTypeIcon({ gameType, className, wrapperClassName, ...rest }: GameTypeIconProps) { 36 | const src = imageMap[gameType] ?? tnt; 37 | 38 | return
39 |
40 | {gameType} 41 |
42 |
; 43 | } 44 | -------------------------------------------------------------------------------- /src/renderer/components/display/SkinAvatar.tsx: -------------------------------------------------------------------------------- 1 | import { getEmptyImage } from "@/renderer/util/misc"; 2 | import { cn } from "@heroui/react"; 3 | import React from "react"; 4 | 5 | interface SkinAvatarProps extends React.HTMLProps { 6 | avatarSrc: [string, string]; 7 | } 8 | 9 | export function SkinAvatar({ avatarSrc, className, ...rest }: SkinAvatarProps) { 10 | let [headUrl, helmUrl] = avatarSrc; 11 | headUrl = headUrl || getEmptyImage(); 12 | helmUrl = helmUrl || getEmptyImage(); 13 | 14 | return
21 | Head 27 | 28 | Helm 34 |
; 35 | } 36 | -------------------------------------------------------------------------------- /src/renderer/components/display/TipPicker.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useTranslation } from "react-i18next"; 3 | 4 | export function TipPicker({ tipKey }: { tipKey: string }) { 5 | const { t } = useTranslation("common", { keyPrefix: "tips" }); 6 | const values = t(tipKey, { returnObjects: true }) as string[]; 7 | const [index, _] = useState(Math.floor(Math.random() * values.length)); 8 | 9 | return values[index]; 10 | } 11 | -------------------------------------------------------------------------------- /src/renderer/components/display/WizardCard.tsx: -------------------------------------------------------------------------------- 1 | import React, { type PropsWithChildren } from "react"; 2 | 3 | interface WizardCardProps { 4 | title: string; 5 | sub: string; 6 | content?: React.ReactNode; 7 | } 8 | 9 | 10 | export function WizardCard({ title, sub, content, children }: PropsWithChildren) { 11 | return
12 |
13 |
14 |

{title}

15 |

{sub}

16 | {content} 17 |
18 |
19 | {children} 20 |
21 |
22 |
; 23 | } 24 | -------------------------------------------------------------------------------- /src/renderer/components/input/AccountPicker.tsx: -------------------------------------------------------------------------------- 1 | import { useAccounts } from "@/renderer/services/accounts"; 2 | import { SkinAvatar } from "@components/display/SkinAvatar"; 3 | import { CardRadio } from "@components/input/CardRadio"; 4 | import { RadioGroup } from "@heroui/radio"; 5 | import { Skeleton } from "@heroui/react"; 6 | import { UserPlus2Icon } from "lucide-react"; 7 | import React, { useEffect, useState } from "react"; 8 | import { useTranslation } from "react-i18next"; 9 | 10 | interface AccountSelectorProps { 11 | accountId: string | null; 12 | allowCreation?: boolean; 13 | onChange: (accountId: string) => void; 14 | } 15 | 16 | export function AccountPicker({ accountId, allowCreation, onChange }: AccountSelectorProps) { 17 | const { t } = useTranslation("common", { keyPrefix: "account-picker" }); 18 | const accounts = useAccounts(); 19 | 20 | return 21 | { 22 | allowCreation && 23 | } 28 | /> 29 | } 30 | 31 | { 32 | accounts.map(a => 33 | } 39 | /> 40 | ) 41 | } 42 | ; 43 | } 44 | 45 | interface PickEntryProps { 46 | value: string; 47 | title: string; 48 | sub: string; 49 | icon: React.ReactNode; 50 | } 51 | 52 | function PickEntry({ icon, value, title, sub }: PickEntryProps) { 53 | return 54 |
55 |
56 | {icon} 57 |
58 | 59 |
60 |
{title}
61 |
{sub}
62 |
63 |
64 |
; 65 | } 66 | 67 | function AccountSkinAvatar({ accountId }: { accountId: string }) { 68 | const [skinUrls, setSkinUrls] = useState<[string, string] | null>(null); 69 | 70 | useEffect(() => { 71 | native.auth.getSkinAvatar(accountId).then(setSkinUrls); 72 | }, [accountId]); 73 | 74 | if (!skinUrls) { 75 | return ; 76 | } 77 | 78 | return ; 79 | } 80 | -------------------------------------------------------------------------------- /src/renderer/components/input/CardRadio.tsx: -------------------------------------------------------------------------------- 1 | import { Radio } from "@heroui/radio"; 2 | 3 | export function CardRadio(props: Parameters[0]) { 4 | const { classNames, ...rest } = props; 5 | 6 | return ; 15 | } 16 | -------------------------------------------------------------------------------- /src/renderer/components/input/Editable.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from "@heroui/react"; 2 | import React, { type PropsWithChildren, useEffect, useRef, useState } from "react"; 3 | 4 | interface EditableProps { 5 | value: string; 6 | onValueChange: (v: string) => void; 7 | inputProps?: Parameters[0]; 8 | } 9 | 10 | export function Editable({ value, onValueChange, children, inputProps }: PropsWithChildren) { 11 | const [editing, setEditing] = useState(false); 12 | const inputRef = useRef(null); 13 | 14 | useEffect(() => { 15 | if (editing) { 16 | inputRef?.current?.focus(); 17 | } 18 | }, [inputRef, editing]); 19 | 20 | function handleBlur(e: React.FocusEvent) { 21 | onValueChange(e.target.value); 22 | setEditing(false); 23 | } 24 | 25 | if (!editing) { 26 | return setEditing(true)}>{children}; 27 | } 28 | 29 | return ; 37 | } 38 | -------------------------------------------------------------------------------- /src/renderer/components/input/FileSelectInput.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Input } from "@heroui/react"; 2 | import { EllipsisIcon } from "lucide-react"; 3 | import { useEffect, useState } from "react"; 4 | 5 | interface FileSelectInputProps { 6 | value: string; 7 | onChange: (pt: string) => void; 8 | selector?: () => string | Promise; 9 | } 10 | 11 | export function FileSelectInput({ value, onChange, selector }: FileSelectInputProps) { 12 | const [internalValue, setInternalValue] = useState(value); 13 | 14 | useEffect(() => { 15 | setInternalValue(value); 16 | }, [value]); 17 | 18 | async function runSelect() { 19 | const d = await (selector ? selector() : native.ext.selectDir()); 20 | if (d) { 21 | setInternalValue(d); 22 | onChange(d); 23 | } 24 | } 25 | 26 | return
27 | onChange(internalValue)}/> 28 | 31 |
; 32 | } 33 | -------------------------------------------------------------------------------- /src/renderer/components/input/PlayerNameInput.tsx: -------------------------------------------------------------------------------- 1 | import { Input } from "@heroui/react"; 2 | import { useState } from "react"; 3 | import { useTranslation } from "react-i18next"; 4 | 5 | interface PlayerNameInputProps { 6 | onChange: (value: string) => void; 7 | } 8 | 9 | export function PlayerNameInput({ onChange }: PlayerNameInputProps) { 10 | const { t } = useTranslation("common", { keyPrefix: "input.player-name" }); 11 | const [internalValue, setInternalValue] = useState(""); 12 | 13 | function validate(s: string) { 14 | return /[0-9A-Z_]{3,16}/i.test(s); 15 | } 16 | 17 | const isValid = validate(internalValue); 18 | 19 | function handleValueChange(s: string) { 20 | setInternalValue(s); 21 | if (!s) { 22 | onChange("Player"); 23 | } else { 24 | if (validate(s)) { 25 | onChange(s); 26 | } else { 27 | onChange(""); 28 | } 29 | } 30 | } 31 | 32 | return 0} // Do not let the box become red by default 36 | value={internalValue} 37 | onValueChange={handleValueChange} 38 | label={t("label")} 39 | />; 40 | } 41 | -------------------------------------------------------------------------------- /src/renderer/components/input/StringArrayInput.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Input } from "@heroui/react"; 2 | import { PlusIcon, XIcon } from "lucide-react"; 3 | import React, { useState } from "react"; 4 | import { useTranslation } from "react-i18next"; 5 | 6 | interface StringArrayInputProps { 7 | value: string[]; 8 | onChange: (value: string[]) => void; 9 | } 10 | 11 | export function StringArrayInput({ value, onChange }: StringArrayInputProps) { 12 | const { t } = useTranslation("common", { keyPrefix: "input.string-array" }); 13 | const [str, setStr] = useState(""); 14 | 15 | function addItem() { 16 | const s = str.trim(); 17 | if (s) { 18 | const d = [...value, s]; 19 | onChange(d); 20 | setStr(""); 21 | } 22 | } 23 | 24 | function removeItem(i: number) { 25 | const d = value.slice(0, i).concat(value.slice(i + 1)); 26 | onChange(d); 27 | } 28 | 29 | return
30 | { 31 | value.map((s, i) => 32 |
33 |
removeItem(i)} 36 | > 37 | 38 |
39 | 40 |
41 | {s} 42 |
43 |
44 | ) 45 | } 46 | 47 |
48 | 49 | 52 |
53 | 54 | { 55 | str && 56 |
{t("blur-to-add")}
57 | } 58 |
; 59 | } 60 | -------------------------------------------------------------------------------- /src/renderer/components/misc/AnimatedRoute.tsx: -------------------------------------------------------------------------------- 1 | import { AnimatePresence, motion } from "framer-motion"; 2 | import React from "react"; 3 | import { useRoute } from "wouter"; 4 | 5 | interface AnimatedRouteProps { 6 | path: string; 7 | component: React.ComponentType<{ params: any }>; 8 | } 9 | 10 | export type PropsWithParams = { params: T } 11 | 12 | export function AnimatedRoute({ path, component }: AnimatedRouteProps) { 13 | const [matched, params] = useRoute(path); 14 | 15 | const Component = component; 16 | 17 | return 18 | { 19 | matched && 20 | 30 | 31 | 32 | } 33 | ; 34 | } 35 | -------------------------------------------------------------------------------- /src/renderer/components/modal/AccountSelectorDialog.tsx: -------------------------------------------------------------------------------- 1 | import { ExtendedAccountPicker } from "@components/compound/ExtendedAccountPicker"; 2 | import type { PropsWithDialog } from "@components/modal/DialogProvider"; 3 | import { Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from "@heroui/react"; 4 | import { useState } from "react"; 5 | import { useTranslation } from "react-i18next"; 6 | 7 | export function AccountSelectorDialog(props: PropsWithDialog) { 8 | const { t } = useTranslation("common", { keyPrefix: "account-selector-dialog" }); 9 | const { onResult, isOpen } = props; 10 | const [accountId, setAccountId] = useState("new"); 11 | return 12 | 13 | {t("title")} 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | ; 22 | } 23 | -------------------------------------------------------------------------------- /src/renderer/components/modal/ConfirmPopup.tsx: -------------------------------------------------------------------------------- 1 | import type { OverlayPlacement } from "@heroui/aria-utils"; 2 | import { Button, Popover, PopoverContent, PopoverTrigger } from "@heroui/react"; 3 | import { ArrowRightIcon } from "lucide-react"; 4 | import React, { type PropsWithChildren, useState } from "react"; 5 | 6 | interface ConfirmPopupProps { 7 | title: string; 8 | sub: string; 9 | btnText: string; 10 | placement?: OverlayPlacement; 11 | onConfirm: () => void; 12 | color?: "default" | "primary" | "secondary" | "success" | "warning" | "danger"; 13 | } 14 | 15 | export function ConfirmPopup( 16 | { 17 | title, 18 | placement, 19 | sub, 20 | btnText, 21 | children, 22 | onConfirm, 23 | color 24 | } 25 | : PropsWithChildren 26 | ) { 27 | const [isOpen, setOpen] = useState(false); 28 | 29 | function handlePress() { 30 | setOpen(false); 31 | onConfirm(); 32 | } 33 | 34 | return 35 | 36 | {children} 37 | 38 | 39 |
40 |
{title}
41 | 42 |
{sub}
43 | 51 |
52 |
53 |
; 54 | } 55 | -------------------------------------------------------------------------------- /src/renderer/components/modal/DialogProvider.tsx: -------------------------------------------------------------------------------- 1 | import { nanoid } from "nanoid"; 2 | import React, { type PropsWithChildren, useContext, useRef, useState } from "react"; 3 | 4 | export interface DialogProps { 5 | isOpen: boolean; 6 | onResult: (v: T) => void; 7 | } 8 | 9 | export type PropsWithDialog = DialogProps & P; 10 | 11 | interface DialogProviderProps { 12 | dialogProps: P; 13 | component: React.ComponentType>; 14 | } 15 | 16 | interface DialogProviderContextContent { 17 | openDialog: () => Promise; 18 | } 19 | 20 | const DialogProviderContext = React.createContext | null>(null); 21 | 22 | export function DialogProvider(props: PropsWithChildren>) { 23 | const [mount, setMount] = useState(false); 24 | const [open, setOpen] = useState(false); 25 | const [key, setKey] = useState(nanoid()); 26 | const resolver = useRef<((v: T) => void) | null>(null); 27 | const unmountTimer = useRef(null); 28 | 29 | function handleResult(v: T) { 30 | if (resolver.current) { 31 | resolver.current(v); 32 | resolver.current = null; 33 | setOpen(false); 34 | 35 | // Delay unmount to display the full animation 36 | unmountTimer.current = window.setTimeout(() => setMount(false), 2000); 37 | } 38 | } 39 | 40 | function openDialog() { 41 | if (unmountTimer.current) { 42 | window.clearTimeout(unmountTimer.current); 43 | unmountTimer.current = null; 44 | } 45 | 46 | setKey(nanoid()); 47 | setOpen(true); 48 | setMount(true); 49 | 50 | const { promise, resolve } = Promise.withResolvers(); 51 | resolver.current = resolve; 52 | 53 | return promise; 54 | } 55 | 56 | const contextValue: DialogProviderContextContent = { openDialog }; 57 | 58 | const DialogComponent = props.component; 59 | 60 | return 61 | {props.children} 62 | { 63 | mount && 64 | } 65 | 66 | ; 67 | } 68 | 69 | export function useOpenDialog(): () => Promise { 70 | const ctx = useContext(DialogProviderContext); 71 | if (!ctx) return () => Promise.reject("Parent dialog context not found"); 72 | 73 | return ctx.openDialog as () => Promise; 74 | } 75 | -------------------------------------------------------------------------------- /src/renderer/components/modal/MessageBox.tsx: -------------------------------------------------------------------------------- 1 | import { cn, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader } from "@heroui/react"; 2 | import React, { type PropsWithChildren } from "react"; 3 | 4 | interface MessageBoxProps extends PropsWithChildren { 5 | title: string; 6 | icon: React.ReactNode; 7 | color: "success" | "info" | "warning" | "danger"; 8 | footer?: React.ReactNode; 9 | isOpen?: boolean; 10 | defaultOpen?: boolean; 11 | onClose?: () => void; 12 | } 13 | 14 | export function MessageBox({ title, icon, color, footer, children, isOpen, defaultOpen, onClose }: MessageBoxProps) { 15 | const colors = { 16 | success: "text-success-500 bg-success-100", 17 | info: "text-primary-500 bg-primary-100", 18 | warning: "text-warning-500 bg-warning-100", 19 | danger: "text-danger-500 bg-danger-100" 20 | } as const; 21 | 22 | return 25 | 26 | {title} 27 | 28 |
29 |
30 | {icon} 31 |
32 | 33 |
34 | {children} 35 |
36 |
37 |
38 | 39 | {footer} 40 | 41 |
42 |
; 43 | } 44 | -------------------------------------------------------------------------------- /src/renderer/fonts.css: -------------------------------------------------------------------------------- 1 | .lang-zh, .lang-zh-CN, .lang-en { 2 | font-family: "Noto Sans Variable", "Noto Sans SC Variable", sans-serif; 3 | } 4 | 5 | pre, code { 6 | font-family: "JetBrains Mono Variable", monospace; 7 | } 8 | -------------------------------------------------------------------------------- /src/renderer/global.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | html, body { 6 | width: 100%; 7 | height: 100%; 8 | user-select: none; 9 | } 10 | 11 | .drag { 12 | app-region: drag; 13 | } 14 | 15 | .no-drag { 16 | app-region: no-drag; 17 | } 18 | 19 | ::-webkit-scrollbar { 20 | width: 0.4rem; 21 | height: 0.4rem; 22 | } 23 | 24 | ::-webkit-scrollbar-track { 25 | opacity: 0; 26 | } 27 | 28 | ::-webkit-scrollbar-thumb { 29 | background-color: #888; 30 | border-radius: 0.25rem; 31 | transition-duration: 100ms; 32 | } 33 | 34 | ::-webkit-scrollbar-thumb:hover { 35 | background-color: #555; 36 | } 37 | 38 | svg.lucide { 39 | padding: 3px; 40 | } 41 | 42 | svg:focus { 43 | outline: none; 44 | } 45 | 46 | pre { 47 | font-family: "Ubuntu Mono", monospace; 48 | } 49 | 50 | a, img, svg { 51 | -webkit-user-drag: none; 52 | } 53 | -------------------------------------------------------------------------------- /src/renderer/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Alicorn Launcher 5 | 6 | 10 | 11 | 12 |
13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/renderer/pages/about/AboutView.tsx: -------------------------------------------------------------------------------- 1 | import { Pagination } from "@heroui/react"; 2 | import { AppInfo } from "@pages/about/AppInfo"; 3 | import { FeaturesInfo } from "@pages/about/FeaturesInfo"; 4 | import { PackagesInfo } from "@pages/about/PackagesInfo"; 5 | import React, { useState } from "react"; 6 | 7 | /** 8 | * The about page. 9 | */ 10 | export function AboutView() { 11 | const [page, setPage] = useState(1); 12 | 13 | const pages = [ 14 | AppInfo, 15 | PackagesInfo, 16 | FeaturesInfo 17 | ]; 18 | 19 | const Page = pages[page - 1]; 20 | 21 | return
22 |
23 |
24 | 25 |
26 |
27 | 35 |
; 36 | } 37 | -------------------------------------------------------------------------------- /src/renderer/pages/about/PackagesInfo.tsx: -------------------------------------------------------------------------------- 1 | import { Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from "@heroui/react"; 2 | import { useTranslation } from "react-i18next"; 3 | import pkg from "~/package.json"; 4 | 5 | export function PackagesInfo() { 6 | const { t } = useTranslation("pages", { keyPrefix: "about.subtitles" }); 7 | 8 | return <> 9 |

{t("packages-info")}

10 | 11 | ; 12 | } 13 | 14 | function Packages() { 15 | const packages = Object.entries(pkg.dependencies); 16 | 17 | const { t } = useTranslation("pages", { keyPrefix: "about" }); 18 | 19 | return 20 | 21 | 22 | {t("packages.name")} 23 | 24 | 25 | {t("packages.ver")} 26 | 27 | 28 | 29 | { 30 | packages.map(([name, version]) => { 31 | return 32 | {name} 33 | {version} 34 | ; 35 | }) 36 | } 37 | 38 |
; 39 | } 40 | -------------------------------------------------------------------------------- /src/renderer/pages/create-game-wizard/CreateGameWizardView.tsx: -------------------------------------------------------------------------------- 1 | import type { CreateGameInit } from "@/main/api/game"; 2 | import { AnimatedRoute } from "@components/misc/AnimatedRoute"; 3 | import { FinishView } from "@pages/create-game-wizard/FinishView"; 4 | import { PickAccountView } from "@pages/create-game-wizard/PickAccountView"; 5 | import { PickModLoaderView } from "@pages/create-game-wizard/PickModLoaderView"; 6 | import { PickVersionView } from "@pages/create-game-wizard/PickVersionView"; 7 | import React, { useState } from "react"; 8 | import { Redirect } from "wouter"; 9 | 10 | interface CreateGameWizardContextContent { 11 | value: Partial; 12 | setValue: (value: Partial) => void; 13 | } 14 | 15 | const CreateGameWizardContext = React.createContext(null); 16 | 17 | export function CreateGameWizardView() { 18 | const [value, setValue] = useState>({}); 19 | return 20 | 21 | 22 | 23 | 24 | 25 | ; 26 | } 27 | 28 | function DefaultPageRedirect() { 29 | return ; 30 | } 31 | 32 | export function useCreateGameWizardContext() { 33 | const ctx = React.useContext(CreateGameWizardContext); 34 | if (!ctx) throw "Should not try to use game creation context outside its provider"; 35 | return ctx; 36 | } 37 | -------------------------------------------------------------------------------- /src/renderer/pages/create-game-wizard/FinishView.tsx: -------------------------------------------------------------------------------- 1 | import { remoteInstaller } from "@/renderer/services/install"; 2 | import { useNav } from "@/renderer/util/nav"; 3 | import { WizardCard } from "@components/display/WizardCard"; 4 | import { Button } from "@heroui/react"; 5 | import { useCreateGameWizardContext } from "@pages/create-game-wizard/CreateGameWizardView"; 6 | import { ArrowRightIcon, CheckIcon, PlayCircleIcon } from "lucide-react"; 7 | import React from "react"; 8 | import { useTranslation } from "react-i18next"; 9 | 10 | export function FinishView() { 11 | const { t } = useTranslation("pages", { keyPrefix: "create-game-wizard.finish" }); 12 | const { t: tc } = useTranslation("pages", { keyPrefix: "create-game" }); 13 | const ctx = useCreateGameWizardContext(); 14 | const nav = useNav(); 15 | 16 | async function finalize(install: boolean) { 17 | const gid = await native.game.add({ 18 | name: tc("default-name"), 19 | containerId: undefined, 20 | accountId: ctx.value.accountId ?? "", 21 | gameVersion: ctx.value.gameVersion!, 22 | assetsLevel: "full", 23 | installProps: { 24 | type: ctx.value.installProps!.type, 25 | gameVersion: ctx.value.gameVersion!, 26 | loaderVersion: "" 27 | } as any, 28 | containerShouldLink: true 29 | }); 30 | 31 | if (install) { 32 | void remoteInstaller.install(gid); 33 | } 34 | 35 | nav("/games"); 36 | } 37 | 38 | return 43 |
44 | 52 | 53 | 60 |
61 | 62 | } 63 | > 64 |
65 | 66 | {tc("default-name")} / {ctx.value.gameVersion} 67 |
68 |
; 69 | } 70 | -------------------------------------------------------------------------------- /src/renderer/pages/create-game/AssetsLevelSelector.tsx: -------------------------------------------------------------------------------- 1 | import type { GameAssetsLevel } from "@/main/game/spec"; 2 | import { Radio, RadioGroup } from "@heroui/radio"; 3 | import React from "react"; 4 | import { useTranslation } from "react-i18next"; 5 | 6 | interface AssetsLevelSelectorProps { 7 | assetsLevel: GameAssetsLevel; 8 | onChange: (v: GameAssetsLevel) => void; 9 | } 10 | 11 | export function AssetLevelSelector({ assetsLevel, onChange }: AssetsLevelSelectorProps) { 12 | const { t } = useTranslation("pages", { keyPrefix: "create-game.assets-level" }); 13 | 14 | return onChange(v as GameAssetsLevel)} 18 | > 19 | 20 | {t("full.label")} 21 | 22 | 23 | {t("video-only.label")} 24 | 25 | ; 26 | } 27 | -------------------------------------------------------------------------------- /src/renderer/pages/create-game/ContainerSelector.tsx: -------------------------------------------------------------------------------- 1 | import { useGameList } from "@/renderer/services/games"; 2 | import { Select, SelectItem, type SharedSelection } from "@heroui/react"; 3 | import { useTranslation } from "react-i18next"; 4 | 5 | interface ContainerSelectorProps { 6 | containerId?: string; 7 | onChange: (containerId?: string) => void; 8 | } 9 | 10 | export function ContainerSelector({ containerId, onChange }: ContainerSelectorProps) { 11 | const { t } = useTranslation("pages", { keyPrefix: "create-game" }); 12 | 13 | const games = useGameList(); 14 | 15 | const sid = games.find(g => g.launchHint.containerId === containerId)?.id; 16 | 17 | function handleSelectionChange(s: SharedSelection) { 18 | if (s instanceof Set) { 19 | const gid = [...s][0]; 20 | if (!gid) { 21 | onChange(undefined); 22 | } else { 23 | onChange(games.find(g => g.id === gid)?.launchHint.containerId); 24 | } 25 | } 26 | } 27 | 28 | return ; 45 | } 46 | -------------------------------------------------------------------------------- /src/renderer/pages/create-game/ModLoaderSelector.tsx: -------------------------------------------------------------------------------- 1 | import type { GameCoreType } from "@/main/game/spec"; 2 | import { GameTypeIcon } from "@components/display/GameTypeIcon"; 3 | import { CardRadio } from "@components/input/CardRadio"; 4 | import { RadioGroup } from "@heroui/radio"; 5 | import { Spinner } from "@heroui/react"; 6 | import React, { useEffect } from "react"; 7 | import { useTranslation } from "react-i18next"; 8 | 9 | interface ModLoaderSelectorProps { 10 | availableModLoaders: string[] | null; 11 | value: string; 12 | onChange: (v: string) => void; 13 | } 14 | 15 | export function ModLoaderSelector({ availableModLoaders, value, onChange }: ModLoaderSelectorProps) { 16 | const { t } = useTranslation("pages", { keyPrefix: "create-game.mod-loader" }); 17 | 18 | const loaders = availableModLoaders ?? []; 19 | 20 | loaders.unshift("vanilla"); 21 | 22 | useEffect(() => { 23 | if (availableModLoaders && !availableModLoaders.includes(value)) { 24 | onChange("vanilla"); 25 | } 26 | }, [availableModLoaders]); 27 | 28 | return <> 29 | 33 | { 34 | !availableModLoaders && 35 |
36 | 37 | {t("loading")} 38 |
39 | } 40 | { 41 | (["vanilla", "fabric", "quilt", "neoforged", "forge", "rift", "liteloader", "optifine"] as const) 42 | .map(lv => { 43 | // Allow mod loaders to be chosen before availability check in case the network is slow 44 | if (availableModLoaders !== null && !loaders.includes(lv)) return null; 45 | 46 | const iconType: GameCoreType = lv === "vanilla" ? "vanilla-release" : lv; 47 | 48 | return 49 |
50 | 51 | 52 |
53 |
{t(`${lv}.label`)}
54 |
{t(`${lv}.sub`)}
55 |
56 |
57 |
; 58 | }) 59 | } 60 |
61 | 62 |
{t("missing")}
63 | ; 64 | 65 | 66 | } 67 | -------------------------------------------------------------------------------- /src/renderer/pages/create-game/ModLoaderVersionSelector.tsx: -------------------------------------------------------------------------------- 1 | import { Alert } from "@components/display/Alert"; 2 | import { Radio, RadioGroup } from "@heroui/radio"; 3 | import { Input } from "@heroui/react"; 4 | import React, { useState } from "react"; 5 | import { useTranslation } from "react-i18next"; 6 | 7 | interface ModLoaderVersionSelectorProps { 8 | value: string; 9 | onChange: (v: string) => void; 10 | } 11 | 12 | export function ModLoaderVersionSelector({ value, onChange }: ModLoaderVersionSelectorProps) { 13 | const { t } = useTranslation("pages", { keyPrefix: `create-game.loader-version` }); 14 | const [isAuto, setIsAuto] = useState(true); 15 | 16 | function handleSelectionChange(v: string) { 17 | if (v === "auto") { 18 | onChange(""); 19 | } 20 | setIsAuto(v !== "manual"); 21 | } 22 | 23 | return
24 |
{t("title")}
25 | 26 | 31 | { 32 | ["auto", "manual"].map(lv => 33 | {t(lv)} 34 | ) 35 | } 36 | 37 | 38 | { 39 | !isAuto && 40 | <> 41 | 42 | 43 | 44 | } 45 |
; 46 | } 47 | -------------------------------------------------------------------------------- /src/renderer/pages/game-detail/AccountPanel.tsx: -------------------------------------------------------------------------------- 1 | import { alter } from "@/main/util/misc"; 2 | import { ExtendedAccountPicker } from "@components/compound/ExtendedAccountPicker"; 3 | import { useCurrentGameProfile } from "@pages/game-detail/GameProfileProvider"; 4 | 5 | export function AccountPanel() { 6 | const game = useCurrentGameProfile(); 7 | 8 | function handleAccountChange(id: string) { 9 | void native.game.update(alter(game, g => g.launchHint.accountId = id)); 10 | } 11 | 12 | return
13 |
14 | 15 |
16 |
; 17 | } 18 | -------------------------------------------------------------------------------- /src/renderer/pages/game-detail/AddonsPanel.tsx: -------------------------------------------------------------------------------- 1 | import { AddonSearchList } from "@components/compound/AddonSearchList"; 2 | import { useCurrentGameProfile } from "@pages/game-detail/GameProfileProvider"; 3 | 4 | export function AddonsPanel() { 5 | const game = useCurrentGameProfile(); 6 | 7 | return
8 | 9 |
; 10 | } 11 | -------------------------------------------------------------------------------- /src/renderer/pages/game-detail/GameProfileProvider.tsx: -------------------------------------------------------------------------------- 1 | import type { GameProfile } from "@/main/game/spec"; 2 | import React, { type PropsWithChildren, useContext } from "react"; 3 | 4 | const GameProfileContext = React.createContext(null); 5 | 6 | export function GameProfileProvider({ children, game }: PropsWithChildren<{ game: GameProfile }>) { 7 | return 8 | {children} 9 | ; 10 | } 11 | 12 | export function useCurrentGameProfile(): GameProfile { 13 | const g = useContext(GameProfileContext); 14 | 15 | if (!g) throw "Should not access game profile outside its provider"; 16 | 17 | return g; 18 | } 19 | -------------------------------------------------------------------------------- /src/renderer/pages/game-detail/LocalAddonsPanel.tsx: -------------------------------------------------------------------------------- 1 | import { useMpmManifest } from "@/renderer/services/mpm"; 2 | import { AddonMetaDisplay } from "@components/display/AddonMetaDisplay"; 3 | import { useCurrentGameProfile } from "@pages/game-detail/GameProfileProvider"; 4 | import { useTranslation } from "react-i18next"; 5 | import { VList } from "virtua"; 6 | 7 | export function LocalAddonsPanel() { 8 | const { id: gameId } = useCurrentGameProfile(); 9 | const { t } = useTranslation("pages", { keyPrefix: "game-detail.manage.local-addons" }); 10 | const manifest = useMpmManifest(gameId); 11 | 12 | if (!manifest) return null; 13 | 14 | return
15 | { 16 | manifest.resolved.length === 0 ? 17 |
18 | {t("empty")} 19 |
20 | : 21 | 22 | { 23 | manifest.resolved.map(p => ) 24 | } 25 | 26 | 27 | } 28 |
; 29 | } 30 | -------------------------------------------------------------------------------- /src/renderer/pages/import-game/ImportGameWarningDialog.tsx: -------------------------------------------------------------------------------- 1 | import { useNav } from "@/renderer/util/nav"; 2 | import { MessageBox } from "@components/modal/MessageBox"; 3 | import { Button } from "@heroui/react"; 4 | import { GitPullRequestClosed } from "lucide-react"; 5 | import { useState } from "react"; 6 | import { Trans, useTranslation } from "react-i18next"; 7 | 8 | export function ImportGameWarningDialog() { 9 | const { t } = useTranslation("pages", { keyPrefix: "import-game.warning" }); 10 | const nav = useNav(); 11 | const [isOpen, setOpen] = useState(true); 12 | 13 | function onUserClose() { 14 | setOpen(false); 15 | nav("/games"); 16 | } 17 | 18 | return } 21 | color="danger" 22 | isOpen={isOpen} 23 | onClose={onUserClose} 24 | footer={ 25 | 28 | } 29 | > 30 |

31 | 32 |

33 |
; 34 | } 35 | -------------------------------------------------------------------------------- /src/renderer/pages/monitor-list/MonitorListView.tsx: -------------------------------------------------------------------------------- 1 | import { procService, type RemoteGameProcess, type RemoteGameStatus, useGameProcList } from "@/renderer/services/proc"; 2 | import { useNav } from "@/renderer/util/nav"; 3 | import { GameTypeIcon } from "@components/display/GameTypeIcon"; 4 | import { Button, Card, CardBody, Chip } from "@heroui/react"; 5 | import { ArrowRightIcon, XIcon } from "lucide-react"; 6 | import { useTranslation } from "react-i18next"; 7 | 8 | export function MonitorListView() { 9 | const procs = useGameProcList(); 10 | 11 | return
12 |
13 | { 14 | procs.map(p => ) 15 | } 16 |
17 |
; 18 | } 19 | 20 | const statusColors = { 21 | running: "success", 22 | crashed: "danger", 23 | exited: "default" 24 | } as const; 25 | 26 | function StatusChip({ status }: { status: RemoteGameStatus }) { 27 | const { t } = useTranslation("pages", { keyPrefix: "monitor.status" }); 28 | 29 | return 30 | {t(status)} 31 | ; 32 | } 33 | 34 | function MonitorItem({ proc }: { proc: RemoteGameProcess }) { 35 | const { id, profile: { type, name }, status } = proc; 36 | const nav = useNav(); 37 | 38 | function revealProc() { 39 | nav(`/monitor/${proc.id}`); 40 | } 41 | 42 | function removeProc() { 43 | procService.remove(id); 44 | } 45 | 46 | return 47 | 48 |
49 | 50 | 51 |
{name}
52 | 53 |
54 | 55 |
56 | 57 |
58 | 61 | 62 | { 63 | status !== "running" && 64 | 67 | } 68 |
69 |
70 |
71 |
; 72 | } 73 | -------------------------------------------------------------------------------- /src/renderer/pages/monitor/GameProcessProvider.tsx: -------------------------------------------------------------------------------- 1 | import { type RemoteGameProcess, useGameProcDetail } from "@/renderer/services/proc"; 2 | import React, { type PropsWithChildren, useContext } from "react"; 3 | 4 | const GameProcessContext = React.createContext(null); 5 | 6 | export function GameProcessProvider({ children, procId }: PropsWithChildren<{ procId: string }>) { 7 | const proc = useGameProcDetail(procId); 8 | return 9 | {children} 10 | ; 11 | } 12 | 13 | export function useCurrentProc(): RemoteGameProcess { 14 | const p = useContext(GameProcessContext); 15 | if (!p) { 16 | throw "Should not try to retrieve game process information outside its provider."; 17 | } 18 | return p; 19 | } 20 | -------------------------------------------------------------------------------- /src/renderer/pages/monitor/MemoryUsageChart.tsx: -------------------------------------------------------------------------------- 1 | import { useThemeColorValues } from "@pages/monitor/use-color"; 2 | import { useTranslation } from "react-i18next"; 3 | import AutoSizer from "react-virtualized-auto-sizer"; 4 | import { CartesianGrid, Line, LineChart, XAxis, YAxis } from "recharts"; 5 | 6 | interface MemoryUsageChartProps { 7 | stat: number[]; 8 | } 9 | 10 | export function MemoryUsageChart({ stat }: MemoryUsageChartProps) { 11 | const { primary, background } = useThemeColorValues(); 12 | const { t } = useTranslation("pages", { keyPrefix: "monitor.memory" }); 13 | 14 | const raw = stat.slice(-10); 15 | 16 | while (raw.length < 10) { 17 | raw.unshift(0); 18 | } 19 | 20 | const data = raw.map((mem, i) => ({ 21 | mem: Math.round(mem / 1024 / 1024 * 100) / 100, 22 | time: i === 9 ? t("now") : (i - 9) + "s" 23 | })); 24 | 25 | return
26 | 27 | {({ height, width }) => 28 | 29 | 37 | 38 | 39 | 40 | 41 | } 42 | 43 | 44 |
; 45 | } 46 | -------------------------------------------------------------------------------- /src/renderer/pages/monitor/MonitorActions.tsx: -------------------------------------------------------------------------------- 1 | import type { RemoteGameStatus } from "@/renderer/services/proc"; 2 | import { useNav } from "@/renderer/util/nav"; 3 | import { ConfirmPopup } from "@components/modal/ConfirmPopup"; 4 | import { Button } from "@heroui/react"; 5 | import { ArrowLeftIcon, FolderArchiveIcon, FolderIcon, OctagonXIcon, ScrollTextIcon } from "lucide-react"; 6 | import React from "react"; 7 | import { useTranslation } from "react-i18next"; 8 | 9 | interface MonitorActionsProps { 10 | procId: string; 11 | gameId: string; 12 | status: RemoteGameStatus; 13 | } 14 | 15 | export const MonitorActionsMemo = React.memo(MonitorActions); 16 | 17 | function MonitorActions({ procId, gameId, status }: MonitorActionsProps) { 18 | const { t } = useTranslation("pages", { keyPrefix: "monitor.actions" }); 19 | const nav = useNav(); 20 | 21 | function handleStopAction() { 22 | native.launcher.stop(procId); 23 | } 24 | 25 | const stopDisabled = status !== "running"; 26 | 27 | return
28 | 31 | 34 | 37 | 40 | 48 | 55 | 56 |
; 57 | } 58 | -------------------------------------------------------------------------------- /src/renderer/pages/monitor/PerformanceDisplay.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "@heroui/react"; 2 | import { useCurrentProc } from "@pages/monitor/GameProcessProvider"; 3 | import { MemoryUsageChart } from "@pages/monitor/MemoryUsageChart"; 4 | import React from "react"; 5 | import { useTranslation } from "react-i18next"; 6 | 7 | const EXPLAIN_MEMORY_URL = "https://stackoverflow.com/a/5406063"; 8 | 9 | export function PerformanceDisplay() { 10 | const { memUsage } = useCurrentProc(); 11 | const { t } = useTranslation("pages", { keyPrefix: "monitor" }); 12 | 13 | function handleExplainMemory() { 14 | native.ext.openURL(EXPLAIN_MEMORY_URL); 15 | } 16 | 17 | return
18 |
19 |
{t("memory.title")}
20 |
21 | {t("memory.sub")} 22 | {t("memory.sub-link")} 23 |
24 | 25 |
26 |
; 27 | } 28 | -------------------------------------------------------------------------------- /src/renderer/pages/monitor/use-color.ts: -------------------------------------------------------------------------------- 1 | import { hslToHex } from "@/renderer/util/misc"; 2 | import { useEffect, useState } from "react"; 3 | 4 | /** 5 | * Gets computed color values for HeroUI theme tokens. 6 | * 7 | * This is applied to recharts as it does not support TailwindCSS classes. 8 | */ 9 | export function useThemeColorValues(): { primary: string, background: string } { 10 | const [primary, setPrimary] = useState("#ffffff"); 11 | const [background, setBackground] = useState("#333"); 12 | 13 | useEffect(() => { 14 | const style = getComputedStyle(document.documentElement); 15 | 16 | setPrimary( 17 | hslToHex(style.getPropertyValue("--heroui-primary")) 18 | ); 19 | 20 | setBackground( 21 | hslToHex(style.getPropertyValue("--heroui-foreground-400")) 22 | ); 23 | }, []); 24 | 25 | return { primary, background }; 26 | } 27 | -------------------------------------------------------------------------------- /src/renderer/pages/pages.ts: -------------------------------------------------------------------------------- 1 | import { BoxIcon, CogIcon, InfoIcon, SquareActivityIcon } from "lucide-react"; 2 | import React from "react"; 3 | 4 | /** 5 | * Describes a page in the app. 6 | */ 7 | export interface PageInfo { 8 | /** 9 | * Page ID used when rendering. 10 | */ 11 | id: string; 12 | 13 | /** 14 | * Page icon class. 15 | */ 16 | icon: React.ComponentType; 17 | } 18 | 19 | export const pages: PageInfo[] = [ 20 | { 21 | id: "games", 22 | icon: BoxIcon 23 | }, 24 | { 25 | id: "monitor", 26 | icon: SquareActivityIcon 27 | }, 28 | { 29 | id: "settings", 30 | icon: CogIcon 31 | }, 32 | { 33 | id: "about", 34 | icon: InfoIcon 35 | } 36 | ]; 37 | -------------------------------------------------------------------------------- /src/renderer/pages/settings/DevTab.tsx: -------------------------------------------------------------------------------- 1 | import { useConfig } from "@/renderer/services/conf"; 2 | import { Alert } from "@components/display/Alert"; 3 | import { Divider } from "@heroui/react"; 4 | import { OnOffEntry } from "@pages/settings/SettingsEntry"; 5 | import { AppWindowIcon, SearchCodeIcon } from "lucide-react"; 6 | import React from "react"; 7 | import { useTranslation } from "react-i18next"; 8 | 9 | export function DevTab() { 10 | const { t } = useTranslation("pages", { keyPrefix: "settings" }); 11 | 12 | const { config, alterConfig } = useConfig(); 13 | 14 | if (!config) return null; 15 | 16 | return <> 17 | 22 | 23 | alterConfig(c => c.dev.devTools = v)} 28 | /> 29 | 30 | 31 | 32 | alterConfig(c => c.dev.showFrame = v)} 37 | /> 38 | ; 39 | } 40 | -------------------------------------------------------------------------------- /src/renderer/pages/settings/LaunchTab.tsx: -------------------------------------------------------------------------------- 1 | import { useConfig } from "@/renderer/services/conf"; 2 | import { Divider } from "@heroui/react"; 3 | import { OnOffEntry, StringArrayEntry } from "@pages/settings/SettingsEntry"; 4 | import { GaugeIcon, TerminalIcon } from "lucide-react"; 5 | 6 | export function LaunchTab() { 7 | const { config, alterConfig } = useConfig(); 8 | 9 | if (!config) return null; 10 | 11 | return <> 12 | alterConfig(c => c.runtime.readyboom = v)} 17 | /> 18 | 19 | 20 | 21 | alterConfig(c => c.runtime.args.vm = v)} 26 | /> 27 | 28 | 29 | 30 | alterConfig(c => c.runtime.args.game = v)} 35 | /> 36 | ; 37 | } 38 | -------------------------------------------------------------------------------- /src/renderer/pages/settings/NetworkTab.tsx: -------------------------------------------------------------------------------- 1 | import { useConfig } from "@/renderer/services/conf"; 2 | import { Divider } from "@heroui/react"; 3 | import { NumberSliderEntry, OnOffEntry } from "@pages/settings/SettingsEntry"; 4 | import { ArrowLeftRightIcon, DatabaseBackupIcon, FileDiffIcon, ZapIcon } from "lucide-react"; 5 | import React from "react"; 6 | 7 | /** 8 | * Network configuration page. 9 | */ 10 | export function NetworkTab() { 11 | const { config, alterConfig } = useConfig(); 12 | 13 | if (!config) return null; 14 | 15 | return <> 16 | alterConfig(c => c.net.validate = v)} 21 | /> 22 | 23 | 24 | 25 | alterConfig(c => c.net.allowAria2 = v)} 30 | /> 31 | 32 | 33 | 34 | alterConfig(c => c.net.concurrency = v)} 41 | /> 42 | 43 | 44 | 45 | alterConfig(c => c.net.mirror.enable = v)} 50 | /> 51 | ; 52 | } 53 | -------------------------------------------------------------------------------- /src/renderer/pages/settings/PreferencesTab.tsx: -------------------------------------------------------------------------------- 1 | import { i18n } from "@/renderer/i18n/i18n"; 2 | 3 | import { useConfig } from "@/renderer/services/conf"; 4 | import { themeManager, useTheme } from "@/renderer/theme"; 5 | import { useNav } from "@/renderer/util/nav"; 6 | import { Divider } from "@heroui/react"; 7 | import { ActionEntry, NumberTuningEntry, OnOffEntry, SelectEntry, TextEntry } from "@pages/settings/SettingsEntry"; 8 | import { FileUserIcon, HardDriveUploadIcon, LanguagesIcon, PaletteIcon, UserCogIcon, ZoomInIcon } from "lucide-react"; 9 | import React from "react"; 10 | import { useTranslation } from "react-i18next"; 11 | 12 | /** 13 | * User preferences page. 14 | */ 15 | export function PreferencesTab() { 16 | const { config, alterConfig } = useConfig(); 17 | 18 | const { theme, setTheme } = useTheme(); 19 | const { i18n: i18next } = useTranslation(); 20 | 21 | const nav = useNav(); 22 | 23 | if (!config) return null; 24 | 25 | function rerunSetup() { 26 | localStorage.removeItem("setup.done"); 27 | nav("/setup"); 28 | } 29 | 30 | return <> 31 | alterConfig(c => c.pref.username = v)} 36 | /> 37 | 38 | 39 | 40 | setTheme(t)} 45 | items={themeManager.getThemes()} 46 | /> 47 | 48 | 49 | 50 | v + "%"} 58 | onChange={v => { 59 | alterConfig(c => c.app.window.zoom = v); 60 | native.bwctl.setZoom(v); 61 | }} 62 | /> 63 | 64 | 65 | 66 | void i18n.alterLanguage(lang)} 71 | items={i18n.getAvailableLanguages()} 72 | /> 73 | 74 | 75 | 76 | alterConfig(c => c.app.hotUpdate = v)} 81 | /> 82 | 83 | 84 | 85 | 86 | 87 | ; 88 | } 89 | -------------------------------------------------------------------------------- /src/renderer/pages/settings/PrivacyTab.tsx: -------------------------------------------------------------------------------- 1 | import { useConfig } from "@/renderer/services/conf"; 2 | import { Divider } from "@heroui/react"; 3 | import { OnOffEntry } from "@pages/settings/SettingsEntry"; 4 | import { FileClockIcon, FileHeartIcon, FileLock2Icon, FileX2Icon } from "lucide-react"; 5 | import React from "react"; 6 | 7 | export function PrivacyTab() { 8 | const { config, alterConfig } = useConfig(); 9 | 10 | if (!config) return null; 11 | 12 | return <> 13 | alterConfig(c => c.analytics.crashReports = v)} 18 | /> 19 | 20 | 21 | 22 | alterConfig(c => c.analytics.performanceReports = v)} 27 | /> 28 | 29 | 30 | 31 | alterConfig(c => c.analytics.ping = v)} 36 | /> 37 | 38 | 39 | 40 | alterConfig(c => c.analytics.hideUA = v)} 45 | /> 46 | ; 47 | } 48 | -------------------------------------------------------------------------------- /src/renderer/pages/settings/StorageTab.tsx: -------------------------------------------------------------------------------- 1 | import { useConfig } from "@/renderer/services/conf"; 2 | import { Alert } from "@components/display/Alert"; 3 | import { Divider } from "@heroui/react"; 4 | import { DirEntry } from "@pages/settings/SettingsEntry"; 5 | import { HardDriveIcon } from "lucide-react"; 6 | import React from "react"; 7 | import { useTranslation } from "react-i18next"; 8 | 9 | export function StorageTab() { 10 | const { t } = useTranslation("pages", { keyPrefix: "settings" }); 11 | 12 | const { config, alterConfig } = useConfig(); 13 | 14 | if (!config) return null; 15 | 16 | return <> 17 | 22 | 23 | alterConfig(c => c.paths.store = v)} 28 | /> 29 | 30 | 31 | 32 | alterConfig(c => c.paths.game = v)} 37 | /> 38 | 39 | 40 | 41 | alterConfig(c => c.paths.temp = v)} 46 | /> 47 | ; 48 | } 49 | -------------------------------------------------------------------------------- /src/renderer/pages/setup/FinishView.tsx: -------------------------------------------------------------------------------- 1 | import { useNav } from "@/renderer/util/nav"; 2 | import { Button } from "@heroui/react"; 3 | import { ArrowRightIcon, CheckCircleIcon } from "lucide-react"; 4 | import React from "react"; 5 | import { useTranslation } from "react-i18next"; 6 | 7 | export function FinishView() { 8 | const { t } = useTranslation("setup", { keyPrefix: "finish" }); 9 | const nav = useNav(); 10 | 11 | function finishSetup() { 12 | nav("/"); 13 | localStorage.setItem("setup.done", "1"); 14 | } 15 | 16 | return
17 |
18 |
19 | 20 |
21 | 22 |

23 | {t("title")} 24 |

25 |

26 | {t("sub")} 27 |

28 |
29 | 30 | 38 |
; 39 | } 40 | -------------------------------------------------------------------------------- /src/renderer/pages/setup/GamePathSetupView.tsx: -------------------------------------------------------------------------------- 1 | import { useConfig } from "@/renderer/services/conf"; 2 | 3 | import { FileSelectInput } from "@components/input/FileSelectInput"; 4 | import { Button } from "@heroui/react"; 5 | import { useSetupNextPage } from "@pages/setup/SetupView"; 6 | import { CheckIcon, PackageIcon } from "lucide-react"; 7 | import { useTranslation } from "react-i18next"; 8 | 9 | export function GamePathSetupView() { 10 | const { t } = useTranslation("setup", { keyPrefix: "game-path" }); 11 | const { config, alterConfig } = useConfig(); 12 | const next = useSetupNextPage(); 13 | 14 | if (!config) return null; 15 | 16 | return
17 |
18 |
19 | 20 |
21 |

22 | {t("title")} 23 |

24 |

25 | {t("sub")} 26 |

27 |
28 | 29 |
30 | alterConfig(c => c.paths.game = v)}/> 31 |
32 | 33 | 34 |
; 35 | } 36 | -------------------------------------------------------------------------------- /src/renderer/pages/setup/LanguageView.tsx: -------------------------------------------------------------------------------- 1 | import { i18n } from "@/renderer/i18n/i18n"; 2 | import logo from "@assets/logo.png"; 3 | import { Button, Divider } from "@heroui/react"; 4 | import { useSetupNextPage } from "@pages/setup/SetupView"; 5 | import { useTranslation } from "react-i18next"; 6 | 7 | export function LanguageView() { 8 | const { t } = useTranslation("setup", { keyPrefix: "lang" }); 9 | const next = useSetupNextPage(); 10 | 11 | function setLang(lang: string) { 12 | void i18n.alterLanguage(lang); 13 | next(); 14 | } 15 | 16 | return
17 |
18 | Logo 19 |
Alicorn
20 |
21 | 22 | 23 | 24 |
25 | { 26 | i18n.getAvailableLanguages().map(lang => 27 | 32 | ) 33 | } 34 |
35 |
; 36 | } 37 | -------------------------------------------------------------------------------- /src/renderer/pages/setup/LicenseView.tsx: -------------------------------------------------------------------------------- 1 | import { ConfirmPopup } from "@components/modal/ConfirmPopup"; 2 | import { Button, Card, CardBody } from "@heroui/react"; 3 | import { useSetupNextPage } from "@pages/setup/SetupView"; 4 | import { CircleCheckIcon, FileBadgeIcon } from "lucide-react"; 5 | import { useTranslation } from "react-i18next"; 6 | import license from "~/LICENSE?raw"; 7 | 8 | export function LicenseView() { 9 | const { t } = useTranslation("setup", { keyPrefix: "license" }); 10 | const next = useSetupNextPage(); 11 | 12 | return
13 |
14 |
15 | 16 |
17 | 18 |

{t("title")}

19 |

20 | {t("sub")} 21 |

22 | 23 | 24 | 25 |
26 |                         {license}
27 |                     
28 |
29 |
30 | 31 |
32 |
{t("btn-hint")}
33 | 40 | 41 | 42 |
43 |
44 |
; 45 | } 46 | -------------------------------------------------------------------------------- /src/renderer/pages/setup/MirrorView.tsx: -------------------------------------------------------------------------------- 1 | import { useConfig } from "@/renderer/services/conf"; 2 | import { Radio, RadioGroup } from "@heroui/radio"; 3 | import { Button, Link } from "@heroui/react"; 4 | import { useSetupNextPage } from "@pages/setup/SetupView"; 5 | import { ArrowRightIcon, GaugeIcon } from "lucide-react"; 6 | import React, { useEffect } from "react"; 7 | import { Trans, useTranslation } from "react-i18next"; 8 | 9 | export function MirrorView() { 10 | const { t } = useTranslation("setup", { keyPrefix: "mirror" }); 11 | const { config, alterConfig } = useConfig(); 12 | const next = useSetupNextPage(); 13 | 14 | function showLink() { 15 | native.ext.openURL("https://bmclapidoc.bangbang93.com"); 16 | } 17 | 18 | useEffect(() => { 19 | if (!import.meta.env.AL_ENABLE_BMCLAPI) { 20 | next(); 21 | } 22 | }, []); 23 | 24 | if (!config) return null; 25 | 26 | return
27 |
28 |
29 |
30 | 31 |
32 | 33 |

{t("title")}

34 |

35 | {t("sub")} 36 |

37 |
38 | 39 |
40 | alterConfig(c => c.net.mirror.enable = v === "allow")} 43 | > 44 | {t("allow.label")} 45 | {t("disallow.label")} 46 | 47 |
48 |
49 | 50 |

51 | ]} 55 | /> 56 |

57 | 58 |
59 | 60 |
61 |
; 62 | } 63 | -------------------------------------------------------------------------------- /src/renderer/pages/setup/SetupView.tsx: -------------------------------------------------------------------------------- 1 | import { useNav } from "@/renderer/util/nav"; 2 | import { AnimatedRoute } from "@components/misc/AnimatedRoute"; 3 | import { AccountInitView } from "@pages/setup/AccountInitView"; 4 | import { AnalyticsView } from "@pages/setup/AnalyticsView"; 5 | import { FinishView } from "@pages/setup/FinishView"; 6 | import { GamePathSetupView } from "@pages/setup/GamePathSetupView"; 7 | import { LanguageView } from "@pages/setup/LanguageView"; 8 | import { LicenseView } from "@pages/setup/LicenseView"; 9 | import { MirrorView } from "@pages/setup/MirrorView"; 10 | import { WelcomeView } from "@pages/setup/WelcomeView"; 11 | import { ZoomFactorView } from "@pages/setup/ZoomFactorView"; 12 | import React, { useContext, useState } from "react"; 13 | import { Redirect } from "wouter"; 14 | 15 | interface PagesContextContent { 16 | currentPage: number; 17 | setCurrentPage: (p: number) => void; 18 | pages: string[]; 19 | } 20 | 21 | const PagesContext = React.createContext(null); 22 | 23 | const setupPages = [ 24 | ["lang", LanguageView], 25 | ["welcome", WelcomeView], 26 | ["zoom", ZoomFactorView], 27 | ["license", LicenseView], 28 | ["mirror", MirrorView], 29 | ["game-path", GamePathSetupView], 30 | ["account-init", AccountInitView], 31 | ["analytics", AnalyticsView], 32 | ["finish", FinishView] 33 | ] as [string, React.ComponentType][]; 34 | 35 | export function SetupView() { 36 | const [currentPage, setCurrentPage] = useState(0); 37 | 38 | const pages = setupPages.map(p => p[0]); 39 | 40 | return
41 | 42 | { 43 | setupPages.map(([name, comp]) => 44 | 45 | ) 46 | } 47 | 48 | 49 | 50 |
; 51 | } 52 | 53 | export function useSetupNextPage() { 54 | const ctx = useContext(PagesContext); 55 | if (!ctx) throw "Should not try to use next-page navigation hook outside its provider"; 56 | 57 | const nav = useNav(); 58 | 59 | return () => { 60 | const pt = ctx.pages[ctx.currentPage + 1]; 61 | ctx.setCurrentPage(ctx.currentPage + 1); 62 | nav(`/setup/${pt}`); 63 | }; 64 | } 65 | 66 | function DefaultPageRedirect() { 67 | return ; 68 | } 69 | -------------------------------------------------------------------------------- /src/renderer/pages/setup/WelcomeView.tsx: -------------------------------------------------------------------------------- 1 | import logoLegacy from "@assets/logo-legacy.png"; 2 | import logo from "@assets/logo.png"; 3 | import { useSetupNextPage } from "@pages/setup/SetupView"; 4 | import { ChevronsRight } from "lucide-react"; 5 | import { useTranslation } from "react-i18next"; 6 | 7 | export function WelcomeView() { 8 | const { t } = useTranslation("setup", { keyPrefix: "welcome" }); 9 | const next = useSetupNextPage(); 10 | 11 | return
12 |
13 |
14 | Legacy Logo 15 |
16 | 17 |
18 | Logo 19 |
20 | 21 |
22 |

{t("title")}

23 |

24 | {t("sub")} 25 |

26 |
27 |
28 | 29 | {import.meta.env.AL_DEV &&
{t("is-dev")}
} 30 |
{t("continue")}
31 |
; 32 | } 33 | -------------------------------------------------------------------------------- /src/renderer/services/accounts.ts: -------------------------------------------------------------------------------- 1 | import type { DetailedAccountProps } from "@/main/auth/types"; 2 | import { accountsSlice } from "@/renderer/store/accounts"; 3 | import { globalStore, useAppSelector } from "@/renderer/store/store"; 4 | 5 | native.auth.onAccountChange(load); 6 | void load(); 7 | 8 | async function load() { 9 | const accounts = await native.auth.getAccounts(); 10 | globalStore.dispatch(accountsSlice.actions.replace({ accounts })); 11 | } 12 | 13 | export function useAccounts(): DetailedAccountProps[] { 14 | return useAppSelector(s => s.accounts.accounts); 15 | } 16 | -------------------------------------------------------------------------------- /src/renderer/services/conf.ts: -------------------------------------------------------------------------------- 1 | import type { UserConfig } from "@/main/conf/conf"; 2 | import { alter } from "@/main/util/misc"; 3 | import { confSlice } from "@/renderer/store/conf"; 4 | import { globalStore, useAppSelector } from "@/renderer/store/store"; 5 | 6 | native.conf.onChange(handleChange); 7 | native.conf.get().then(handleChange); 8 | 9 | function handleChange(c: UserConfig) { 10 | globalStore.dispatch( 11 | confSlice.actions.replace({ config: c }) 12 | ); 13 | } 14 | 15 | export function useConfig() { 16 | const config = useAppSelector(s => s.conf.config); 17 | 18 | function alterConfig(update: (c: UserConfig) => void) { 19 | if (!config) return; 20 | 21 | native.conf.update(alter(config, update)); 22 | } 23 | 24 | return { config, alterConfig }; 25 | } 26 | -------------------------------------------------------------------------------- /src/renderer/services/games.ts: -------------------------------------------------------------------------------- 1 | import type { GameProfile } from "@/main/game/spec"; 2 | import { gamesSlice } from "@/renderer/store/games"; 3 | import { type AppState, globalStore, useAppSelector } from "@/renderer/store/store"; 4 | import { createSelector } from "@reduxjs/toolkit"; 5 | 6 | native.game.onChange(load); 7 | void load(); 8 | 9 | async function load() { 10 | const games = await native.game.list(); 11 | globalStore.dispatch( 12 | gamesSlice.actions.replace({ games }) 13 | ); 14 | } 15 | 16 | const selectGameList = createSelector( 17 | [ 18 | (s: AppState) => s.games.games 19 | ], 20 | (games) => { 21 | return Object.values(games); 22 | } 23 | ); 24 | 25 | export function useGameList(): GameProfile[] { 26 | return useAppSelector(selectGameList); 27 | } 28 | 29 | export function useGameProfile(id: string): GameProfile | null { 30 | const g = useAppSelector(s => s.games.games[id]); 31 | return g ?? null; 32 | } 33 | -------------------------------------------------------------------------------- /src/renderer/services/sources.ts: -------------------------------------------------------------------------------- 1 | import type { VersionManifest } from "@/main/install/vanilla"; 2 | import { useEffect, useState } from "react"; 3 | 4 | export function useVersionManifest(): VersionManifest | null { 5 | const [versionManifest, setVersionManifest] = useState(null); 6 | 7 | useEffect(() => { 8 | native.sources.getVersionManifest().then(setVersionManifest); 9 | }, []); 10 | 11 | return versionManifest; 12 | } 13 | -------------------------------------------------------------------------------- /src/renderer/store/accounts.ts: -------------------------------------------------------------------------------- 1 | import type { DetailedAccountProps } from "@/main/auth/types"; 2 | import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; 3 | 4 | interface AccountsSliceState { 5 | accounts: DetailedAccountProps[]; 6 | } 7 | 8 | export const accountsSlice = createSlice({ 9 | name: "accounts", 10 | initialState: { 11 | accounts: [] 12 | } as AccountsSliceState, 13 | reducers: { 14 | replace(state, action: PayloadAction<{ accounts: DetailedAccountProps[] }>) { 15 | state.accounts = action.payload.accounts; 16 | } 17 | } 18 | }); 19 | -------------------------------------------------------------------------------- /src/renderer/store/conf.ts: -------------------------------------------------------------------------------- 1 | import type { UserConfig } from "@/main/conf/conf"; 2 | import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; 3 | 4 | type ConfSliceState = { 5 | config: UserConfig | null; 6 | } 7 | export const confSlice = createSlice({ 8 | name: "conf", 9 | initialState: { 10 | config: null 11 | } as ConfSliceState, 12 | reducers: { 13 | replace(state, action: PayloadAction<{ config: UserConfig | null }>) { 14 | state.config = action.payload.config; 15 | } 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /src/renderer/store/games.ts: -------------------------------------------------------------------------------- 1 | import type { GameProfile } from "@/main/game/spec"; 2 | import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; 3 | 4 | type GameListSliceState = { 5 | games: Record; 6 | } 7 | export const gamesSlice = createSlice({ 8 | name: "games", 9 | initialState: { 10 | games: {} 11 | } as GameListSliceState, 12 | reducers: { 13 | replace(state, action: PayloadAction<{ games: GameProfile[] }>) { 14 | for (const g of action.payload.games) { 15 | state.games[g.id] = g; 16 | } 17 | } 18 | } 19 | }); 20 | -------------------------------------------------------------------------------- /src/renderer/store/install-progress.ts: -------------------------------------------------------------------------------- 1 | import type { Progress } from "@/main/util/progress"; 2 | import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; 3 | 4 | interface InstallProgressSliceState { 5 | installing: string[]; 6 | progress: Record; 7 | } 8 | 9 | export const installProgressSlice = createSlice({ 10 | name: "installProgress", 11 | initialState: { 12 | installing: [], 13 | progress: {} 14 | } as InstallProgressSliceState, 15 | reducers: { 16 | markInstalling: (state, action: PayloadAction<{ gameId: string }>) => { 17 | state.installing = Array.from(new Set([...state.installing, action.payload.gameId])); 18 | }, 19 | 20 | update: (state, action: PayloadAction<{ gameId: string, progress: Progress }>) => { 21 | state.progress[action.payload.gameId] = action.payload.progress; 22 | }, 23 | 24 | reset: (state, action: PayloadAction<{ gameId: string }>) => { 25 | state.installing = state.installing.filter(id => id !== action.payload.gameId); 26 | delete state.progress[action.payload.gameId]; 27 | } 28 | } 29 | }); 30 | -------------------------------------------------------------------------------- /src/renderer/store/mpm.ts: -------------------------------------------------------------------------------- 1 | import type { MpmManifest } from "@/main/mpm/spec"; 2 | import { createSlice, type PayloadAction } from "@reduxjs/toolkit"; 3 | 4 | interface MpmSliceState { 5 | installingAddons: { gameId: string, id: string }[]; 6 | manifests: Record; 7 | } 8 | 9 | export const mpmSlice = createSlice({ 10 | name: "mpm", 11 | initialState: { 12 | installingAddons: [], 13 | manifests: {} 14 | } as MpmSliceState, 15 | reducers: { 16 | markInstalling: (state, action: PayloadAction<{ gameId: string, id: string }>) => { 17 | state.installingAddons.push(action.payload); 18 | }, 19 | 20 | unmarkInstalling: (state, action: PayloadAction<{ gameId: string, id: string }>) => { 21 | const { gameId, id } = action.payload; 22 | state.installingAddons = state.installingAddons 23 | .filter(m => !(m.gameId === gameId && m.id === id)); 24 | }, 25 | 26 | replaceManifest: (state, action: PayloadAction<{ gameId: string, manifest: MpmManifest }>) => { 27 | const { gameId, manifest } = action.payload; 28 | state.manifests[gameId] = manifest; 29 | } 30 | } 31 | }); 32 | -------------------------------------------------------------------------------- /src/renderer/store/store.ts: -------------------------------------------------------------------------------- 1 | import { accountsSlice } from "@/renderer/store/accounts"; 2 | import { confSlice } from "@/renderer/store/conf"; 3 | import { gamesSlice } from "@/renderer/store/games"; 4 | import { installProgressSlice } from "@/renderer/store/install-progress"; 5 | import { mpmSlice } from "@/renderer/store/mpm"; 6 | import { configureStore } from "@reduxjs/toolkit"; 7 | import { useDispatch, useSelector } from "react-redux"; 8 | 9 | export const globalStore = configureStore({ 10 | reducer: { 11 | installProgress: installProgressSlice.reducer, 12 | games: gamesSlice.reducer, 13 | conf: confSlice.reducer, 14 | accounts: accountsSlice.reducer, 15 | mpm: mpmSlice.reducer 16 | } 17 | }); 18 | 19 | export type AppState = ReturnType; 20 | export const useAppDispatch = useDispatch.withTypes(); 21 | export const useAppSelector = useSelector.withTypes(); 22 | -------------------------------------------------------------------------------- /src/renderer/theme.tsx: -------------------------------------------------------------------------------- 1 | import type { ConfigTheme } from "@heroui/react"; 2 | import React, { 3 | type Dispatch, 4 | type PropsWithChildren, 5 | type SetStateAction, 6 | useContext, 7 | useEffect, 8 | useRef 9 | } from "react"; 10 | import { useLocalStorage } from "react-use"; 11 | import themes from "~/themes"; 12 | 13 | function getThemes() { 14 | return ["light", "dark", ...Object.keys(themes)]; 15 | } 16 | 17 | function isDark(th: string) { 18 | return (themes as Record)[th]?.extend === "dark" || th === "dark"; 19 | } 20 | 21 | interface ThemeContextContent { 22 | theme: string; 23 | setTheme: (theme: string) => void; 24 | } 25 | 26 | const ThemeContext = React.createContext(null); 27 | 28 | export function useAutoTheme() { 29 | const { theme } = useTheme(); 30 | const originalTheme = useRef(null); 31 | 32 | useEffect(() => { 33 | if (originalTheme.current) { 34 | document.documentElement.classList.remove(originalTheme.current, "dark"); 35 | } 36 | 37 | document.documentElement.classList.add(theme); 38 | if (isDark(theme)) { 39 | document.documentElement.classList.add("dark"); 40 | } 41 | 42 | originalTheme.current = theme; 43 | }, [theme]); 44 | } 45 | 46 | export function useTheme() { 47 | const context = useContext(ThemeContext); 48 | 49 | if (!context) throw "Cannot use theme hook outside the provider"; 50 | 51 | return { theme: context.theme, setTheme: context.setTheme }; 52 | } 53 | 54 | export function ThemeSwitchProvider({ children }: PropsWithChildren) { 55 | const [theme, setTheme] = useLocalStorage("theme", "dark") as [string, Dispatch>, () => void]; 56 | 57 | return 58 | {children} 59 | ; 60 | } 61 | 62 | export const themeManager = { getThemes, isDark }; 63 | -------------------------------------------------------------------------------- /src/renderer/util/misc.ts: -------------------------------------------------------------------------------- 1 | export function hslToHex(str: string): string { 2 | const [hs, ss, ls] = str.split(" "); 3 | const h = parseFloat(hs); 4 | const s = parseFloat(ss.replaceAll("%", "")); 5 | const l = parseFloat(ls.replaceAll("%", "")) / 100; 6 | 7 | const a = s * Math.min(l, 1 - l) / 100; 8 | 9 | function fmt(n: number) { 10 | const k = (n + h / 30) % 12; 11 | const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); 12 | return Math.round(255 * color).toString(16).padStart(2, "0"); // convert to Hex and prefix "0" if needed 13 | } 14 | 15 | return `#${fmt(0)}${fmt(8)}${fmt(4)}`; 16 | } 17 | 18 | export function getEmptyImage() { 19 | return "data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=="; 20 | } 21 | -------------------------------------------------------------------------------- /src/renderer/util/nav.ts: -------------------------------------------------------------------------------- 1 | import { useLocation } from "wouter"; 2 | 3 | export function useNav() { 4 | const [, nav] = useLocation(); 5 | return nav; 6 | } 7 | -------------------------------------------------------------------------------- /src/shared-env.d.ts: -------------------------------------------------------------------------------- 1 | import type { BuildDefines } from "~/build-src/defines"; 2 | 3 | declare global { 4 | interface ImportMetaEnv extends BuildDefines {} 5 | } 6 | 7 | 8 | export {}; 9 | -------------------------------------------------------------------------------- /tailwind.config.ts: -------------------------------------------------------------------------------- 1 | import { heroui } from "@heroui/react"; 2 | import type { Config } from "tailwindcss"; 3 | import themes from "./themes"; 4 | 5 | export default { 6 | content: [ 7 | "./src/renderer/**/*.{html,js,ts,jsx,tsx}", 8 | "./node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}" 9 | ], 10 | theme: { 11 | extend: {} 12 | }, 13 | darkMode: "class", 14 | plugins: [heroui({ themes })] 15 | } satisfies Config; 16 | -------------------------------------------------------------------------------- /test/electron-mock.ts: -------------------------------------------------------------------------------- 1 | import { mock } from "bun:test"; 2 | import path from "node:path"; 3 | 4 | class BrowserWindow { 5 | static getAllWindows() { 6 | return []; 7 | } 8 | } 9 | 10 | mock.module("electron", () => { 11 | return { 12 | app: { 13 | getAppPath() { 14 | return path.resolve("build", "dev"); 15 | }, 16 | 17 | getPath(sec: string) { 18 | switch (sec) { 19 | case "app": 20 | return this.getAppPath(); 21 | case "temp": 22 | return path.resolve("emulated", "temp"); 23 | } 24 | } 25 | }, 26 | screen: {}, 27 | ipcMain: {}, 28 | net: { fetch }, 29 | BrowserWindow 30 | }; 31 | }); 32 | -------------------------------------------------------------------------------- /test/instrumented/cache.ts: -------------------------------------------------------------------------------- 1 | import { cache } from "@/main/cache/cache"; 2 | import { hash } from "@/main/security/hash"; 3 | import fs from "fs-extra"; 4 | import assert from "node:assert"; 5 | import { iTest } from "~/test/instrumented/tools"; 6 | 7 | await iTest.run("Reuse Cached File", async () => { 8 | await fs.writeFile("cache-data.txt", "ciallo, world"); 9 | const sha1 = await hash.forFile("cache-data.txt", "sha1"); 10 | await cache.enroll("cache-data.txt"); 11 | 12 | await cache.deploy("reuse-data.txt", sha1, true); 13 | const dat = await fs.readFile("reuse-data.txt"); 14 | 15 | assert(dat.toString() === "ciallo, world", "Reused files should have the same content"); 16 | 17 | try { 18 | await fs.writeFile("reuse-data.txt", "this should not be written"); 19 | } catch {} 20 | 21 | const dat1 = await fs.readFile("reuse-data.txt"); 22 | assert(dat1.toString() === "ciallo, world", "Reused files should be readonly"); 23 | }, "lite"); 24 | -------------------------------------------------------------------------------- /test/instrumented/entry.ts: -------------------------------------------------------------------------------- 1 | import { app } from "electron"; 2 | import { iTest } from "~/test/instrumented/tools"; 3 | 4 | /** 5 | * The main entry of instrumented test. 6 | */ 7 | export async function runInstrumentedTest() { 8 | await Promise.all([ 9 | import("./cache"), 10 | import("./hash"), 11 | import("./install"), 12 | import("./jrt"), 13 | import("./net"), 14 | import("./reg") 15 | ]); 16 | 17 | await iTest.dumpSummary(); 18 | app.quit(); 19 | } 20 | -------------------------------------------------------------------------------- /test/instrumented/hash.ts: -------------------------------------------------------------------------------- 1 | import { hash } from "@/main/security/hash"; 2 | import fs from "fs-extra"; 3 | import assert from "node:assert"; 4 | import { iTest } from "~/test/instrumented/tools"; 5 | 6 | await iTest.run("Hash File", async () => { 7 | await fs.writeFile("hash-test.txt", "ciallo, world"); 8 | 9 | assert( 10 | await hash.checkFile("hash-test.txt", "sha1", "f7525f9a515602c82385c51b5bb2678d70f111f2"), 11 | "SHA-1 should match" 12 | ); 13 | assert( 14 | await hash.checkFile("hash-test.txt", "sha256", "7ddfd602ea34e005f781b50de561cd0ac7bb6cb22e5be47dd1f9ad01a5bd64a9"), 15 | "SHA-256 should match" 16 | ); 17 | }, "lite"); 18 | -------------------------------------------------------------------------------- /test/instrumented/install.ts: -------------------------------------------------------------------------------- 1 | import { containers } from "@/main/container/manage"; 2 | import { paths } from "@/main/fs/paths"; 3 | import { forgeInstaller } from "@/main/install/forge"; 4 | import { smelt } from "@/main/install/smelt"; 5 | import { vanillaInstaller } from "@/main/install/vanilla"; 6 | import type { VersionProfile } from "@/main/profile/version-profile"; 7 | import fs from "fs-extra"; 8 | import assert from "node:assert"; 9 | import path from "node:path"; 10 | import { iTest } from "~/test/instrumented/tools"; 11 | 12 | await iTest.run("Fetch Version Manifest", async () => { 13 | const m = await vanillaInstaller.getManifest(); 14 | 15 | assert(m.versions.length > 0, "Version list should exist"); 16 | assert([m.latest.release, m.latest.snapshot].includes(m.versions[0].id), "Version list should be sorted"); 17 | }, "medium"); 18 | 19 | const c = containers.create({ id: "test", root: paths.game.to("test"), flags: { link: true } }); 20 | 21 | let pf: VersionProfile; 22 | 23 | await iTest.run("Install Version Profile", async () => { 24 | pf = await vanillaInstaller.installProfile("1.20.4", c); 25 | assert(pf.id === "1.20.4", "Should install correct profile"); 26 | }, "medium"); 27 | 28 | await iTest.run("Install Libraries", async () => { 29 | await vanillaInstaller.installLibraries(pf, c, new Set()); 30 | assert(await fs.pathExists(c.client("1.20.4")), "Client file should exist"); 31 | }, "full"); 32 | 33 | await iTest.run("Install Forge", async () => { 34 | const v = await forgeInstaller.pickLoaderVersion("1.20.4"); 35 | const fp = await forgeInstaller.downloadInstaller(v, "installer"); 36 | const init = await smelt.readInstallProfile(fp); 37 | await smelt.deployVersionProfile(init, c); 38 | await smelt.runPostInstall(init, fp, pf, c); 39 | const versions = await fs.readdir(path.join(c.props.root, "versions")); 40 | 41 | assert(versions.map(v => v.toLowerCase()).some(v => v.includes("forge")), "Should found Forge installation"); 42 | }, "full"); 43 | 44 | await iTest.run("Install Assets", async () => { 45 | await vanillaInstaller.installAssets(pf, c, "video-only"); 46 | assert(await fs.pathExists(c.assetIndex(pf.assetIndex.id)), "Asset index file should exist"); 47 | const asi = await fs.readJSON(c.assetIndex(pf.assetIndex.id)); 48 | const objs = Object.values(asi.objects).map((o: any) => o.hash); 49 | const obj = objs[0]; 50 | assert(await fs.pathExists(c.asset(obj)), `Asset file should exist`); 51 | }, "full"); 52 | -------------------------------------------------------------------------------- /test/instrumented/jrt.ts: -------------------------------------------------------------------------------- 1 | import { jrt } from "@/main/jrt/install"; 2 | import os from "node:os"; 3 | import { iTest } from "~/test/instrumented/tools"; 4 | 5 | await iTest.run("Install JRT", async () => { 6 | await jrt.installRuntime("java-runtime-gamma"); 7 | }, "full"); 8 | 9 | if (os.platform() === "darwin") { 10 | await iTest.run("Install Legacy JRT on macOS", async () => { 11 | await jrt.installRuntime("jre-legacy"); 12 | }, "full"); 13 | } 14 | -------------------------------------------------------------------------------- /test/instrumented/reg.ts: -------------------------------------------------------------------------------- 1 | import { paths } from "@/main/fs/paths"; 2 | import type { GameProfile } from "@/main/game/spec"; 3 | import { reg, registry } from "@/main/registry/registry"; 4 | import fs from "fs-extra"; 5 | import assert from "node:assert"; 6 | import { iTest } from "~/test/instrumented/tools"; 7 | 8 | /** 9 | * This test does not run in Bun so it's classified as instrumented tests. 10 | */ 11 | await iTest.run("Registries Save & Load", async () => { 12 | const g = { 13 | id: "default", 14 | name: "fake", 15 | launchHint: { 16 | containerId: "" // Required when purging 17 | } 18 | } as GameProfile; // We're not building a full copy here 19 | 20 | reg.games.add(g.id, g); 21 | 22 | assert(reg.games.get("default") === g, "Should save registry content in memory"); 23 | 24 | await registry.close(); 25 | 26 | const m = await fs.readJSON(paths.store.to("registries.json")); 27 | 28 | const co = m["games"]["default"]; 29 | 30 | assert(!!co, "Should save registry content"); 31 | assert(co.name === "fake", "Should keep object information"); 32 | }, "lite"); 33 | -------------------------------------------------------------------------------- /test/instrumented/tools.ts: -------------------------------------------------------------------------------- 1 | import { writeJSON } from "fs-extra"; 2 | import type { TestLevel } from "~/config"; 3 | 4 | export interface TestSummary { 5 | allPassed: boolean; 6 | suites: TestSuiteSummary[]; 7 | } 8 | 9 | export interface TestSuiteSummary { 10 | name: string; 11 | passed: boolean; 12 | message?: string; 13 | } 14 | 15 | const suites: TestSuiteSummary[] = []; 16 | 17 | const testLevels = ["lite", "medium", "full"]; 18 | 19 | async function run(name: string, exec: () => void | Promise, level: TestLevel = "full") { 20 | if (testLevels.indexOf(import.meta.env.AL_TEST_LEVEL) >= testLevels.indexOf(level)) { 21 | console.log(`Executing test: ${name}`); 22 | try { 23 | await exec(); 24 | suites.push({ 25 | name, 26 | passed: true 27 | }); 28 | } catch (e) { 29 | suites.push({ 30 | name, 31 | passed: false, 32 | message: e?.toString() 33 | }); 34 | } 35 | } else { 36 | console.log(`Skipped test: ${name}`); 37 | } 38 | } 39 | 40 | 41 | async function dumpSummary() { 42 | await writeJSON("test-summary.json", { 43 | allPassed: suites.every(s => s.passed), 44 | suites 45 | }); 46 | } 47 | 48 | export const iTest = { run, dumpSummary }; 49 | -------------------------------------------------------------------------------- /test/main/conf.test.ts: -------------------------------------------------------------------------------- 1 | import { conf } from "@/main/conf/conf"; 2 | import { expect, test } from "bun:test"; 3 | import fs from "fs-extra"; 4 | import path from "node:path"; 5 | 6 | const cfgPath = path.resolve("emulated", "config.v2.json"); 7 | process.env.ALICORN_CONFIG_PATH = cfgPath; 8 | 9 | test("Config Read & Write", async () => { 10 | await fs.remove(cfgPath); 11 | await conf.load(); 12 | expect(conf().dev.devTools, "Should use default config when missing").toBeFalse(); 13 | 14 | conf.alter(c => c.dev.devTools = true); 15 | await conf.store(); 16 | await conf.load(); 17 | expect(conf().dev.devTools, "Should keep changes between saves & loads").toBeTrue(); 18 | 19 | // Check array values 20 | conf.alter(c => c.runtime.args.vm = ["arg1"]); 21 | await conf.store(); 22 | await conf.load(); 23 | expect(conf().runtime.args.vm.length, "Should save array values").toEqual(1); 24 | }); 25 | -------------------------------------------------------------------------------- /test/main/fs.test.ts: -------------------------------------------------------------------------------- 1 | import { paths } from "@/main/fs/paths"; 2 | import { expect, test } from "bun:test"; 3 | import path from "node:path"; 4 | 5 | test("Path Resolution", () => { 6 | paths.setup({ 7 | storeRoot: path.resolve("emulated", "store") 8 | }); 9 | 10 | expect(paths.store.to("foo.so"), "Should resolve file path correctly") 11 | .toEqual(path.normalize(path.resolve("emulated", "store", "foo.so"))); 12 | }); 13 | -------------------------------------------------------------------------------- /test/resources/Circular.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "Circular", 3 | "inheritsFrom": "Circular" 4 | } -------------------------------------------------------------------------------- /themes.ts: -------------------------------------------------------------------------------- 1 | // This file is included by Tailwind CSS which does not support TS paths 2 | // Must use relative path 3 | 4 | import type { ConfigThemes } from "@heroui/react"; 5 | import advanced from "./themes/advanced"; 6 | import amazingGrace from "./themes/amazing-grace"; 7 | import hoshi from "./themes/hoshi"; 8 | import overworld from "./themes/overworld"; 9 | import sakuraDark from "./themes/sakura-dark"; 10 | import sakuraLight from "./themes/sakura-light"; 11 | import twikie from "./themes/twikie"; 12 | 13 | export default { 14 | "sakura-light": sakuraLight, 15 | "sakura-dark": sakuraDark, 16 | "overworld": overworld, 17 | "hoshi": hoshi, 18 | "twikie": twikie, 19 | "amazing-grace": amazingGrace, 20 | "advanced": advanced 21 | } satisfies ConfigThemes; 22 | -------------------------------------------------------------------------------- /themes/amazing-grace.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigTheme } from "@heroui/react"; 2 | 3 | export default { 4 | extend: "light", 5 | "colors": { 6 | "default": { 7 | "50": "#d7dfe4", 8 | "100": "#c0d6ea", 9 | "200": "#b1cbdd", 10 | "300": "#9cb4c6", 11 | "400": "#8aa9bf", 12 | "500": "#738da0", 13 | "600": "#5a717d", 14 | "700": "#3f4d57", 15 | "800": "#242c32", 16 | "900": "#1b2225", 17 | "foreground": "#000", 18 | "DEFAULT": "#8aa9bf" 19 | }, 20 | "primary": { 21 | "50": "#e8f4fa", 22 | "100": "#c7e5f4", 23 | "200": "#a7d6ed", 24 | "300": "#86c6e6", 25 | "400": "#66b7e0", 26 | "500": "#45a8d9", 27 | "600": "#398bb3", 28 | "700": "#2d6d8d", 29 | "800": "#215067", 30 | "900": "#153241", 31 | "foreground": "#fff", 32 | "DEFAULT": "#45a8d9" 33 | }, 34 | "secondary": { 35 | "50": "#fcf1f3", 36 | "100": "#f8dee3", 37 | "200": "#f4cbd3", 38 | "300": "#efb8c3", 39 | "400": "#eba5b2", 40 | "500": "#e792a2", 41 | "600": "#bf7886", 42 | "700": "#965f69", 43 | "800": "#6e454d", 44 | "900": "#452c31", 45 | "foreground": "#000", 46 | "DEFAULT": "#e792a2" 47 | }, 48 | "background": "#daeff8", 49 | "foreground": { 50 | "50": "#e2e4e8", 51 | "100": "#babfc8", 52 | "200": "#9299a9", 53 | "300": "#6a7389", 54 | "400": "#424e69", 55 | "500": "#1a2849", 56 | "600": "#15213c", 57 | "700": "#111a2f", 58 | "800": "#0c1323", 59 | "900": "#080c16", 60 | "foreground": "#fff", 61 | "DEFAULT": "#1a2849" 62 | }, 63 | "content1": { 64 | "DEFAULT": "#bde1f0", 65 | "foreground": "#000" 66 | }, 67 | "content2": { 68 | "DEFAULT": "#b2d5e4", 69 | "foreground": "#000" 70 | }, 71 | "content3": { 72 | "DEFAULT": "#9abfcf", 73 | "foreground": "#000" 74 | }, 75 | "content4": { 76 | "DEFAULT": "#83b1c4", 77 | "foreground": "#000" 78 | }, 79 | "focus": "#45a8d9", 80 | "overlay": "#000000", 81 | "divider": "#111111" 82 | } 83 | } satisfies ConfigTheme; 84 | -------------------------------------------------------------------------------- /themes/hoshi.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigTheme } from "@heroui/react"; 2 | 3 | const color = { 4 | "50": "#ffffff", 5 | "100": "#ffffff", 6 | "200": "#ffffff", 7 | "300": "#ffffff", 8 | "400": "#f0f0f0", 9 | "500": "#e0e0e0", 10 | "600": "#d2d2d2", 11 | "700": "#a6a6a6", 12 | "800": "#797979", 13 | "900": "#4d4d4d", 14 | foreground: "#000", 15 | DEFAULT: "#f0f0f0" 16 | }; 17 | 18 | export default { 19 | extend: "dark", 20 | colors: { 21 | primary: color, 22 | secondary: color, 23 | background: "#000000", 24 | foreground: "#ffffff", 25 | content1: { 26 | DEFAULT: "#18181b", 27 | foreground: "#fff" 28 | }, 29 | content2: { 30 | DEFAULT: "#2b2b2b", 31 | foreground: "#fff" 32 | }, 33 | content3: { 34 | DEFAULT: "#464646", 35 | foreground: "#fff" 36 | }, 37 | content4: { 38 | DEFAULT: "#616161", 39 | foreground: "#fff" 40 | }, 41 | focus: "#ffffff", 42 | overlay: "#ffffff" 43 | }, 44 | layout: { 45 | radius: { 46 | small: "4px", 47 | medium: "8px", 48 | large: "12px" 49 | } 50 | } 51 | } satisfies ConfigTheme; 52 | -------------------------------------------------------------------------------- /themes/overworld.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigTheme } from "@heroui/react"; 2 | 3 | export default { 4 | extend: "dark", 5 | colors: { 6 | primary: { 7 | "50": "#12280c", 8 | "100": "#1d3f13", 9 | "200": "#275619", 10 | "300": "#326e20", 11 | "400": "#3c8527", 12 | "500": "#5e9a4d", 13 | "600": "#80b073", 14 | "700": "#a2c598", 15 | "800": "#c5dabe", 16 | "900": "#e7f0e4", 17 | foreground: "#fff", 18 | DEFAULT: "#3c8527" 19 | }, 20 | secondary: { 21 | "50": "#2b1d0c", 22 | "100": "#3f2d17", 23 | "200": "#644824", 24 | "300": "#896231", 25 | "400": "#ae7d3f", 26 | "500": "#d3974c", 27 | "600": "#dba96b", 28 | "700": "#e2bb8b", 29 | "800": "#eaceaa", 30 | "900": "#f2e0c9", 31 | foreground: "#fff", 32 | DEFAULT: "#d3974c" 33 | }, 34 | focus: "#3c8527" 35 | }, 36 | 37 | layout: { 38 | radius: { 39 | small: "0px", 40 | medium: "0px", 41 | large: "0px" 42 | } 43 | } 44 | } satisfies ConfigTheme; 45 | -------------------------------------------------------------------------------- /themes/twikie.ts: -------------------------------------------------------------------------------- 1 | import type { ConfigTheme } from "@heroui/react"; 2 | 3 | export default { 4 | extend: "light", 5 | "colors": { 6 | "default": { 7 | "50": "#f8f0f4", 8 | "100": "#eedae4", 9 | "200": "#e4c4d5", 10 | "300": "#dbaec5", 11 | "400": "#d199b6", 12 | "500": "#c783a6", 13 | "600": "#a46c89", 14 | "700": "#81556c", 15 | "800": "#5f3e4f", 16 | "900": "#3c2732", 17 | "foreground": "#230c55", 18 | "DEFAULT": "#c783a6" 19 | }, 20 | "primary": { 21 | "50": "#ebe4f1", 22 | "100": "#cebdde", 23 | "200": "#b297cb", 24 | "300": "#9670b8", 25 | "400": "#794aa4", 26 | "500": "#5d2391", 27 | "600": "#4d1d78", 28 | "700": "#3c175e", 29 | "800": "#2c1145", 30 | "900": "#1c0b2c", 31 | "foreground": "#fff", 32 | "DEFAULT": "#5d2391" 33 | }, 34 | "secondary": { 35 | "50": "#fbe5ef", 36 | "100": "#f5c1d9", 37 | "200": "#f09dc2", 38 | "300": "#ea78ac", 39 | "400": "#e55495", 40 | "500": "#df307f", 41 | "600": "#b82869", 42 | "700": "#911f53", 43 | "800": "#6a173c", 44 | "900": "#430e26", 45 | "foreground": "#000", 46 | "DEFAULT": "#df307f" 47 | }, 48 | "background": "#ffe0f0", 49 | "foreground": { 50 | "50": "#e8e2ed", 51 | "100": "#c8b9d5", 52 | "200": "#a891bc", 53 | "300": "#8768a3", 54 | "400": "#67408b", 55 | "500": "#471772", 56 | "600": "#3b135e", 57 | "700": "#2e0f4a", 58 | "800": "#220b36", 59 | "900": "#150722", 60 | "foreground": "#fff", 61 | "DEFAULT": "#471772" 62 | }, 63 | "content1": { 64 | "DEFAULT": "#e2c3d3", 65 | "foreground": "#000" 66 | }, 67 | "content2": { 68 | "DEFAULT": "#ceb1c1", 69 | "foreground": "#000" 70 | }, 71 | "content3": { 72 | "DEFAULT": "#b091a2", 73 | "foreground": "#000" 74 | }, 75 | "content4": { 76 | "DEFAULT": "#977c8a", 77 | "foreground": "#000" 78 | }, 79 | "focus": "#5d2391", 80 | "overlay": "#000000", 81 | "divider": "#111111" 82 | } 83 | } satisfies ConfigTheme; 84 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "module": "ESNext", 5 | "allowJs": true, 6 | "jsx": "react-jsx", 7 | "sourceMap": true, 8 | "removeComments": true, 9 | "strict": true, 10 | "moduleResolution": "Bundler", 11 | "resolveJsonModule": true, 12 | "baseUrl": "./", 13 | "esModuleInterop": true, 14 | "skipLibCheck": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "noEmit": true, 17 | "types": [ 18 | "vite/client", 19 | "@types/bun" 20 | ], 21 | "paths": { 22 | "@/*": [ 23 | "./src/*" 24 | ], 25 | "@components/*": [ 26 | "./src/renderer/components/*" 27 | ], 28 | "@pages/*": [ 29 | "./src/renderer/pages/*" 30 | ], 31 | "@assets/*": [ 32 | "./assets/*" 33 | ], 34 | "~/*": [ 35 | "./*" 36 | ] 37 | }, 38 | "strictNullChecks": true 39 | }, 40 | "include": [ 41 | "./**/*.ts", 42 | "./**/*.tsx" 43 | ], 44 | "exclude": [ 45 | "node_modules", 46 | "dist" 47 | ] 48 | } 49 | --------------------------------------------------------------------------------