You've already forked AstralRinth
Compare commits
89 Commits
14bf06e4bd
...
AR-0.10.30
| Author | SHA1 | Date | |
|---|---|---|---|
| 7846fd00aa | |||
| cebc195fe0 | |||
| ae58f3844d | |||
| acd4b1696a | |||
| 5ea78b78c2 | |||
| f90998157d | |||
| 634000cdb6 | |||
|
|
5fd8c38c1c | ||
|
|
15892a88d3 | ||
|
|
32793c50e1 | ||
|
|
0e0ca1971a | ||
|
|
bb9af18eed | ||
|
|
d4516d3527 | ||
|
|
87de47fe5e | ||
|
|
7d76fe1b6a | ||
| 46d30e491a | |||
| 059c0618f1 | |||
| 7ef60fcafe | |||
| ec17e79014 | |||
| e351d674f4 | |||
| f555fa916a | |||
| dbe38cb4e7 | |||
| 2e40e26116 | |||
|
|
ae25a15abd | ||
|
|
0f755b94ce | ||
|
|
bcf46d440b | ||
|
|
526561f2de | ||
|
|
a8caa1afc3 | ||
|
|
98e9a8473d | ||
|
|
936395484e | ||
|
|
0c3e23db96 | ||
|
|
013ba4d86d | ||
|
|
93813c448c | ||
|
|
c20b869e62 | ||
|
|
56c556821b | ||
|
|
44267619b6 | ||
| 10afd673db | |||
|
|
90043fe84d | ||
|
|
a6a98ff63e | ||
|
|
911652133b | ||
|
|
cee1b5f522 | ||
|
|
62f5a23fcb | ||
| 5a10292add | |||
| 3f606a08aa | |||
| 2d5d747202 | |||
|
|
eb595cdc3e | ||
| 7516ff9e47 | |||
|
|
572cd065ed | ||
| df9bbe3ba0 | |||
| 362fd7f32a | |||
|
|
76dc8a0897 | ||
|
|
4723de6269 | ||
|
|
e15fa35bad | ||
| 7716a0c524 | |||
|
|
2cc6bc8ce4 | ||
|
|
5d19d31b2c | ||
|
|
c1b95ede07 | ||
|
|
058185c7fd | ||
|
|
6fb125cf0f | ||
|
|
a945e9b005 | ||
|
|
b943638afb | ||
|
|
207dc0e2bb | ||
|
|
359fbd4738 | ||
| adf831dab9 | |||
| efeac22d14 | |||
| 591d98a9eb | |||
| 77472d9a09 | |||
| 789d666515 | |||
| d917bff6ef | |||
| 4e69cd8bde | |||
| b71e4cc6f9 | |||
| a56ab6adb9 | |||
| f1b67c9584 | |||
| 3d32640b83 | |||
| 6dfb599e14 | |||
| 332a543f66 | |||
| 1ef96c447e | |||
| 1ec92b5f97 | |||
| f0a4532051 | |||
|
|
f7700acce4 | ||
|
|
87a3e2d022 | ||
|
|
5d17663040 | ||
|
|
cff3c72f94 | ||
|
|
fadf475f06 | ||
|
|
7228499737 | ||
|
|
bca467a634 | ||
| 14f6450cf4 | |||
| 97bd18c7b3 | |||
| 34d85a03b2 |
@@ -2,5 +2,8 @@
|
||||
[target.'cfg(windows)']
|
||||
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"]
|
||||
|
||||
[target.x86_64-pc-windows-msvc]
|
||||
linker = "rust-lld"
|
||||
|
||||
[build]
|
||||
rustflags = ["--cfg", "tokio_unstable"]
|
||||
|
||||
72
.github/workflows/astralrinth-build.yml
vendored
72
.github/workflows/astralrinth-build.yml
vendored
@@ -16,6 +16,7 @@ on:
|
||||
- 'packages/assets/**'
|
||||
- 'packages/ui/**'
|
||||
- 'packages/utils/**'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -24,12 +25,12 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# platform: [macos-latest, windows-latest, ubuntu-latest]
|
||||
platform: [ubuntu-latest]
|
||||
platform: [windows-latest, ubuntu-latest]
|
||||
include:
|
||||
# - platform: macos-latest
|
||||
# artifact-target-name: universal-apple-darwin
|
||||
# - platform: windows-latest
|
||||
# artifact-target-name: x86_64-pc-windows-msvc
|
||||
- platform: windows-latest
|
||||
artifact-target-name: x86_64-pc-windows-msvc
|
||||
- platform: ubuntu-latest
|
||||
artifact-target-name: x86_64-unknown-linux-gnu
|
||||
|
||||
@@ -41,6 +42,35 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 2
|
||||
|
||||
- name: 🔍 Validate Git config does not introduce CRLF
|
||||
shell: bash
|
||||
run: |
|
||||
echo "🔍 Checking Git config for CRLF settings..."
|
||||
|
||||
autocrlf=$(git config --get core.autocrlf || echo "unset")
|
||||
eol_setting=$(git config --get core.eol || echo "unset")
|
||||
|
||||
echo "core.autocrlf = $autocrlf"
|
||||
echo "core.eol = $eol_setting"
|
||||
|
||||
if [ "$autocrlf" = "true" ]; then
|
||||
echo "⚠️ WARNING: core.autocrlf is set to 'true'. Consider setting it to 'input' or 'false'."
|
||||
fi
|
||||
|
||||
if [ "$eol_setting" = "crlf" ]; then
|
||||
echo "⚠️ WARNING: core.eol is set to 'crlf'. Consider unsetting it or setting to 'lf'."
|
||||
fi
|
||||
|
||||
- name: 🔍 Check migration files line endings (LF only)
|
||||
shell: bash
|
||||
run: |
|
||||
echo "🔍 Scanning migration SQL files for CR characters (\\r)..."
|
||||
if grep -Iq $'\r' packages/app-lib/migrations/*.sql; then
|
||||
echo "❌ ERROR: Some migration files contain CR (\\r) characters — expected only LF line endings."
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All migration files use LF line endings"
|
||||
|
||||
- name: 🧰 Setup Rust toolchain
|
||||
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||
with:
|
||||
@@ -73,17 +103,25 @@ jobs:
|
||||
- name: 🧰 Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
# - name: ✍️ Set up Windows code signing (jsign)
|
||||
# if: matrix.platform == 'windows-latest' && env.SIGN_WINDOWS_BINARIES == 'true'
|
||||
# shell: bash
|
||||
# run: |
|
||||
# choco install jsign --ignore-dependencies
|
||||
- name: ✍️ Set up Windows code signing (jsign)
|
||||
if: matrix.platform == 'windows-latest' && env.SIGN_WINDOWS_BINARIES == 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
choco install jsign --ignore-dependencies
|
||||
|
||||
- name: 🗑️ Clean up cached bundles
|
||||
shell: bash
|
||||
run: |
|
||||
rm -rf target/release/bundle
|
||||
rm -rf target/*/release/bundle || true
|
||||
|
||||
- name: 🌍 Load environment variables for build.rs
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Loading .env.prod..."
|
||||
set -a
|
||||
source packages/app-lib/.env.prod
|
||||
set +a
|
||||
|
||||
# - name: 🔨 Build macOS app
|
||||
# if: matrix.platform == 'macos-latest'
|
||||
@@ -99,15 +137,15 @@ jobs:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
# - name: 🔨 Build Windows app
|
||||
# if: matrix.platform == 'windows-latest'
|
||||
# shell: pwsh
|
||||
# run: |
|
||||
# $env:JAVA_HOME = "$env:JAVA_HOME_11_X64"
|
||||
# pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis'
|
||||
# env:
|
||||
# TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
# TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
- name: 🔨 Build Windows app
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$env:JAVA_HOME = "$env:JAVA_HOME_11_X64"
|
||||
pnpm --filter=@modrinth/app run tauri build --config tauri-release.conf.json --verbose --bundles 'nsis'
|
||||
env:
|
||||
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_PRIVATE_KEY }}
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_KEY_PASSWORD }}
|
||||
|
||||
- name: 📤 Upload app bundles
|
||||
uses: actions/upload-artifact@v3
|
||||
|
||||
485
Cargo.lock
generated
485
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -67,7 +67,13 @@ heck = "0.5.0"
|
||||
hex = "0.4.3"
|
||||
hickory-resolver = "0.25.2"
|
||||
hmac = "0.12.1"
|
||||
hyper-tls = "0.6.0"
|
||||
hyper = "1.6.0"
|
||||
hyper-rustls = { version = "0.27.7", default-features = false, features = [
|
||||
"http1",
|
||||
"native-tokio",
|
||||
"ring",
|
||||
"tls12",
|
||||
] }
|
||||
hyper-util = "0.1.14"
|
||||
iana-time-zone = "0.1.63"
|
||||
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
|
||||
|
||||
159
README.md
159
README.md
@@ -1,76 +1,123 @@
|
||||
# Navigation in this README
|
||||
- [Install instructions](#install-instructions)
|
||||
- [Features](#features)
|
||||
- [Getting started](#getting-started)
|
||||
- [Disclaimer](#disclaimer)
|
||||
- [Donate](#support-our-project-crypto-wallets)
|
||||
# 📘 Navigation
|
||||
|
||||
- [🔧 Install Instructions](#install-instructions)
|
||||
- [✨ Features](#features)
|
||||
- [🚀 Getting Started](#getting-started)
|
||||
- [⚠️ Disclaimer](#disclaimer)
|
||||
- [💰 Donate](#support-our-project-crypto-wallets)
|
||||
|
||||
## Other languages
|
||||
> [Русский](readme/ru_ru/README.md)
|
||||
|
||||
## Support channel
|
||||
> [Telegram](https://me.astralium.su/ref/telegram_channel)
|
||||
|
||||
---
|
||||
|
||||
# About Project
|
||||
|
||||
## AstralRinth • Empowering Your Minecraft Adventure
|
||||
Welcome to AR • Fork of Modrinth, the ultimate game launcher designed to enhance your Minecraft experience through the Modrinth platform and their API. Whether you're a graphical interface enthusiast, or a developer integrating Modrinth projects, Theseus core is your gateway to a new level of Minecraft gaming.
|
||||
## **AstralRinth • Empowering Your Minecraft Adventure**
|
||||
|
||||
## About Software
|
||||
Introducing AstralRinth, a specialized variant of Theseus dedicated to implementing offline authorization for an even more flexible and user-centric Minecraft Modrinth experience. Roam the Minecraft realms without the constraints of online authentication, thanks to AstralRinth.
|
||||
Welcome to **AstralRinth (AR)** — a powerful fork of Modrinth, reimagined to enhance your Minecraft journey. Whether you're a GUI enthusiast or a developer building with Modrinth’s API, **Theseus Core** is your launchpad into a new era of Minecraft gameplay.
|
||||
|
||||
## AR • Unlocking Minecraft's Boundless Horizon
|
||||
Dive into the extraordinary world of AstralRinth, a fork of the original project with a unique focus on providing a free trial experience for Minecraft, all without the need for a license. Currently boasting:
|
||||
- *Recently, improved integration with the Git Astralium API has been added.*
|
||||
|
||||
# Install instructions
|
||||
- To install our application, you need to download a file for your operating system from our available releases or development builds • [Download variants here](https://git.astralium.su/didirus/AstralRinth/releases)
|
||||
- After you have downloaded the required executable file or archive, then open it
|
||||
## **About the Software**
|
||||
|
||||
### Downloadable file extensions
|
||||
- `.msi` format for Windows OS system _(Supported popular latest versions of Microsoft Windows)_
|
||||
- `.dmg` format for MacOS system _(Works on Macos Ventura / Sonoma / Sequoia, but it should be works on older OS builds)_
|
||||
- `.deb` format for Linux OS systems _(Since there are quite a few distributions, we do not guarantee
|
||||
**AstralRinth** is a dedicated branch of the Theseus project, focused on **offline authentication**, offering you more flexibility and control. Play Minecraft without the need for constant online verification — a user-first approach to modern modded gaming.
|
||||
|
||||
### Installation subjects
|
||||
- Builds in releases that are signed with the following prefixes are not recommended for installation and may contain errors:
|
||||
- `dev`
|
||||
- `nightly`
|
||||
- `dirty`
|
||||
- `dirty-dev`
|
||||
- `dirty-nightly`
|
||||
- `dirty_dev`
|
||||
- `dirty_nightly`
|
||||
- Auto-updating takes place through parsing special versions from releases, so we also distribute clean types of `.msi, .dmg and .deb`
|
||||
## **AR • Unlocking Minecraft's Boundless Horizon**
|
||||
|
||||
This unique fork introduces a **free trial Minecraft experience**, bypassing license checks while maintaining rich functionality. Currently includes:
|
||||
|
||||
---
|
||||
|
||||
# Install Instructions
|
||||
|
||||
To install the launcher:
|
||||
|
||||
1. Visit the [releases page](https://git.astralium.su/didirus/AstralRinth/releases) to download the correct version for your system.
|
||||
2. Run the downloaded file or extract and launch it, depending on the format.
|
||||
|
||||
### Downloadable File Extensions
|
||||
|
||||
| Extension | OS | Notes |
|
||||
| --------- | ------- | --------------------------------------------------------------------- |
|
||||
| `.msi` | Windows | Supported on all recent Windows versions |
|
||||
| `.dmg` | macOS | Works on Ventura, Sonoma, Sequoia _(may also support older versions)_ |
|
||||
| `.deb` | Linux | Basic support; compatibility may vary by distribution |
|
||||
|
||||
### Installation Warnings
|
||||
|
||||
Avoid using builds with these prefixes — they may be unstable or experimental:
|
||||
|
||||
- `dev`
|
||||
- `nightly`
|
||||
- `dirty`
|
||||
- `dirty-dev`
|
||||
- `dirty-nightly`
|
||||
- `dirty_dev`
|
||||
- `dirty_nightly`
|
||||
|
||||
---
|
||||
|
||||
# Features
|
||||
|
||||
### Featured enhancement in AR
|
||||
- AstralRinth offers a range of authorization options, giving users the flexibility to log in with valid licenses or even a pirate account without auth credentials breaks (_Unlike MultiMC Cracked and similar software_). Experience Minecraft on your terms, breaking free from traditional licensing constraints (_Popular in Russian Federation_).
|
||||
> _The launcher provides an opportunity to use the well-known Modrinth, but with an improved user experience._
|
||||
|
||||
### Easy to use
|
||||
- Using the launcher is intuitive, any user can figure it out.
|
||||
## Included exclusive features
|
||||
|
||||
### Update notifies
|
||||
- We have implemented notifications about the release of new updates on our Git. The launcher can also download them for you and try to install them.
|
||||
- No ads in the entire launcher.
|
||||
- Custom `.svg` vector icons for a distinct UI.
|
||||
- Improved compatibility with both licensed and pirate accounts.
|
||||
- Use **official microsoft accounts** or **offline/pirate accounts** — login won't break.
|
||||
- Supports license-free access for testing or personal use.
|
||||
- No dependence on official authentication services.
|
||||
- Discord Rich Presence integration:
|
||||
- Dynamic status messages.
|
||||
- In-game timer and AFK counter.
|
||||
- Strict disabling of statistics and other Modrinth metrics.
|
||||
- Optimized archive/package size.
|
||||
- Integrated update fetcher for seamless version management.
|
||||
- Built-in update alerts for new versions posted on Git Astralium.
|
||||
- Automatic download and installation capabilities.
|
||||
- Database migration fixes, when error occurred (Interactive Mode) (Modrinth issue)
|
||||
- ElyBy skin system integration (AuthLib / Java)
|
||||
|
||||
### Enhancements
|
||||
- Custom .SVG vectors for a personalized touch.
|
||||
- Improved compatibility for both pirate and licensed accounts.
|
||||
- Beautiful Discord RPC with random messages while playing, along with an in-game timer and AFK counter.
|
||||
- Forced disabling of statistics collection (modrinch metrics) with a hard patch from AstralRinth, ensuring it remains deactivated regardless of the configuration setting.
|
||||
- Removal of advertisements from all launcher views.
|
||||
- Optimization of packages (archives).
|
||||
- Integrated update fetching feature
|
||||
---
|
||||
|
||||
# Getting Started
|
||||
To begin your AstralRinth adventure, follow these steps:
|
||||
1. **Download Your OS Version**: Head over to our [releases page](https://git.astralium.su/didirus/AstralRinth/releases/) to find the right file for your operating system.
|
||||
- **Choosing the Correct File**: Ensure you select the file that matches your OS requirements.
|
||||
- [**How select file**](#downloadable-file-extensions)
|
||||
- [**How select release**](#installation-subjects)
|
||||
2. **Authentication**: Log in with a valid license or, for testing, try using a pirate account to see AstralRinth in action.
|
||||
3. **Launch Minecraft**: Start your journey by launching Minecraft through AstralRinth and enjoy the adventures that await.
|
||||
- **Choosing java installation**: The launcher will try to automatically detect the recommended JVM version for running the game, but you can configure everything in the launcher settings.
|
||||
|
||||
To begin using AstralRinth:
|
||||
|
||||
1. **Download Your OS Version**
|
||||
|
||||
- Go to the [releases page](https://git.astralium.su/didirus/AstralRinth/releases)
|
||||
- [How to choose a file](#downloadable-file-extensions)
|
||||
- [How to choose a release](#installation-warnings)
|
||||
|
||||
2. **Log In**
|
||||
|
||||
- Use your official Mojang/Microsoft account, or test using a non-licensed account.
|
||||
|
||||
3. **Launch Minecraft**
|
||||
- Start Minecraft from the launcher.
|
||||
- The launcher will auto-detect the recommended JVM version.
|
||||
- You can also configure Java manually in the settings.
|
||||
|
||||
---
|
||||
|
||||
# Disclaimer
|
||||
- AstralRinth is a project intended for experimentation and educational purposes only. It does not endorse or support piracy, and users are encouraged to obtain valid licenses for a fully-supported Minecraft experience.
|
||||
- Users are reminded to respect licensing agreements and support the developers of Minecraft.
|
||||
|
||||
# Support our Project (Crypto Wallets)
|
||||
- **AstralRinth** is intended **solely for educational and experimental use**.
|
||||
- We **do not condone piracy** — users are encouraged to purchase a legitimate Minecraft license.
|
||||
- Respect all relevant licensing agreements and support Minecraft developers.
|
||||
|
||||
---
|
||||
|
||||
# Support Our Project (Crypto Wallets)
|
||||
|
||||
If you'd like to support development, you can donate via the following crypto wallets:
|
||||
|
||||
- BTC (Telegram): 14g6asNYzcUoaQtB8B2QGKabgEvn55wfLj
|
||||
- USDT TRC20 (Telegram): TMSmv1D5Fdf4fipUpwBCdh16WevrV45vGr
|
||||
- TONCOIN (Telegram): UQAqUJ2_hVBI6k_gPyfp_jd-1K0OS61nIFPZuJWN9BwGAvKe
|
||||
- TONCOIN (Telegram): UQAqUJ2_hVBI6k_gPyfp_jd-1K0OS61nIFPZuJWN9BwGAvKe
|
||||
|
||||
@@ -42,7 +42,7 @@ import ModrinthLoadingIndicator from '@/components/LoadingIndicatorBar.vue'
|
||||
import { handleError, useNotifications } from '@/store/notifications.js'
|
||||
import { command_listener, warning_listener } from '@/helpers/events.js'
|
||||
import { type } from '@tauri-apps/plugin-os'
|
||||
import { getOS, isDev, restartApp } from '@/helpers/utils.js'
|
||||
import { getOS, isDev } from '@/helpers/utils.js'
|
||||
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
@@ -61,9 +61,10 @@ import { renderString } from '@modrinth/utils'
|
||||
import { useFetch } from '@/helpers/fetch.js'
|
||||
// import { check } from '@tauri-apps/plugin-updater'
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
|
||||
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.js'
|
||||
import { get_user } from '@/helpers/cache.js'
|
||||
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
||||
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
|
||||
// import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
||||
// import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
|
||||
import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
||||
@@ -72,6 +73,9 @@ import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
|
||||
import { get_available_capes, get_available_skins } from './helpers/skins'
|
||||
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
|
||||
|
||||
// [AR] Feature
|
||||
import { getRemote, updateState } from '@/helpers/update.js'
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
const news = ref([])
|
||||
@@ -99,6 +103,7 @@ const isMaximized = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await useCheckDisableMouseover()
|
||||
await getRemote(false) // [AR] Check for updates
|
||||
|
||||
document.querySelector('body').addEventListener('click', handleClick)
|
||||
document.querySelector('body').addEventListener('auxclick', handleAuxClick)
|
||||
@@ -161,11 +166,11 @@ async function setupApp() {
|
||||
|
||||
initAnalytics()
|
||||
if (!telemetry) {
|
||||
console.info("[AR] Telemetry disabled by default (Hard patched).")
|
||||
console.info("[AR] • Telemetry disabled by default (Hard patched).")
|
||||
optOutAnalytics()
|
||||
}
|
||||
if (!personalized_ads) {
|
||||
console.info("[AR] Personalized ads disabled by default (Hard patched).")
|
||||
console.info("[AR] • Personalized ads disabled by default (Hard patched).")
|
||||
}
|
||||
|
||||
if (dev) debugAnalytics()
|
||||
@@ -188,7 +193,7 @@ async function setupApp() {
|
||||
}),
|
||||
)
|
||||
|
||||
// Patched by AstralRinth
|
||||
/// [AR] Patch
|
||||
// useFetch(
|
||||
// `https://api.modrinth.com/appCriticalAnnouncement.json?version=${version}`,
|
||||
// 'criticalAnnouncements',
|
||||
@@ -279,6 +284,8 @@ const incompatibilityWarningModal = ref()
|
||||
|
||||
const credentials = ref()
|
||||
|
||||
const modrinthLoginFlowWaitModal = ref()
|
||||
|
||||
async function fetchCredentials() {
|
||||
const creds = await getCreds().catch(handleError)
|
||||
if (creds && creds.user_id) {
|
||||
@@ -288,8 +295,24 @@ async function fetchCredentials() {
|
||||
}
|
||||
|
||||
async function signIn() {
|
||||
await login().catch(handleError)
|
||||
await fetchCredentials()
|
||||
modrinthLoginFlowWaitModal.value.show()
|
||||
|
||||
try {
|
||||
await login()
|
||||
await fetchCredentials()
|
||||
} catch (error) {
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
typeof error['message'] === 'string' &&
|
||||
error.message.includes('Login canceled')
|
||||
) {
|
||||
// Not really an error due to being a result of user interaction, show nothing
|
||||
} else {
|
||||
handleError(error)
|
||||
}
|
||||
} finally {
|
||||
modrinthLoginFlowWaitModal.value.hide()
|
||||
}
|
||||
}
|
||||
|
||||
async function logOut() {
|
||||
@@ -418,6 +441,9 @@ function handleAuxClick(e) {
|
||||
<Suspense>
|
||||
<AppSettingsModal ref="settingsModal" />
|
||||
</Suspense>
|
||||
<Suspense>
|
||||
<AuthGrantFlowWaitModal ref="modrinthLoginFlowWaitModal" @flow-cancel="cancelLogin" />
|
||||
</Suspense>
|
||||
<Suspense>
|
||||
<InstanceCreationModal ref="installationModal" />
|
||||
</Suspense>
|
||||
@@ -465,12 +491,20 @@ function handleAuxClick(e) {
|
||||
<PlusIcon />
|
||||
</NavButton>
|
||||
<div class="flex flex-grow"></div>
|
||||
<NavButton v-if="updateAvailable" v-tooltip.right="'Install update'" :to="() => restartApp()">
|
||||
<!-- [AR] TODO -->
|
||||
<!-- <NavButton v-if="updateAvailable" v-tooltip.right="'Install update'" :to="() => restartApp()">
|
||||
<DownloadIcon />
|
||||
</NavButton>
|
||||
<NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
|
||||
<SettingsIcon />
|
||||
</NavButton>
|
||||
</NavButton> -->
|
||||
<template v-if="updateState">
|
||||
<NavButton class="neon-icon pulse" v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
|
||||
<SettingsIcon />
|
||||
</NavButton>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
|
||||
<SettingsIcon />
|
||||
</NavButton>
|
||||
</template>
|
||||
<ButtonStyled v-if="credentials" type="transparent" circular>
|
||||
<OverflowMenu
|
||||
:options="[
|
||||
@@ -659,6 +693,9 @@ function handleAuxClick(e) {
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../../../packages/assets/styles/neon-icon.scss';
|
||||
@import '../../../packages/assets/styles/neon-text.scss';
|
||||
|
||||
.window-controls {
|
||||
z-index: 20;
|
||||
display: none;
|
||||
|
||||
@@ -136,7 +136,7 @@ const filteredResults = computed(() => {
|
||||
|
||||
if (sortBy.value === 'Game version') {
|
||||
instances.sort((a, b) => {
|
||||
return a.game_version.localeCompare(b.game_version)
|
||||
return a.game_version.localeCompare(b.game_version, undefined, { numeric: true })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -213,6 +213,17 @@ const filteredResults = computed(() => {
|
||||
instanceMap.set(entry[0], entry[1])
|
||||
})
|
||||
}
|
||||
// default sorting would do 1.20.4 < 1.8.9 because 2 < 8
|
||||
// localeCompare with numeric=true puts 1.8.9 < 1.20.4 because 8 < 20
|
||||
if (group.value === 'Game version') {
|
||||
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
|
||||
return a[0].localeCompare(b[0], undefined, { numeric: true })
|
||||
})
|
||||
instanceMap.clear()
|
||||
sortedEntries.forEach((entry) => {
|
||||
instanceMap.set(entry[0], entry[1])
|
||||
})
|
||||
}
|
||||
|
||||
return instanceMap
|
||||
})
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="mode !== 'isolated'"
|
||||
ref="button"
|
||||
<div v-if="mode !== 'isolated'" ref="button"
|
||||
class="button-base mt-2 px-3 py-2 bg-button-bg rounded-xl flex items-center gap-2"
|
||||
:class="{ expanded: mode === 'expanded' }"
|
||||
@click="toggleMenu"
|
||||
>
|
||||
<Avatar
|
||||
size="36px"
|
||||
:src="
|
||||
selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||
"
|
||||
/>
|
||||
:class="{ expanded: mode === 'expanded' }" @click="toggleMenu">
|
||||
<Avatar size="36px" :src="selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||
" />
|
||||
<div class="flex flex-col w-full">
|
||||
<span>
|
||||
<component :is="getAccountType(selectedAccount)" v-if="selectedAccount" class="vector-icon" />
|
||||
@@ -32,31 +24,23 @@
|
||||
</h4>
|
||||
<p>Selected</p>
|
||||
</div>
|
||||
<Button
|
||||
v-tooltip="'Log out'"
|
||||
icon-only
|
||||
color="raised"
|
||||
@click="logout(selectedAccount.profile.id)"
|
||||
>
|
||||
<Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.profile.id)">
|
||||
<TrashIcon />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-else class="login-section account">
|
||||
<h4>Not signed in</h4>
|
||||
<Button
|
||||
v-tooltip="'Log in'"
|
||||
:disabled="loginDisabled"
|
||||
icon-only
|
||||
color="primary"
|
||||
@click="login()"
|
||||
>
|
||||
<LogInIcon v-if="!loginDisabled" />
|
||||
<Button v-tooltip="'Log via Microsoft'" :disabled="microsoftLoginDisabled" icon-only @click="login()">
|
||||
<MicrosoftIcon v-if="!microsoftLoginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
<MicrosoftIcon/>
|
||||
</Button>
|
||||
<Button v-tooltip="'Add offline'" icon-only @click="tryOfflineLogin()">
|
||||
<Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
|
||||
<PirateIcon />
|
||||
</Button>
|
||||
<Button v-tooltip="'Log via Ely.by'" icon-only @click="showElybyLoginModal()">
|
||||
<ElyByIcon v-if="!elybyLoginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
</Button>
|
||||
</div>
|
||||
<div v-if="displayAccounts.length > 0" class="account-group">
|
||||
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
|
||||
@@ -73,53 +57,135 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="accounts.length > 0" class="login-section account centered">
|
||||
<Button v-tooltip="'Log in'" icon-only @click="login()">
|
||||
<MicrosoftIcon />
|
||||
<Button v-tooltip="'Log via Microsoft'" icon-only @click="login()">
|
||||
<MicrosoftIcon v-if="!microsoftLoginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
</Button>
|
||||
<Button v-tooltip="'Add offline'" icon-only @click="tryOfflineLogin()">
|
||||
<Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
|
||||
<PirateIcon />
|
||||
</Button>
|
||||
<Button v-tooltip="'Log via Ely.by'" icon-only @click="showElybyLoginModal()">
|
||||
<ElyByIcon v-if="!elybyLoginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</transition>
|
||||
<ModalWrapper ref="loginOfflineModal" class="modal" header="Add new offline account">
|
||||
<div class="modal-body">
|
||||
<div class="label">Enter offline username</div>
|
||||
<input type="text" v-model="playerName" placeholder="Provide offline player name" />
|
||||
<Button icon-only color="secondary" @click="offlineLoginFinally()">
|
||||
Continue
|
||||
</Button>
|
||||
<ModalWrapper ref="addElybyModal" class="modal" header="Authenticate with Ely.by">
|
||||
<ModalWrapper ref="requestElybyTwoFactorCodeModal" class="modal"
|
||||
header="Ely.by requested 2FA code for authentication">
|
||||
<div class="flex flex-col gap-4 px-6 py-5">
|
||||
<label class="label">Enter your 2FA code</label>
|
||||
<input v-model="elybyTwoFactorCode" type="text" placeholder="Your 2FA code here..." class="input" />
|
||||
<div class="mt-6 ml-auto">
|
||||
<Button icon-only color="primary" class="continue-button" @click="addElybyProfile()">
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<div class="flex flex-col gap-4 px-6 py-5">
|
||||
<label class="label">Enter your player name or email (preferred)</label>
|
||||
<input v-model="elybyLogin" type="text" placeholder="Your player name or email here..." class="input" />
|
||||
<label class="label">Enter your password</label>
|
||||
<input v-model="elybyPassword" type="password" placeholder="Your password here..." class="input" />
|
||||
<div class="mt-6 ml-auto">
|
||||
<Button icon-only color="primary" class="continue-button" @click="addElybyProfile()">
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="loginErrorModal" class="modal" header="Error while proceed">
|
||||
<div class="modal-body">
|
||||
<div class="label">Error occurred while adding offline account</div>
|
||||
<Button color="primary" @click="retryOfflineLogin()">
|
||||
Try again
|
||||
</Button>
|
||||
<ModalWrapper ref="addOfflineModal" class="modal" header="Add new offline account">
|
||||
<div class="flex flex-col gap-4 px-6 py-5">
|
||||
<label class="label">Enter your player name</label>
|
||||
<input v-model="offlinePlayerName" type="text" placeholder="Your player name here..." class="input" />
|
||||
<div class="mt-6 ml-auto">
|
||||
<Button icon-only color="primary" class="continue-button" @click="addOfflineProfile()">
|
||||
Login
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="unexpectedErrorModal" class="modal" header="Ошибка">
|
||||
<ModalWrapper
|
||||
ref="authenticationElybyErrorModal"
|
||||
class="modal"
|
||||
header="Error while proceeding authentication event with Ely.by">
|
||||
<div class="flex flex-col gap-4 px-6 py-5">
|
||||
<label class="text-base font-medium text-red-700">
|
||||
An error occurred while logging in.
|
||||
</label>
|
||||
|
||||
<div class="mt-6 ml-auto">
|
||||
<Button color="primary" class="retry-button" @click="retryAddElybyProfile">
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="inputElybyErrorModal" class="modal" header="Error while proceeding input event with Ely.by">
|
||||
<div class="flex flex-col gap-4 px-6 py-5">
|
||||
<label class="text-base font-medium text-red-700">
|
||||
An error occurred while adding the Ely.by account. Please follow the instructions below.
|
||||
</label>
|
||||
|
||||
<ul class="list-disc list-inside text-sm space-y-1">
|
||||
<li>Check that you have entered the correct player name or email.</li>
|
||||
<li>Check that you have entered the correct password.</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-6 ml-auto">
|
||||
<Button color="primary" class="retry-button" @click="retryAddElybyProfile">
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="inputErrorModal" class="modal" header="Error while proceeding input event with offline account">
|
||||
<div class="flex flex-col gap-4 px-6 py-5">
|
||||
<label class="text-base font-medium text-red-700">
|
||||
An error occurred while adding the offline account. Please follow the instructions below.
|
||||
</label>
|
||||
|
||||
<ul class="list-disc list-inside text-sm space-y-1">
|
||||
<li>Check that you have entered the correct player name.</li>
|
||||
<li>
|
||||
Player name must be at least {{ minOfflinePlayerNameLength }} characters long and no more than
|
||||
{{ maxOfflinePlayerNameLength }} characters.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="mt-6 ml-auto">
|
||||
<Button color="primary" class="retry-button" @click="retryAddOfflineProfile">
|
||||
Try again
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="exceptionErrorModal" class="modal" header="Unexpected error occurred">
|
||||
<div class="modal-body">
|
||||
<div class="label">Unexcepted error</div>
|
||||
<label class="label">An unexpected error has occurred. Please try again later.</label>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
import {
|
||||
DropdownIcon,
|
||||
PlusIcon,
|
||||
TrashIcon,
|
||||
LogInIcon,
|
||||
PirateIcon as Offline,
|
||||
MicrosoftIcon as License,
|
||||
ElyByIcon as Elyby,
|
||||
MicrosoftIcon,
|
||||
PirateIcon,
|
||||
SpinnerIcon } from '@modrinth/assets'
|
||||
ElyByIcon,
|
||||
SpinnerIcon
|
||||
} from '@modrinth/assets'
|
||||
import { Avatar, Button, Card } from '@modrinth/ui'
|
||||
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
||||
import {
|
||||
elyby_auth_authenticate,
|
||||
elyby_login,
|
||||
offline_login,
|
||||
users,
|
||||
remove_user,
|
||||
@@ -146,48 +212,180 @@ defineProps({
|
||||
const emit = defineEmits(['change'])
|
||||
|
||||
const accounts = ref({})
|
||||
const loginDisabled = ref(false)
|
||||
const microsoftLoginDisabled = ref(false)
|
||||
const elybyLoginDisabled = ref(false)
|
||||
const defaultUser = ref()
|
||||
const loginOfflineModal = ref(null)
|
||||
const loginErrorModal = ref(null)
|
||||
const unexpectedErrorModal = ref(null)
|
||||
const playerName = ref('')
|
||||
|
||||
async function tryOfflineLogin() { // Patched by AstralRinth
|
||||
loginOfflineModal.value.show()
|
||||
// [AR] • Feature
|
||||
const clientToken = "astralrinth"
|
||||
const addOfflineModal = ref(null)
|
||||
const addElybyModal = ref(null)
|
||||
const requestElybyTwoFactorCodeModal = ref(null)
|
||||
const authenticationElybyErrorModal = ref(null)
|
||||
const inputElybyErrorModal = ref(null)
|
||||
const inputErrorModal = ref(null)
|
||||
const exceptionErrorModal = ref(null)
|
||||
const offlinePlayerName = ref('')
|
||||
const elybyLogin = ref('')
|
||||
const elybyPassword = ref('')
|
||||
const elybyTwoFactorCode = ref('')
|
||||
const minOfflinePlayerNameLength = 2
|
||||
const maxOfflinePlayerNameLength = 20
|
||||
|
||||
// [AR] • Feature
|
||||
function getAccountType(account) {
|
||||
switch (account.account_type) {
|
||||
case 'microsoft':
|
||||
return License
|
||||
case 'pirate':
|
||||
return Offline
|
||||
case 'elyby':
|
||||
return Elyby
|
||||
}
|
||||
}
|
||||
|
||||
async function offlineLoginFinally() { // Patched by AstralRinth
|
||||
const name = playerName.value
|
||||
if (name.length > 1 && name.length < 20 && name !== '') {
|
||||
const loggedIn = await offline_login(name).catch(handleError)
|
||||
loginOfflineModal.value.hide()
|
||||
if (loggedIn) {
|
||||
await setAccount(loggedIn)
|
||||
// [AR] • Feature
|
||||
function showOfflineLoginModal() {
|
||||
addOfflineModal.value?.show()
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
function showElybyLoginModal() {
|
||||
addElybyModal.value?.show()
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
function retryAddOfflineProfile() {
|
||||
inputErrorModal.value?.hide()
|
||||
clearOfflineFields()
|
||||
showOfflineLoginModal()
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
function retryAddElybyProfile() {
|
||||
authenticationElybyErrorModal.value?.hide()
|
||||
inputElybyErrorModal.value?.hide()
|
||||
clearElybyFields()
|
||||
showElybyLoginModal()
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
function clearElybyFields() {
|
||||
elybyLogin.value = ''
|
||||
elybyPassword.value = ''
|
||||
elybyTwoFactorCode.value = ''
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
function clearOfflineFields() {
|
||||
offlinePlayerName.value = ''
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
async function addOfflineProfile() {
|
||||
const name = offlinePlayerName.value.trim()
|
||||
const isValidName = name.length >= minOfflinePlayerNameLength && name.length <= maxOfflinePlayerNameLength
|
||||
|
||||
if (!isValidName) {
|
||||
addOfflineModal.value?.hide()
|
||||
inputErrorModal.value?.show()
|
||||
clearOfflineFields()
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await offline_login(name)
|
||||
|
||||
addOfflineModal.value?.hide()
|
||||
|
||||
if (result) {
|
||||
await setAccount(result)
|
||||
await refreshValues()
|
||||
} else {
|
||||
unexpectedErrorModal.value.show()
|
||||
exceptionErrorModal.value?.show()
|
||||
}
|
||||
playerName.value = ''
|
||||
} else {
|
||||
playerName.value = ''
|
||||
loginOfflineModal.value.hide()
|
||||
loginErrorModal.value.show()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
exceptionErrorModal.value?.show()
|
||||
} finally {
|
||||
clearOfflineFields()
|
||||
}
|
||||
}
|
||||
|
||||
function retryOfflineLogin() { // Patched by AstralRinth
|
||||
loginErrorModal.value.hide()
|
||||
tryOfflineLogin()
|
||||
}
|
||||
// [AR] • Feature
|
||||
async function addElybyProfile() {
|
||||
if (!elybyLogin.value || !elybyPassword.value) {
|
||||
addElybyModal.value?.hide()
|
||||
inputElybyErrorModal.value?.show()
|
||||
clearElybyFields()
|
||||
return
|
||||
}
|
||||
elybyLoginDisabled.value = true
|
||||
|
||||
function getAccountType(account) { // Patched by AstralRinth
|
||||
if (account.access_token != "null" && account.access_token != null && account.access_token != "") {
|
||||
return License
|
||||
} else {
|
||||
return Offline
|
||||
const login = elybyLogin.value.trim()
|
||||
let password = elybyPassword.value.trim()
|
||||
const twoFactorCode = elybyTwoFactorCode.value.trim()
|
||||
if (password && twoFactorCode) {
|
||||
password = `${password}:${twoFactorCode}`
|
||||
}
|
||||
|
||||
try {
|
||||
const raw_result = await elyby_auth_authenticate(
|
||||
login,
|
||||
password,
|
||||
clientToken
|
||||
)
|
||||
|
||||
const json_data = JSON.parse(raw_result)
|
||||
|
||||
console.log(json_data?.error)
|
||||
console.log(json_data?.errorMessage)
|
||||
|
||||
if (!json_data.accessToken) {
|
||||
if (
|
||||
json_data.error === 'ForbiddenOperationException' &&
|
||||
json_data.errorMessage?.includes('two factor')
|
||||
) {
|
||||
requestElybyTwoFactorCodeModal.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
addElybyModal.value?.hide()
|
||||
requestElybyTwoFactorCodeModal.value?.hide()
|
||||
authenticationElybyErrorModal.value?.show()
|
||||
return
|
||||
}
|
||||
|
||||
const accessToken = json_data.accessToken
|
||||
const selectedProfileId = convertRawStringToUUIDv4(json_data.selectedProfile.id)
|
||||
const selectedProfileName = json_data.selectedProfile.name
|
||||
|
||||
const result = await elyby_login(selectedProfileId, selectedProfileName, accessToken)
|
||||
|
||||
addElybyModal.value?.hide()
|
||||
requestElybyTwoFactorCodeModal.value?.hide()
|
||||
|
||||
clearElybyFields()
|
||||
|
||||
await setAccount(result)
|
||||
await refreshValues()
|
||||
} catch (err) {
|
||||
handleError(err)
|
||||
exceptionErrorModal.value?.show()
|
||||
} finally {
|
||||
elybyLoginDisabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
function convertRawStringToUUIDv4(rawId) {
|
||||
if (rawId.length !== 32) {
|
||||
console.warn('Invalid UUID string:', rawId)
|
||||
return rawId
|
||||
}
|
||||
return `${rawId.slice(0, 8)}-${rawId.slice(8, 12)}-${rawId.slice(12, 16)}-${rawId.slice(16, 20)}-${rawId.slice(20)}`
|
||||
}
|
||||
|
||||
const equippedSkin = ref(null)
|
||||
const headUrlCache = ref(new Map())
|
||||
|
||||
@@ -213,13 +411,13 @@ async function refreshValues() {
|
||||
}
|
||||
|
||||
function setLoginDisabled(value) {
|
||||
loginDisabled.value = value
|
||||
microsoftLoginDisabled.value = value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
refreshValues,
|
||||
setLoginDisabled,
|
||||
loginDisabled,
|
||||
loginDisabled: microsoftLoginDisabled,
|
||||
})
|
||||
await refreshValues()
|
||||
|
||||
@@ -265,7 +463,7 @@ async function setAccount(account) {
|
||||
}
|
||||
|
||||
async function login() {
|
||||
loginDisabled.value = true
|
||||
microsoftLoginDisabled.value = true
|
||||
const loggedIn = await login_flow().catch(handleSevereError)
|
||||
|
||||
if (loggedIn) {
|
||||
@@ -274,7 +472,7 @@ async function login() {
|
||||
}
|
||||
|
||||
trackEvent('AccountLogIn')
|
||||
loginDisabled.value = false
|
||||
microsoftLoginDisabled.value = false
|
||||
}
|
||||
|
||||
const logout = async (id) => {
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
<template>
|
||||
<ModalWrapper ref="modal" :header="'Import from CurseForge Profile Code'">
|
||||
<div class="modal-body">
|
||||
<div class="input-row">
|
||||
<p class="input-label">Profile Code</p>
|
||||
<div class="iconified-input">
|
||||
<SearchIcon aria-hidden="true" class="text-lg" />
|
||||
<input
|
||||
ref="codeInput"
|
||||
v-model="profileCode"
|
||||
autocomplete="off"
|
||||
class="h-12 card-shadow"
|
||||
spellcheck="false"
|
||||
type="text"
|
||||
placeholder="Enter CurseForge profile code"
|
||||
maxlength="20"
|
||||
@keyup.enter="importProfile"
|
||||
/>
|
||||
<Button v-if="profileCode" class="r-btn" @click="() => (profileCode = '')">
|
||||
<XIcon />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="metadata && !importing" class="profile-info">
|
||||
<h3>Profile Information</h3>
|
||||
<p><strong>Name:</strong> {{ metadata.name }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="importing && importProgress.visible" class="progress-section">
|
||||
<div class="progress-info">
|
||||
<span class="progress-text">{{ importProgress.message }}</span>
|
||||
<span class="progress-percentage">{{ Math.floor(importProgress.percentage) }}%</span>
|
||||
</div>
|
||||
<div class="progress-bar-container">
|
||||
<div
|
||||
class="progress-bar"
|
||||
:style="{ width: `${importProgress.percentage}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<Button @click="hide" :disabled="importing">
|
||||
<XIcon />
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
v-if="!metadata"
|
||||
@click="fetchMetadata"
|
||||
:disabled="!profileCode.trim() || fetching"
|
||||
color="secondary"
|
||||
>
|
||||
<SearchIcon v-if="!fetching" />
|
||||
{{ fetching ? 'Checking...' : 'Check Profile' }}
|
||||
</Button>
|
||||
<Button
|
||||
v-if="metadata"
|
||||
@click="importProfile"
|
||||
:disabled="importing"
|
||||
color="primary"
|
||||
>
|
||||
<DownloadIcon v-if="!importing" />
|
||||
{{ importing ? 'Importing...' : 'Import Profile' }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { Button } from '@modrinth/ui'
|
||||
import {
|
||||
XIcon,
|
||||
SearchIcon,
|
||||
DownloadIcon
|
||||
} from '@modrinth/assets'
|
||||
import {
|
||||
fetch_curseforge_profile_metadata,
|
||||
import_curseforge_profile
|
||||
} from '@/helpers/import.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { loading_listener } from '@/helpers/events.js'
|
||||
|
||||
const props = defineProps({
|
||||
closeParent: {
|
||||
type: Function,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const router = useRouter()
|
||||
const modal = ref(null)
|
||||
const codeInput = ref(null)
|
||||
const profileCode = ref('')
|
||||
const metadata = ref(null)
|
||||
const fetching = ref(false)
|
||||
const importing = ref(false)
|
||||
const error = ref('')
|
||||
const importProgress = ref({
|
||||
visible: false,
|
||||
percentage: 0,
|
||||
message: 'Starting import...',
|
||||
totalMods: 0,
|
||||
downloadedMods: 0
|
||||
})
|
||||
|
||||
let unlistenLoading = null
|
||||
let activeLoadingBarId = null
|
||||
let progressFallbackTimer = null
|
||||
|
||||
defineExpose({
|
||||
show: () => {
|
||||
profileCode.value = ''
|
||||
metadata.value = null
|
||||
fetching.value = false
|
||||
importing.value = false
|
||||
error.value = ''
|
||||
importProgress.value = {
|
||||
visible: false,
|
||||
percentage: 0,
|
||||
message: 'Starting import...',
|
||||
totalMods: 0,
|
||||
downloadedMods: 0
|
||||
}
|
||||
modal.value?.show()
|
||||
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
codeInput.value?.focus()
|
||||
}, 100)
|
||||
})
|
||||
|
||||
trackEvent('CurseForgeProfileImportStart', { source: 'ImportModal' })
|
||||
},
|
||||
})
|
||||
|
||||
const hide = () => {
|
||||
modal.value?.hide()
|
||||
}
|
||||
|
||||
const fetchMetadata = async () => {
|
||||
if (!profileCode.value.trim()) return
|
||||
|
||||
fetching.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const result = await fetch_curseforge_profile_metadata(profileCode.value.trim())
|
||||
metadata.value = result
|
||||
trackEvent('CurseForgeProfileMetadataFetched', {
|
||||
profileCode: profileCode.value.trim()
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch CurseForge profile metadata:', err)
|
||||
error.value = 'Failed to fetch profile information. Please check the code and try again.'
|
||||
handleError(err)
|
||||
} finally {
|
||||
fetching.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const importProfile = async () => {
|
||||
if (!profileCode.value.trim()) return
|
||||
|
||||
importing.value = true
|
||||
error.value = ''
|
||||
activeLoadingBarId = null // Reset for new import session
|
||||
importProgress.value = {
|
||||
visible: true,
|
||||
percentage: 0,
|
||||
message: 'Starting import...',
|
||||
totalMods: 0,
|
||||
downloadedMods: 0
|
||||
}
|
||||
|
||||
// Fallback progress timer in case loading events don't work
|
||||
progressFallbackTimer = setInterval(() => {
|
||||
if (importing.value && importProgress.value.percentage < 90) {
|
||||
// Slowly increment progress as a fallback
|
||||
importProgress.value.percentage = Math.min(90, importProgress.value.percentage + 1)
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
try {
|
||||
const { result, profilePath } = await import_curseforge_profile(profileCode.value.trim())
|
||||
|
||||
trackEvent('CurseForgeProfileImported', {
|
||||
profileCode: profileCode.value.trim()
|
||||
})
|
||||
|
||||
hide()
|
||||
|
||||
// Close the parent modal if provided
|
||||
if (props.closeParent) {
|
||||
props.closeParent()
|
||||
}
|
||||
|
||||
// Navigate to the imported profile
|
||||
await router.push(`/instance/${encodeURIComponent(profilePath)}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to import CurseForge profile:', err)
|
||||
error.value = 'Failed to import profile. Please try again.'
|
||||
handleError(err)
|
||||
} finally {
|
||||
importing.value = false
|
||||
importProgress.value.visible = false
|
||||
if (progressFallbackTimer) {
|
||||
clearInterval(progressFallbackTimer)
|
||||
progressFallbackTimer = null
|
||||
}
|
||||
activeLoadingBarId = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Listen for loading events to update progress
|
||||
unlistenLoading = await loading_listener((event) => {
|
||||
console.log('Loading event received:', event) // Debug log
|
||||
|
||||
// Handle all loading events that could be related to CurseForge profile import
|
||||
const isCurseForgeEvent = event.event?.type === 'curseforge_profile_download'
|
||||
const hasProfileName = event.event?.profile_name && importing.value
|
||||
|
||||
if ((isCurseForgeEvent || hasProfileName) && importing.value) {
|
||||
// Store the loading bar ID for this import session
|
||||
if (!activeLoadingBarId) {
|
||||
activeLoadingBarId = event.loader_uuid
|
||||
}
|
||||
|
||||
// Only process events for our current import session
|
||||
if (event.loader_uuid === activeLoadingBarId) {
|
||||
if (event.fraction !== null && event.fraction !== undefined) {
|
||||
const baseProgress = (event.fraction || 0) * 100
|
||||
|
||||
// Calculate custom progress based on the message
|
||||
let finalProgress = baseProgress
|
||||
const message = event.message || 'Importing profile...'
|
||||
|
||||
// Custom progress calculation for different stages
|
||||
if (message.includes('Fetching') || message.includes('metadata')) {
|
||||
finalProgress = Math.min(10, baseProgress)
|
||||
} else if (message.includes('Downloading profile ZIP') || message.includes('profile ZIP')) {
|
||||
finalProgress = Math.min(15, 10 + (baseProgress - 10) * 0.5)
|
||||
} else if (message.includes('Extracting') || message.includes('ZIP')) {
|
||||
finalProgress = Math.min(20, 15 + (baseProgress - 15) * 0.5)
|
||||
} else if (message.includes('Configuring') || message.includes('profile')) {
|
||||
finalProgress = Math.min(30, 20 + (baseProgress - 20) * 0.5)
|
||||
} else if (message.includes('Copying') || message.includes('files')) {
|
||||
finalProgress = Math.min(40, 30 + (baseProgress - 30) * 0.5)
|
||||
} else if (message.includes('Downloaded mod') && message.includes(' of ')) {
|
||||
// Parse "Downloaded mod X of Y" message
|
||||
const match = message.match(/Downloaded mod (\d+) of (\d+)/)
|
||||
if (match) {
|
||||
const current = parseInt(match[1])
|
||||
const total = parseInt(match[2])
|
||||
// Mods take 40% of progress (from 40% to 80%)
|
||||
const modProgress = (current / total) * 40
|
||||
finalProgress = 40 + modProgress
|
||||
} else {
|
||||
finalProgress = Math.min(80, 40 + (baseProgress - 40) * 0.5)
|
||||
}
|
||||
} else if (message.includes('Downloading mod') || message.includes('mods')) {
|
||||
// General mod downloading stage (40% to 80%)
|
||||
finalProgress = Math.min(80, 40 + (baseProgress - 40) * 0.4)
|
||||
} else if (message.includes('Installing Minecraft') || message.includes('Minecraft')) {
|
||||
finalProgress = Math.min(95, 80 + (baseProgress - 80) * 0.75)
|
||||
} else if (message.includes('Finalizing') || message.includes('completed')) {
|
||||
finalProgress = Math.min(100, 95 + (baseProgress - 95))
|
||||
} else {
|
||||
// Default: use the base progress but ensure minimum progression
|
||||
finalProgress = Math.max(importProgress.value.percentage, baseProgress)
|
||||
}
|
||||
|
||||
importProgress.value.percentage = Math.min(100, Math.max(0, finalProgress))
|
||||
importProgress.value.message = message
|
||||
} else {
|
||||
// Loading complete
|
||||
importProgress.value.percentage = 100
|
||||
importProgress.value.message = 'Import completed!'
|
||||
activeLoadingBarId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unlistenLoading) {
|
||||
unlistenLoading()
|
||||
}
|
||||
if (progressFallbackTimer) {
|
||||
clearInterval(progressFallbackTimer)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.input-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-contrast);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.profile-info {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-button);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 1rem;
|
||||
|
||||
h3 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 0.25rem 0;
|
||||
color: var(--color-base);
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: var(--color-red);
|
||||
border: 1px solid var(--color-red);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0.75rem;
|
||||
|
||||
p {
|
||||
margin: 0;
|
||||
color: var(--color-contrast);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.progress-text {
|
||||
color: var(--color-base);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.progress-percentage {
|
||||
color: var(--color-contrast);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-bar-container {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: var(--color-button);
|
||||
border-radius: 2px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: var(--color-brand);
|
||||
border-radius: 2px;
|
||||
transition: width 0.3s ease;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -18,11 +18,16 @@ import { cancel_directory_change } from '@/helpers/settings.ts'
|
||||
import { install } from '@/helpers/profile.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { applyMigrationFix } from '@/helpers/utils.js'
|
||||
import { restartApp } from '@/helpers/utils.js'
|
||||
|
||||
const errorModal = ref()
|
||||
const error = ref()
|
||||
const closable = ref(true)
|
||||
const errorCollapsed = ref(false)
|
||||
const language = ref('en')
|
||||
const migrationFixSuccess = ref(null) // null | true | false
|
||||
const migrationFixCallbackModel = ref()
|
||||
|
||||
const title = ref('An error occurred')
|
||||
const errorType = ref('unknown')
|
||||
@@ -148,6 +153,30 @@ async function copyToClipboard(text) {
|
||||
copied.value = false
|
||||
}, 3000)
|
||||
}
|
||||
|
||||
function toggleLanguage() {
|
||||
language.value = language.value === 'en' ? 'ru' : 'en'
|
||||
}
|
||||
|
||||
async function onApplyMigrationFix(eol) {
|
||||
console.log(`[AR] • Attempting to apply migration ${eol.toUpperCase()} fix`)
|
||||
try {
|
||||
const result = await applyMigrationFix(eol)
|
||||
migrationFixSuccess.value = result === true
|
||||
console.log(`[AR] • Successfully applied migration ${eol.toUpperCase()} fix`, result)
|
||||
} catch (err) {
|
||||
console.error(`[AR] • Failed to apply migration fix:`, err)
|
||||
migrationFixSuccess.value = false
|
||||
} finally {
|
||||
migrationFixCallbackModel.value?.show?.()
|
||||
if (migrationFixSuccess.value === true) {
|
||||
setTimeout(async () => {
|
||||
await restartApp()
|
||||
}, 3000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -298,10 +327,20 @@ async function copyToClipboard(text) {
|
||||
<template v-if="copied"> <CheckIcon class="text-green" /> Copied! </template>
|
||||
<template v-else> <CopyIcon /> Copy debug info </template>
|
||||
</button>
|
||||
<ButtonStyled class="neon-button neon">
|
||||
<a href="https://me.astralium.su/get/ar/help" target="_blank" rel="noopener noreferrer">
|
||||
Get AstralRinth support
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled class="neon-button neon" >
|
||||
<a href="https://me.astralium.su/get/ar" target="_blank" rel="noopener noreferrer">
|
||||
Checkout latest releases
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<template v-if="hasDebugInfo">
|
||||
<div class="bg-button-bg rounded-xl mt-2 overflow-clip">
|
||||
<div class="bg-button-bg rounded-xl mt-2 overflow-hidden">
|
||||
<button
|
||||
class="flex items-center justify-between w-full bg-transparent border-0 px-4 py-3 cursor-pointer"
|
||||
@click="errorCollapsed = !errorCollapsed"
|
||||
@@ -313,12 +352,123 @@ async function copyToClipboard(text) {
|
||||
/>
|
||||
</button>
|
||||
<Collapsible :collapsed="errorCollapsed">
|
||||
<pre class="m-0 px-4 py-3 bg-bg rounded-none">{{ debugInfo }}</pre>
|
||||
<pre
|
||||
class="m-0 px-4 py-3 bg-bg rounded-none whitespace-pre-wrap break-words overflow-x-auto max-w-full"
|
||||
>{{ debugInfo }}</pre>
|
||||
</Collapsible>
|
||||
</div>
|
||||
<template v-if="errorType === 'state_init'">
|
||||
<div class="notice">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 v-if="language === 'en'" class="notice__title">⚠️ Migration Issue • Important Notice ⚠️</h3>
|
||||
<h3 v-if="language === 'ru'" class="notice__title">⚠️ Проблема миграции • Важное уведомление ⚠️</h3>
|
||||
<ButtonStyled>
|
||||
<button @click="toggleLanguage">
|
||||
{{ language === 'en' ? '📖 Русский' : '📖 English' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<p v-if="language === 'en'" class="notice__text">
|
||||
We're experiencing an issue with our database migration system due to differences in how different operating systems handle line endings. This might cause problems with our app's functionality.
|
||||
</p>
|
||||
<p v-if="language === 'en'" class="notice__text">
|
||||
<strong>What's happening?</strong> When we build our app, we use a system that checks the integrity of our database migrations. However, this system can get confused when it encounters different line endings (like CRLF vs LF) used by different operating systems. This can lead to errors and make our app unusable.
|
||||
</p>
|
||||
<p v-if="language === 'en'" class="notice__text">
|
||||
<strong>Why is this happening?</strong> This issue is caused by a combination of factors, including different operating systems handling line endings differently, Git's line ending conversion settings, and our app's build process.
|
||||
</p>
|
||||
<p v-if="language === 'en'" class="notice__text">
|
||||
<strong>What are we doing about it?</strong> We're working to resolve this issue and ensure that our app works smoothly for all users. In the meantime, we apologize for any inconvenience this might cause and appreciate your patience and understanding.
|
||||
</p>
|
||||
<p v-if="language === 'ru'" class="notice__text">
|
||||
Мы сталкиваемся с проблемой в нашей системе миграции базы данных из-за различий в том, как разные операционные системы обрабатывают окончания строк. Это может вызвать проблемы с функциональностью нашего приложения.
|
||||
</p>
|
||||
<p v-if="language === 'ru'" class="notice__text">
|
||||
<strong>Что происходит?</strong> Когда мы строим наше приложение, мы используем систему, которая проверяет целостность наших миграций базы данных. Однако эта система может сбиваться, когда сталкивается с различными окончаниями строк (например, CRLF против LF), используемыми разными операционными системами. Это может привести к ошибкам и сделать наше приложение неработоспособным.
|
||||
</p>
|
||||
<p v-if="language === 'ru'" class="notice__text">
|
||||
<strong>Почему это происходит?</strong> Эта проблема вызвана сочетанием факторов, включая различную обработку окончаний строк разными операционными системами, настройки преобразования окончаний строк в Git и процесс сборки нашего приложения.
|
||||
</p>
|
||||
<p v-if="language === 'ru'" class="notice__text">
|
||||
<strong>Что мы с этим делаем?</strong> Мы работаем над решением этой проблемы и обеспечением бесперебойной работы нашего приложения для всех пользователей. В это время мы извиняемся за возможные неудобства и благодарим вас за терпение и понимание.
|
||||
</p>
|
||||
</div>
|
||||
<h2 class="text-lg font-bold text-contrast">
|
||||
<template v-if="language === 'en'">Possible fix in real time:</template>
|
||||
<template v-if="language === 'ru'">Возможное исправление в реальном времени:</template>
|
||||
</h2>
|
||||
<div class="flex justify-between">
|
||||
<ol class="flex flex-col gap-3">
|
||||
<li>
|
||||
<ButtonStyled class="neon-button neon">
|
||||
<button
|
||||
:title="language === 'en'
|
||||
? 'Convert all line endings in migration files to LF (Unix-style: \\n)'
|
||||
: 'Преобразовать все окончания строк в файлах миграций в LF (Unix-стиль: \\n)'"
|
||||
aria-label="LF"
|
||||
@click="onApplyMigrationFix('lf')"
|
||||
>
|
||||
{{ language === 'en' ? 'Apply LF Migration Fix' : 'Применить исправление миграции LF' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</li>
|
||||
<li>
|
||||
<ButtonStyled class="neon-button neon">
|
||||
<button
|
||||
:title="language === 'en'
|
||||
? 'Convert all line endings in migration files to CRLF (Windows-style: \\r\\n)'
|
||||
: 'Преобразовать все окончания строк в файлах миграций в CRLF (Windows-стиль: \\r\\n)'"
|
||||
aria-label="CRLF"
|
||||
@click="onApplyMigrationFix('crlf')"
|
||||
>
|
||||
{{ language === 'en' ? 'Apply CRLF Migration Fix' : 'Применить исправление миграции CRLF' }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper
|
||||
ref="migrationFixCallbackModel"
|
||||
:header="language === 'en'
|
||||
? '💡 Migration fix report'
|
||||
: '💡 Отчет об исправлении миграции'"
|
||||
:closable="closable">
|
||||
<div class="modal-body">
|
||||
<h2 class="text-lg font-bold text-contrast space-y-2">
|
||||
<template v-if="migrationFixSuccess === true">
|
||||
<p class="flex items-center gap-2 neon-text">
|
||||
✅
|
||||
{{ language === 'en'
|
||||
? 'The migration fix has been applied successfully. Please restart the launcher and try to log in to the game :)'
|
||||
: 'Исправление миграции успешно применено. Пожалуйста, перезапустите лаунчер и попробуйте снова авторизоваться в игре :)' }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm neon-text">
|
||||
{{ language === 'en'
|
||||
? 'If the problem persists, please try the other fix.'
|
||||
: 'Если проблема сохраняется, пожалуйста, попробуйте другой способ.' }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template v-else-if="migrationFixSuccess === false">
|
||||
<p class="flex items-center gap-2 neon-text">
|
||||
❌
|
||||
{{ language === 'en'
|
||||
? 'The migration fix failed or had no effect.'
|
||||
: 'Исправление миграции не было успешно применено или не имело эффекта.' }}
|
||||
</p>
|
||||
<p class="mt-2 text-sm neon-text">
|
||||
{{ language === 'en'
|
||||
? 'If the problem persists, please try the other fix.'
|
||||
: 'Если проблема сохраняется, пожалуйста, попробуйте другой способ.' }}
|
||||
</p>
|
||||
</template>
|
||||
</h2>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<style>
|
||||
@@ -333,6 +483,9 @@ async function copyToClipboard(text) {
|
||||
</style>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@import '../../../../../packages/assets/styles/neon-button.scss';
|
||||
@import '../../../../../packages/assets/styles/neon-text.scss';
|
||||
|
||||
.cta-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -163,6 +163,14 @@
|
||||
<div v-else class="table-content empty">No profiles found</div>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<Button
|
||||
v-if="selectedProfileType.name === 'Curseforge'"
|
||||
@click="showCurseForgeProfileModal"
|
||||
:disabled="loading"
|
||||
>
|
||||
<CodeIcon />
|
||||
Import from Profile Code
|
||||
</Button>
|
||||
<Button
|
||||
:disabled="
|
||||
loading ||
|
||||
@@ -194,10 +202,12 @@
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<CurseForgeProfileImportModal ref="curseforgeProfileModal" :close-parent="hide" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import CurseForgeProfileImportModal from '@/components/ui/CurseForgeProfileImportModal.vue'
|
||||
import {
|
||||
CodeIcon,
|
||||
FolderOpenIcon,
|
||||
@@ -283,6 +293,11 @@ const hide = () => {
|
||||
unlistener.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const showCurseForgeProfileModal = () => {
|
||||
curseforgeProfileModal.value?.show()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (unlistener.value) {
|
||||
unlistener.value()
|
||||
@@ -305,12 +320,16 @@ const [
|
||||
get_game_versions().then(shallowRef).catch(handleError),
|
||||
get_loaders()
|
||||
.then((value) =>
|
||||
value
|
||||
.filter((item) => item.supported_project_types.includes('modpack'))
|
||||
.map((item) => item.name.toLowerCase()),
|
||||
ref(
|
||||
value
|
||||
.filter((item) => item.supported_project_types.includes('modpack'))
|
||||
.map((item) => item.name.toLowerCase()),
|
||||
),
|
||||
)
|
||||
.then(ref)
|
||||
.catch(handleError),
|
||||
.catch((err) => {
|
||||
handleError(err)
|
||||
return ref([])
|
||||
}),
|
||||
])
|
||||
loaders.value.unshift('vanilla')
|
||||
|
||||
@@ -334,6 +353,7 @@ const game_versions = computed(() => {
|
||||
})
|
||||
|
||||
const modal = ref(null)
|
||||
const curseforgeProfileModal = ref(null)
|
||||
|
||||
const check_valid = computed(() => {
|
||||
return (
|
||||
|
||||
@@ -36,60 +36,6 @@
|
||||
<span class="circle stopped" />
|
||||
<span class="running-text"> No instances running </span>
|
||||
</div>
|
||||
<div v-if="updateState">
|
||||
<a>
|
||||
<Button class="download" :disabled="installState" @click="initUpdateModal(), getRemote(false)">
|
||||
<DownloadIcon />
|
||||
{{
|
||||
installState
|
||||
? "Downloading new update..."
|
||||
: "Download new update"
|
||||
}}
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
<ModalWrapper ref="updateModalView" :has-to-type="false" header="Request to update the AstralRinth launcher">
|
||||
<div class="modal-body">
|
||||
<div class="markdown-body">
|
||||
<p>The new version of the AstralRinth launcher is available.</p>
|
||||
<p>Your version is outdated. We recommend that you update to the latest version.</p>
|
||||
<p><strong>⚠️ Warning ⚠️</strong></p>
|
||||
<p>
|
||||
Before updating, make sure that you have saved all running instances and made a backup copy of the instances
|
||||
that are valuable to you. Remember that the authors of the product are not responsible for the breakdown of
|
||||
your files, so you should always make copies of them and keep them in a safe place.
|
||||
</p>
|
||||
</div>
|
||||
<span>Source • Git Astralium</span>
|
||||
<span>Version on remote server • <p id="releaseData" class="cosmic inline-fix"></p></span>
|
||||
<span>Version on local device •
|
||||
<p class="cosmic inline-fix">v{{ version }}</p>
|
||||
</span>
|
||||
<div class="button-group push-right">
|
||||
<Button class="updater-modal" @click="updateModalView.hide()">
|
||||
Cancel</Button>
|
||||
<Button class="updater-modal" @click="initDownload()">
|
||||
Download file
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="updateRequestFailView" :has-to-type="false" header="Failed to request a file from the server :(">
|
||||
<div class="modal-body">
|
||||
<div class="markdown-body">
|
||||
<p><strong>Error occurred</strong></p>
|
||||
<p>Unfortunately, the program was unable to download the file from our servers.</p>
|
||||
<p>Please try downloading it yourself from <a href="https://me.astralium.su/get/ar" target="_blank" rel="noopener noreferrer">Git Astralium</a> if there are any updates available.</p>
|
||||
</div>
|
||||
<span>Local AstralRinth •
|
||||
<p class="cosmic inline-fix">v{{ version }}</p>
|
||||
</span>
|
||||
</div>
|
||||
<div class="button-group push-right">
|
||||
<Button class="updater-modal" @click="updateRequestFailView.hide()">
|
||||
Close</Button>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</div>
|
||||
<transition name="download">
|
||||
<Card v-if="showCard === true && currentLoadingBars.length > 0" ref="card" class="info-card">
|
||||
@@ -138,29 +84,6 @@ import ProgressBar from '@/components/ui/ProgressBar.vue'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
import { get_many } from '@/helpers/profile.js'
|
||||
import { trackEvent } from '@/helpers/analytics'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
|
||||
const version = await getVersion()
|
||||
|
||||
import { installState, getRemote, updateState } from '@/helpers/update.js'
|
||||
import ModalWrapper from './modal/ModalWrapper.vue'
|
||||
|
||||
const updateModalView = ref(null)
|
||||
const updateRequestFailView = ref(null)
|
||||
|
||||
const initUpdateModal = async () => {
|
||||
updateModalView.value.show()
|
||||
}
|
||||
|
||||
const initDownload = async () => {
|
||||
updateModalView.value.hide()
|
||||
const result = await getRemote(true);
|
||||
if (!result) {
|
||||
updateRequestFailView.value.show()
|
||||
}
|
||||
}
|
||||
|
||||
await getRemote(false)
|
||||
|
||||
const router = useRouter()
|
||||
const card = ref(null)
|
||||
@@ -318,101 +241,6 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.inline-fix {
|
||||
display: inline-flex;
|
||||
margin-top: -2rem;
|
||||
margin-bottom: -2rem;
|
||||
//margin-left: 0.3rem;
|
||||
}
|
||||
|
||||
.cosmic {
|
||||
color: #3e8cde;
|
||||
text-decoration: none;
|
||||
text-shadow:
|
||||
0 0 4px rgba(79, 173, 255, 0.5),
|
||||
0 0 8px rgba(14, 98, 204, 0.5),
|
||||
0 0 12px rgba(122, 31, 199, 0.5);
|
||||
transition: color 0.35s ease;
|
||||
}
|
||||
|
||||
.markdown-body {
|
||||
:deep(table) {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
:deep(hr),
|
||||
:deep(h1),
|
||||
:deep(h2) {
|
||||
max-width: max(60rem, 90%);
|
||||
}
|
||||
|
||||
:deep(ul),
|
||||
:deep(ol) {
|
||||
margin-left: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: var(--gap-lg);
|
||||
text-align: left;
|
||||
|
||||
.button-group {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
strong {
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
}
|
||||
|
||||
.download {
|
||||
color: #3e8cde;
|
||||
border-radius: var(--radius-md);
|
||||
border: 1px solid var(--color-button-bg);
|
||||
// padding: var(--gap-sm) var(--gap-lg);
|
||||
background-color: rgba(0, 0, 0, 0);
|
||||
text-decoration: none;
|
||||
text-shadow:
|
||||
0 0 4px rgba(79, 173, 255, 0.5),
|
||||
0 0 8px rgba(14, 98, 204, 0.5),
|
||||
0 0 12px rgba(122, 31, 199, 0.5);
|
||||
transition: color 0.35s ease;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.download:hover,
|
||||
.download:focus,
|
||||
.download:active {
|
||||
color: #10fae5;
|
||||
text-shadow: #26065e;
|
||||
}
|
||||
|
||||
.updater-modal {
|
||||
color: #3e8cde;
|
||||
padding: var(--gap-sm) var(--gap-lg);
|
||||
text-decoration: none;
|
||||
text-shadow:
|
||||
0 0 4px rgba(79, 173, 255, 0.5),
|
||||
0 0 8px rgba(14, 98, 204, 0.5),
|
||||
0 0 12px rgba(122, 31, 199, 0.5);
|
||||
transition: color 0.35s ease;
|
||||
}
|
||||
|
||||
.updater-modal:hover,
|
||||
.updater-modal:focus,
|
||||
.updater-modal:active {
|
||||
color: #10fae5;
|
||||
text-shadow: #26065e;
|
||||
}
|
||||
|
||||
.action-groups {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type Version,
|
||||
} from '@modrinth/utils'
|
||||
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { get_project, get_version_many } from '@/helpers/cache'
|
||||
import ModpackVersionModal from '@/components/ui/ModpackVersionModal.vue'
|
||||
import dayjs from 'dayjs'
|
||||
@@ -35,6 +36,11 @@ import type {
|
||||
Manifest,
|
||||
} from '../../../helpers/types'
|
||||
|
||||
import { initAuthlibPatching } from '@/helpers/utils.js'
|
||||
const authLibPatchingModal = ref(null)
|
||||
const isAuthLibPatchedSuccess = ref(false)
|
||||
const isAuthLibPatching = ref(false)
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const repairConfirmModal = ref()
|
||||
@@ -447,9 +453,43 @@ const messages = defineMessages({
|
||||
defaultMessage: 'reinstall',
|
||||
},
|
||||
})
|
||||
|
||||
async function handleInitAuthLibPatching(ismojang: boolean) {
|
||||
isAuthLibPatching.value = true
|
||||
let state = false
|
||||
let instance_path = props.instance.loader_version != null ? props.instance.game_version + "-" + props.instance.loader_version : props.instance.game_version
|
||||
try {
|
||||
state = await initAuthlibPatching(instance_path, ismojang)
|
||||
} catch (err) {
|
||||
console.error(err)
|
||||
}
|
||||
isAuthLibPatching.value = false
|
||||
isAuthLibPatchedSuccess.value = state
|
||||
authLibPatchingModal.value.show()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ModalWrapper
|
||||
ref="authLibPatchingModal"
|
||||
:header="'AuthLib installation report'"
|
||||
:closable="true"
|
||||
@close="authLibPatchingModal.hide()"
|
||||
>
|
||||
<div class="modal-body">
|
||||
<h2 class="text-lg font-bold text-contrast space-y-2">
|
||||
<p class="flex items-center gap-2 neon-text">
|
||||
<span v-if="isAuthLibPatchedSuccess" class="neon-text">
|
||||
AuthLib installation completed successfully! Now you can log in and play!
|
||||
</span>
|
||||
<span v-else class="neon-text">
|
||||
Failed to install AuthLib. It's possible that no compatible AuthLib version was found for the selected game and/or mod loader version.
|
||||
There may also be a problem with accessing resources behind CloudFlare.
|
||||
</span>
|
||||
</p>
|
||||
</h2>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ConfirmModalWrapper
|
||||
ref="repairConfirmModal"
|
||||
:title="formatMessage(messages.repairConfirmTitle)"
|
||||
@@ -720,6 +760,24 @@ const messages = defineMessages({
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<h2 class="m-0 mt-4 text-lg font-extrabold text-contrast block">
|
||||
<div v-if="isAuthLibPatching" class="w-6 h-6 cursor-pointer hover:brightness-75 neon-icon pulse">
|
||||
<SpinnerIcon class="size-4 animate-spin" />
|
||||
</div>
|
||||
Auth system (Skins) <span class="text-sm font-bold px-2 bg-brand-highlight text-brand rounded-full">Beta</span>
|
||||
</h2>
|
||||
<div class="mt-4 flex gap-2">
|
||||
<ButtonStyled class="neon-button neon">
|
||||
<button :disabled="isAuthLibPatching" @click="handleInitAuthLibPatching(true)">
|
||||
Install Microsoft
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled class="neon-button neon">
|
||||
<button :disabled="isAuthLibPatching" @click="handleInitAuthLibPatching(false) ">
|
||||
Install Ely.By
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="instance.linked_data && instance.linked_data.locked">
|
||||
@@ -787,3 +845,9 @@ const messages = defineMessages({
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../../../../../../packages/assets/styles/neon-button.scss';
|
||||
@import '../../../../../../packages/assets/styles/neon-text.scss';
|
||||
@import '../../../../../../packages/assets/styles/neon-icon.scss';
|
||||
</style>
|
||||
@@ -6,9 +6,9 @@ import { edit, get_optimal_jre_key } from '@/helpers/profile'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
||||
import { get_max_memory } from '@/helpers/jre'
|
||||
import { get } from '@/helpers/settings.ts'
|
||||
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
|
||||
import useMemorySlider from '@/composables/useMemorySlider'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -34,7 +34,7 @@ const envVars = ref(
|
||||
|
||||
const overrideMemorySettings = ref(!!props.instance.memory)
|
||||
const memory = ref(props.instance.memory ?? globalSettings.memory)
|
||||
const maxMemory = Math.floor((await get_max_memory().catch(handleError)) / 1024)
|
||||
const { maxMemory, snapPoints } = await useMemorySlider()
|
||||
|
||||
const editProfileObject = computed(() => {
|
||||
const editProfile: {
|
||||
@@ -156,6 +156,8 @@ const messages = defineMessages({
|
||||
:min="512"
|
||||
:max="maxMemory"
|
||||
:step="64"
|
||||
:snap-points="snapPoints"
|
||||
:snap-range="512"
|
||||
unit="MB"
|
||||
/>
|
||||
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
PaintbrushIcon,
|
||||
GameIcon,
|
||||
CoffeeIcon,
|
||||
DownloadIcon,
|
||||
SpinnerIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { TabbedModal } from '@modrinth/ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
@@ -23,6 +25,23 @@ import { useTheming } from '@/store/state'
|
||||
import FeatureFlagSettings from '@/components/ui/settings/FeatureFlagSettings.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
// [AR] Imports
|
||||
import { installState, getRemote, updateState } from '@/helpers/update.js'
|
||||
|
||||
const updateModalView = ref(null)
|
||||
const updateRequestFailView = ref(null)
|
||||
|
||||
const initUpdateModal = async () => {
|
||||
updateModalView.value.show()
|
||||
}
|
||||
|
||||
const initDownload = async () => {
|
||||
updateModalView.value.hide()
|
||||
const result = await getRemote(true);
|
||||
if (!result) {
|
||||
updateRequestFailView.value.show()
|
||||
}
|
||||
}
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
@@ -138,11 +157,10 @@ function devModeCount() {
|
||||
{{ formatMessage(developerModeEnabled) }}
|
||||
</p>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
<button
|
||||
class="p-0 m-0 bg-transparent border-none cursor-pointer button-animation"
|
||||
:class="{ 'text-brand': themeStore.devMode, 'text-secondary': !themeStore.devMode }"
|
||||
@click="devModeCount"
|
||||
>
|
||||
@click="devModeCount">
|
||||
<AstralRinthLogo class="w-6 h-6" />
|
||||
</button>
|
||||
<div>
|
||||
@@ -153,9 +171,80 @@ function devModeCount() {
|
||||
{{ osVersion }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="updateState" class="w-8 h-8 cursor-pointer hover:brightness-75 neon-icon pulse">
|
||||
<template v-if="installState">
|
||||
<SpinnerIcon class="size-6 animate-spin" v-tooltip.bottom="'Installing in process...'" />
|
||||
</template>
|
||||
<template v-else>
|
||||
<DownloadIcon class="size-6" v-tooltip.bottom="'View update info'" @click="!installState && (initUpdateModal(), getRemote(false))" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</TabbedModal>
|
||||
<!-- [AR] Feature -->
|
||||
<ModalWrapper ref="updateModalView" :has-to-type="false" header="Request to update the AstralRinth launcher">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<p>The new version of the AstralRinth launcher is available.</p>
|
||||
<p>Your version is outdated. We recommend that you update to the latest version.</p>
|
||||
<p><strong>⚠️ Warning ⚠️</strong></p>
|
||||
<p>
|
||||
Before updating, make sure that you have saved all running instances and made a backup copy of the instances
|
||||
that are valuable to you. Remember that the authors of the product are not responsible for the breakdown of
|
||||
your files, so you should always make copies of them and keep them in a safe place.
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-sm text-secondary space-y-1">
|
||||
<a class="neon-text" href="https://me.astralium.su/get/ar" target="_blank"
|
||||
rel="noopener noreferrer"><strong>Source:</strong> Git Astralium</a>
|
||||
<p>
|
||||
<strong>Version on remote server:</strong>
|
||||
<span id="releaseData" class="neon-text"></span>
|
||||
</p>
|
||||
<p>
|
||||
<strong>Version on local device:</strong>
|
||||
<span class="neon-text">v{{ version }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-4 right-4 flex items-center gap-4 neon-button neon">
|
||||
<Button class="bordered" @click="updateModalView.hide()">Cancel</Button>
|
||||
<Button class="bordered" @click="initDownload()">Download file</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
<ModalWrapper ref="updateRequestFailView" :has-to-type="false" header="Failed to request a file from the server :(">
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<p><strong>Error occurred</strong></p>
|
||||
<p>Unfortunately, the program was unable to download the file from our servers.</p>
|
||||
<p>
|
||||
Please try downloading it yourself from
|
||||
<a class="neon-text" href="https://me.astralium.su/get/ar" target="_blank" rel="noopener noreferrer">Git
|
||||
Astralium</a>
|
||||
if there are any updates available.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-sm text-secondary">
|
||||
<p>
|
||||
<strong>Local AstralRinth:</strong>
|
||||
<span class="neon-text">v{{ version }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="absolute bottom-4 right-4 flex items-center gap-4 neon-button neon">
|
||||
<Button class="bordered" @click="updateRequestFailView.hide()">Close</Button>
|
||||
</div>
|
||||
</div>
|
||||
</ModalWrapper>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '../../../../../../packages/assets/styles/neon-icon.scss';
|
||||
@import '../../../../../../packages/assets/styles/neon-button.scss';
|
||||
@import '../../../../../../packages/assets/styles/neon-text.scss';
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { LogInIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
|
||||
defineProps({
|
||||
onFlowCancel: {
|
||||
type: Function,
|
||||
default() {
|
||||
return async () => {}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref()
|
||||
|
||||
function show() {
|
||||
modal.value.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
<template>
|
||||
<ModalWrapper ref="modal" @hide="onFlowCancel">
|
||||
<template #title>
|
||||
<span class="items-center gap-2 text-lg font-extrabold text-contrast">
|
||||
<LogInIcon /> Sign in
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="flex justify-center gap-2">
|
||||
<SpinnerIcon class="w-12 h-12 animate-spin" />
|
||||
</div>
|
||||
<p class="text-sm text-secondary">
|
||||
Please sign in at the browser window that just opened to continue.
|
||||
</p>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
@@ -1,9 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { get, set } from '@/helpers/settings.ts'
|
||||
import { ref, watch } from 'vue'
|
||||
import { get_max_memory } from '@/helpers/jre'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import { Slider, Toggle } from '@modrinth/ui'
|
||||
import useMemorySlider from '@/composables/useMemorySlider'
|
||||
|
||||
const fetchSettings = await get()
|
||||
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
|
||||
@@ -11,7 +10,7 @@ fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).jo
|
||||
|
||||
const settings = ref(fetchSettings)
|
||||
|
||||
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
|
||||
const { maxMemory, snapPoints } = await useMemorySlider()
|
||||
|
||||
watch(
|
||||
settings,
|
||||
@@ -107,6 +106,8 @@ watch(
|
||||
:min="512"
|
||||
:max="maxMemory"
|
||||
:step="64"
|
||||
:snap-points="snapPoints"
|
||||
:snap-range="512"
|
||||
unit="MB"
|
||||
/>
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ watch(
|
||||
option, you opt out and ads will no longer be shown based on your interests.
|
||||
</p>
|
||||
</div>
|
||||
<!-- AstralRinth disabled element by default -->
|
||||
<!-- [AR] Patch. Disabled element by default -->
|
||||
<Toggle id="personalized-ads" v-model="settings.personalized_ads" :disabled="!settings.personalized_ads" />
|
||||
</div>
|
||||
|
||||
@@ -43,7 +43,7 @@ watch(
|
||||
longer be collected.
|
||||
</p>
|
||||
</div>
|
||||
<!-- AstralRinth disabled element by default -->
|
||||
<!-- [AR] Patch. Disabled element by default -->
|
||||
<Toggle id="opt-out-analytics" v-model="settings.telemetry" :disabled="!settings.telemetry" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ import {
|
||||
type Cape,
|
||||
type SkinModel,
|
||||
get_normalized_skin_texture,
|
||||
determineModelType,
|
||||
} from '@/helpers/skins.ts'
|
||||
import { handleError } from '@/store/notifications'
|
||||
import {
|
||||
@@ -253,7 +254,7 @@ async function showNew(e: MouseEvent, skinTextureUrl: string) {
|
||||
mode.value = 'new'
|
||||
currentSkin.value = null
|
||||
uploadedTextureUrl.value = skinTextureUrl
|
||||
variant.value = 'CLASSIC'
|
||||
variant.value = await determineModelType(skinTextureUrl)
|
||||
selectedCape.value = undefined
|
||||
visibleCapeList.value = []
|
||||
initVisibleCapeList()
|
||||
|
||||
@@ -128,6 +128,14 @@ const messages = defineMessages({
|
||||
id: 'instance.worlds.game_already_open',
|
||||
defaultMessage: 'Instance is already open',
|
||||
},
|
||||
noContact: {
|
||||
id: 'instance.worlds.no_contact',
|
||||
defaultMessage: "Server couldn't be contacted",
|
||||
},
|
||||
incompatibleServer: {
|
||||
id: 'instance.worlds.incompatible_server',
|
||||
defaultMessage: 'Server is incompatible',
|
||||
},
|
||||
copyAddress: {
|
||||
id: 'instance.worlds.copy_address',
|
||||
defaultMessage: 'Copy address',
|
||||
@@ -302,39 +310,33 @@ const messages = defineMessages({
|
||||
</template>
|
||||
</div>
|
||||
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
|
||||
<template v-if="world.type === 'singleplayer' || serverStatus">
|
||||
<ButtonStyled
|
||||
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
|
||||
color="red"
|
||||
>
|
||||
<button @click="emit('stop')">
|
||||
<StopCircleIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.stopButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<button
|
||||
v-tooltip="
|
||||
serverIncompatible
|
||||
? 'Server is incompatible'
|
||||
<ButtonStyled
|
||||
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
|
||||
color="red"
|
||||
>
|
||||
<button @click="emit('stop')">
|
||||
<StopCircleIcon aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.stopButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-else>
|
||||
<button
|
||||
v-tooltip="
|
||||
!serverStatus
|
||||
? formatMessage(messages.noContact)
|
||||
: serverIncompatible
|
||||
? formatMessage(messages.incompatibleServer)
|
||||
: !supportsQuickPlay
|
||||
? formatMessage(messages.noQuickPlay)
|
||||
: playingOtherWorld || locked
|
||||
? formatMessage(messages.gameAlreadyOpen)
|
||||
: null
|
||||
"
|
||||
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
|
||||
@click="emit('play')"
|
||||
>
|
||||
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
||||
<PlayIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.playButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<ButtonStyled v-else>
|
||||
<button class="invisible">
|
||||
<PlayIcon aria-hidden="true" />
|
||||
"
|
||||
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
|
||||
@click="emit('play')"
|
||||
>
|
||||
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
||||
<PlayIcon v-else aria-hidden="true" />
|
||||
{{ formatMessage(commonMessages.playButton) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
21
apps/app-frontend/src/composables/useMemorySlider.js
Normal file
21
apps/app-frontend/src/composables/useMemorySlider.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { get_max_memory } from '@/helpers/jre.js'
|
||||
import { handleError } from '@/store/notifications.js'
|
||||
|
||||
export default async function () {
|
||||
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
|
||||
|
||||
const snapPoints = computed(() => {
|
||||
let points = []
|
||||
let memory = 2048
|
||||
|
||||
while (memory <= maxMemory.value) {
|
||||
points.push(memory)
|
||||
memory *= 2
|
||||
}
|
||||
|
||||
return points
|
||||
})
|
||||
|
||||
return { maxMemory, snapPoints }
|
||||
}
|
||||
@@ -17,6 +17,24 @@ export async function offline_login(name) {
|
||||
return await invoke('plugin:auth|offline_login', { name: name })
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
export async function elyby_login(uuid, login, accessToken) {
|
||||
return await invoke('plugin:auth|elyby_login', {
|
||||
uuid,
|
||||
login,
|
||||
accessToken
|
||||
})
|
||||
}
|
||||
|
||||
// [AR] • Feature
|
||||
export async function elyby_auth_authenticate(login, password, clientToken) {
|
||||
return await invoke('plugin:auth|elyby_auth_authenticate', {
|
||||
login,
|
||||
password,
|
||||
clientToken,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Authenticate a user with Hydra - part 1.
|
||||
* This begins the authentication flow quasi-synchronously.
|
||||
|
||||
@@ -61,3 +61,31 @@ export async function is_valid_importable_instance(instanceFolder, launcherType)
|
||||
export async function get_default_launcher_path(launcherType) {
|
||||
return await invoke('plugin:import|get_default_launcher_path', { launcherType })
|
||||
}
|
||||
|
||||
/// Fetch CurseForge profile metadata from profile code
|
||||
/// eg: fetch_curseforge_profile_metadata("eSrNlKNo")
|
||||
export async function fetch_curseforge_profile_metadata(profileCode) {
|
||||
return await invoke('plugin:import|fetch_curseforge_profile_metadata', { profileCode })
|
||||
}
|
||||
|
||||
/// Import a CurseForge profile from profile code
|
||||
/// eg: import_curseforge_profile("eSrNlKNo")
|
||||
export async function import_curseforge_profile(profileCode) {
|
||||
try {
|
||||
// First, fetch the profile metadata to get the actual name
|
||||
const metadata = await fetch_curseforge_profile_metadata(profileCode)
|
||||
|
||||
// create a basic, empty instance using the actual profile name
|
||||
const profilePath = await create(metadata.name, '1.19.4', 'vanilla', 'latest', null, true)
|
||||
|
||||
const result = await invoke('plugin:import|import_curseforge_profile', {
|
||||
profilePath,
|
||||
profileCode,
|
||||
})
|
||||
|
||||
// Return the profile path for navigation
|
||||
return { result, profilePath }
|
||||
} catch (error) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,3 +16,7 @@ export async function logout() {
|
||||
export async function get() {
|
||||
return await invoke('plugin:mr-auth|get')
|
||||
}
|
||||
|
||||
export async function cancelLogin() {
|
||||
return await invoke('plugin:mr-auth|cancel_modrinth_login')
|
||||
}
|
||||
|
||||
@@ -2,7 +2,13 @@ import * as THREE from 'three'
|
||||
import type { Skin, Cape } from '../skins'
|
||||
import { get_normalized_skin_texture, determineModelType } from '../skins'
|
||||
import { reactive } from 'vue'
|
||||
import { setupSkinModel, disposeCaches, loadTexture, applyCapeTexture } from '@modrinth/utils'
|
||||
import {
|
||||
setupSkinModel,
|
||||
disposeCaches,
|
||||
loadTexture,
|
||||
applyCapeTexture,
|
||||
createTransparentTexture,
|
||||
} from '@modrinth/utils'
|
||||
import { skinPreviewStorage } from '../storage/skin-preview-storage'
|
||||
import { headStorage } from '../storage/head-storage'
|
||||
import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
|
||||
@@ -120,6 +126,9 @@ class BatchSkinRenderer {
|
||||
if (capeUrl) {
|
||||
const capeTexture = await loadTexture(capeUrl)
|
||||
applyCapeTexture(model, capeTexture)
|
||||
} else {
|
||||
const transparentTexture = createTransparentTexture()
|
||||
applyCapeTexture(model, null, transparentTexture)
|
||||
}
|
||||
|
||||
const group = new THREE.Group()
|
||||
|
||||
@@ -62,15 +62,12 @@ export async function determineModelType(texture: string): Promise<'SLIM' | 'CLA
|
||||
|
||||
context.drawImage(image, 0, 0)
|
||||
|
||||
const armX = 44
|
||||
const armY = 16
|
||||
const armWidth = 4
|
||||
const armX = 54
|
||||
const armY = 20
|
||||
const armWidth = 2
|
||||
const armHeight = 12
|
||||
|
||||
const imageData = context.getImageData(armX, armY, armWidth, armHeight).data
|
||||
|
||||
for (let y = 0; y < armHeight; y++) {
|
||||
const alphaIndex = (3 + y * armWidth) * 4 + 3
|
||||
for (let alphaIndex = 3; alphaIndex < imageData.length; alphaIndex += 4) {
|
||||
if (imageData[alphaIndex] !== 0) {
|
||||
resolve('CLASSIC')
|
||||
return
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ref } from 'vue'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
import { getArtifact, getOS } from '@/helpers/utils.js'
|
||||
import { initUpdateLauncher, getOS } from '@/helpers/utils.js'
|
||||
|
||||
export const allowState = ref(false)
|
||||
export const installState = ref(false)
|
||||
@@ -11,7 +11,7 @@ const releaseLink = `https://git.astralium.su/api/v1/repos/didirus/AstralRinth/r
|
||||
const failedFetch = [`Failed to fetch remote releases:`, `Failed to fetch remote commits:`]
|
||||
|
||||
const osList = ['macos', 'windows', 'linux']
|
||||
const macExtensionList = ['.app', '.dmg']
|
||||
const macExtensionList = ['.dmg', '.pkg']
|
||||
const windowsExtensionList = ['.exe', '.msi']
|
||||
|
||||
const blacklistPrefixes = [
|
||||
@@ -52,7 +52,7 @@ export async function getRemote(isDownloadState) {
|
||||
installState.value = true;
|
||||
const builds = remoteData.assets;
|
||||
const fileName = getInstaller(getExtension(), builds);
|
||||
result = fileName ? await getArtifact(fileName[1], fileName[0], currentOS.value, true) : false;
|
||||
result = fileName ? await initUpdateLauncher(fileName[1], fileName[0], currentOS.value, true) : false;
|
||||
installState.value = false;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,9 +10,20 @@ export async function getOS() {
|
||||
return await invoke('plugin:utils|get_os')
|
||||
}
|
||||
|
||||
export async function getArtifact(downloadurl, filename, ostype, autoupdatesupported) {
|
||||
console.log('Downloading build', downloadurl, filename, ostype, autoupdatesupported)
|
||||
return await invoke('plugin:utils|get_artifact', { downloadurl, filename, ostype, autoupdatesupported })
|
||||
// [AR] Feature. Updater
|
||||
export async function initUpdateLauncher(downloadUrl, filename, osType, autoUpdateSupported) {
|
||||
console.log('Downloading build', downloadUrl, filename, osType, autoUpdateSupported)
|
||||
return await invoke('plugin:utils|init_update_launcher', { downloadUrl, filename, osType, autoUpdateSupported })
|
||||
}
|
||||
|
||||
// [AR] Migration. Patch
|
||||
export async function applyMigrationFix(eol) {
|
||||
return await invoke('plugin:utils|apply_migration_fix', { eol })
|
||||
}
|
||||
|
||||
// [AR] Feature. Ely.by
|
||||
export async function initAuthlibPatching(minecraftVersion, isMojang) {
|
||||
return await invoke('plugin:utils|init_authlib_patching', { minecraftVersion, isMojang })
|
||||
}
|
||||
|
||||
export async function openPath(path) {
|
||||
|
||||
@@ -377,6 +377,12 @@
|
||||
"instance.worlds.hardcore": {
|
||||
"message": "Hardcore mode"
|
||||
},
|
||||
"instance.worlds.incompatible_server": {
|
||||
"message": "Server is incompatible"
|
||||
},
|
||||
"instance.worlds.no_contact": {
|
||||
"message": "Server couldn't be contacted"
|
||||
},
|
||||
"instance.worlds.no_quick_play": {
|
||||
"message": "You can only jump straight into worlds on Minecraft 1.20+"
|
||||
},
|
||||
|
||||
@@ -220,7 +220,7 @@ async function refreshSearch() {
|
||||
}
|
||||
}
|
||||
results.value = rawResults.result
|
||||
currentPage.value = Math.max(1, Math.min(pageCount.value, currentPage.value))
|
||||
currentPage.value = 1
|
||||
|
||||
const persistentParams: LocationQuery = {}
|
||||
|
||||
@@ -266,6 +266,7 @@ async function onSearchChangeToTop() {
|
||||
|
||||
function clearSearch() {
|
||||
query.value = ''
|
||||
currentPage.value = 1
|
||||
}
|
||||
|
||||
watch(
|
||||
|
||||
@@ -15,7 +15,7 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
|
||||
println!("A browser window will now open, follow the login flow there.");
|
||||
let login = minecraft_auth::begin_login().await?;
|
||||
|
||||
println!("Open URL {} in a browser", login.redirect_uri.as_str());
|
||||
println!("Open URL {} in a browser", login.auth_request_uri.as_str());
|
||||
|
||||
println!("Please enter URL code: ");
|
||||
let mut input = String::new();
|
||||
|
||||
@@ -31,6 +31,8 @@ thiserror.workspace = true
|
||||
daedalus.workspace = true
|
||||
chrono.workspace = true
|
||||
either.workspace = true
|
||||
hyper = { workspace = true, features = ["server"] }
|
||||
hyper-util.workspace = true
|
||||
|
||||
url.workspace = true
|
||||
urlencoding.workspace = true
|
||||
|
||||
@@ -13,6 +13,8 @@ fn main() {
|
||||
InlinedPlugin::new()
|
||||
.commands(&[
|
||||
"offline_login",
|
||||
"elyby_login",
|
||||
"elyby_auth_authenticate",
|
||||
"login",
|
||||
"remove_user",
|
||||
"get_default_user",
|
||||
@@ -49,6 +51,8 @@ fn main() {
|
||||
"import",
|
||||
InlinedPlugin::new()
|
||||
.commands(&[
|
||||
"fetch_curseforge_profile_metadata",
|
||||
"import_curseforge_profile",
|
||||
"get_importable_instances",
|
||||
"import_instance",
|
||||
"is_valid_importable_instance",
|
||||
@@ -121,7 +125,12 @@ fn main() {
|
||||
.plugin(
|
||||
"mr-auth",
|
||||
InlinedPlugin::new()
|
||||
.commands(&["modrinth_login", "logout", "get"])
|
||||
.commands(&[
|
||||
"modrinth_login",
|
||||
"logout",
|
||||
"get",
|
||||
"cancel_modrinth_login",
|
||||
])
|
||||
.default_permission(
|
||||
DefaultPermissionRule::AllowAllCommands,
|
||||
),
|
||||
@@ -218,7 +227,9 @@ fn main() {
|
||||
"utils",
|
||||
InlinedPlugin::new()
|
||||
.commands(&[
|
||||
"get_artifact",
|
||||
"init_authlib_patching",
|
||||
"apply_migration_fix",
|
||||
"init_update_launcher",
|
||||
"get_os",
|
||||
"should_disable_mouseover",
|
||||
"highlight_in_folder",
|
||||
|
||||
@@ -19,13 +19,10 @@
|
||||
"window-state:default",
|
||||
"window-state:allow-restore-state",
|
||||
"window-state:allow-save-window-state",
|
||||
|
||||
|
||||
{
|
||||
"identifier": "http:default",
|
||||
"allow": [
|
||||
{ "url": "https://modrinth.com/*" },
|
||||
{ "url": "https://*.modrinth.com/*" }
|
||||
]
|
||||
"allow": [{ "url": "https://modrinth.com/*" }, { "url": "https://*.modrinth.com/*" }]
|
||||
},
|
||||
|
||||
"auth:default",
|
||||
|
||||
@@ -2,12 +2,15 @@ use crate::api::Result;
|
||||
use chrono::{Duration, Utc};
|
||||
use tauri::plugin::TauriPlugin;
|
||||
use tauri::{Manager, Runtime, UserAttentionType};
|
||||
use tauri_plugin_http::reqwest::Client;
|
||||
use theseus::prelude::*;
|
||||
|
||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
tauri::plugin::Builder::<R>::new("auth")
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
offline_login,
|
||||
elyby_login,
|
||||
elyby_auth_authenticate,
|
||||
login,
|
||||
remove_user,
|
||||
get_default_user,
|
||||
@@ -17,14 +20,65 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
.build()
|
||||
}
|
||||
|
||||
/// ### AR • Feature
|
||||
/// Create new offline user
|
||||
/// This is custom function from Astralium Org.
|
||||
#[tauri::command]
|
||||
pub async fn offline_login(name: &str) -> Result<Credentials> {
|
||||
let credentials = minecraft_auth::offline_auth(name).await?;
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
/// ### AR • Feature
|
||||
/// Create new Ely.by user
|
||||
#[tauri::command]
|
||||
pub async fn elyby_login(
|
||||
uuid: uuid::Uuid,
|
||||
login: &str,
|
||||
access_token: &str
|
||||
) -> Result<Credentials> {
|
||||
let credentials = minecraft_auth::elyby_auth(uuid, login, access_token).await?;
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
/// ### AR • Feature
|
||||
/// Authenticate Ely.by user
|
||||
#[tauri::command]
|
||||
pub async fn elyby_auth_authenticate(
|
||||
login: &str,
|
||||
password: &str,
|
||||
client_token: &str,
|
||||
) -> Result<String> {
|
||||
let client = Client::new();
|
||||
let auth_body = serde_json::json!({
|
||||
"username": login,
|
||||
"password": password,
|
||||
"clientToken": client_token,
|
||||
});
|
||||
|
||||
let response = match client
|
||||
.post("https://authserver.ely.by/auth/authenticate")
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&auth_body)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
tracing::error!("[AR] • Failed to send request: {}", e);
|
||||
return Ok("".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
let text = match response.text().await {
|
||||
Ok(body) => body,
|
||||
Err(e) => {
|
||||
tracing::error!("[AR] • Failed to read response text: {}", e);
|
||||
return Ok("".to_string());
|
||||
}
|
||||
};
|
||||
Ok(text)
|
||||
}
|
||||
|
||||
/// Authenticate a user with Hydra - part 1
|
||||
/// This begins the authentication flow quasi-synchronously, returning a URL to visit (that the user will sign in at)
|
||||
#[tauri::command]
|
||||
@@ -42,7 +96,7 @@ pub async fn login<R: Runtime>(
|
||||
let window = tauri::WebviewWindowBuilder::new(
|
||||
&app,
|
||||
"signin",
|
||||
tauri::WebviewUrl::External(flow.redirect_uri.parse().map_err(
|
||||
tauri::WebviewUrl::External(flow.auth_request_uri.parse().map_err(
|
||||
|_| {
|
||||
theseus::ErrorKind::OtherError(
|
||||
"Error parsing auth redirect URL".to_string(),
|
||||
@@ -86,6 +140,7 @@ pub async fn login<R: Runtime>(
|
||||
window.close()?;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_user(user: uuid::Uuid) -> Result<()> {
|
||||
Ok(minecraft_auth::remove_user(user).await?)
|
||||
|
||||
@@ -2,6 +2,11 @@ use std::path::PathBuf;
|
||||
|
||||
use crate::api::Result;
|
||||
use theseus::pack::import::ImportLauncherType;
|
||||
use theseus::pack::import::curseforge_profile::{
|
||||
CurseForgeProfileMetadata,
|
||||
fetch_curseforge_profile_metadata as fetch_cf_metadata,
|
||||
import_curseforge_profile as import_cf_profile,
|
||||
};
|
||||
|
||||
use theseus::pack::import;
|
||||
|
||||
@@ -12,6 +17,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
import_instance,
|
||||
is_valid_importable_instance,
|
||||
get_default_launcher_path,
|
||||
fetch_curseforge_profile_metadata,
|
||||
import_curseforge_profile,
|
||||
])
|
||||
.build()
|
||||
}
|
||||
@@ -68,3 +75,24 @@ pub async fn get_default_launcher_path(
|
||||
) -> Result<Option<PathBuf>> {
|
||||
Ok(import::get_default_launcher_path(launcher_type))
|
||||
}
|
||||
|
||||
/// Fetch CurseForge profile metadata from profile code
|
||||
/// eg: fetch_curseforge_profile_metadata("eSrNlKNo")
|
||||
#[tauri::command]
|
||||
pub async fn fetch_curseforge_profile_metadata(
|
||||
profile_code: String,
|
||||
) -> Result<CurseForgeProfileMetadata> {
|
||||
Ok(fetch_cf_metadata(&profile_code).await?)
|
||||
}
|
||||
|
||||
/// Import a CurseForge profile from profile code
|
||||
/// profile_path should be a blank profile for this purpose- if the function fails, it will be deleted
|
||||
/// eg: import_curseforge_profile("profile-path", "eSrNlKNo")
|
||||
#[tauri::command]
|
||||
pub async fn import_curseforge_profile(
|
||||
profile_path: String,
|
||||
profile_code: String,
|
||||
) -> Result<()> {
|
||||
import_cf_profile(&profile_code, &profile_path).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ pub mod cache;
|
||||
pub mod friends;
|
||||
pub mod worlds;
|
||||
|
||||
mod oauth_utils;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, TheseusSerializableError>;
|
||||
|
||||
// // Main returnable Theseus GUI error
|
||||
|
||||
@@ -1,79 +1,70 @@
|
||||
use crate::api::Result;
|
||||
use chrono::{Duration, Utc};
|
||||
use crate::api::TheseusSerializableError;
|
||||
use crate::api::oauth_utils;
|
||||
use tauri::Manager;
|
||||
use tauri::Runtime;
|
||||
use tauri::plugin::TauriPlugin;
|
||||
use tauri::{Manager, Runtime, UserAttentionType};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use theseus::prelude::*;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("mr-auth")
|
||||
.invoke_handler(tauri::generate_handler![modrinth_login, logout, get,])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
modrinth_login,
|
||||
logout,
|
||||
get,
|
||||
cancel_modrinth_login,
|
||||
])
|
||||
.build()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn modrinth_login<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
) -> Result<Option<ModrinthCredentials>> {
|
||||
let redirect_uri = mr_auth::authenticate_begin_flow();
|
||||
) -> Result<ModrinthCredentials> {
|
||||
let (auth_code_recv_socket_tx, auth_code_recv_socket) = oneshot::channel();
|
||||
let auth_code = tokio::spawn(oauth_utils::auth_code_reply::listen(
|
||||
auth_code_recv_socket_tx,
|
||||
));
|
||||
|
||||
let start = Utc::now();
|
||||
let auth_code_recv_socket = auth_code_recv_socket.await.unwrap()?;
|
||||
|
||||
if let Some(window) = app.get_webview_window("modrinth-signin") {
|
||||
window.close()?;
|
||||
}
|
||||
let auth_request_uri = format!(
|
||||
"{}?launcher=true&ipver={}&port={}",
|
||||
mr_auth::authenticate_begin_flow(),
|
||||
if auth_code_recv_socket.is_ipv4() {
|
||||
"4"
|
||||
} else {
|
||||
"6"
|
||||
},
|
||||
auth_code_recv_socket.port()
|
||||
);
|
||||
|
||||
let window = tauri::WebviewWindowBuilder::new(
|
||||
&app,
|
||||
"modrinth-signin",
|
||||
tauri::WebviewUrl::External(redirect_uri.parse().map_err(|_| {
|
||||
theseus::ErrorKind::OtherError(
|
||||
"Error parsing auth redirect URL".to_string(),
|
||||
app.opener()
|
||||
.open_url(auth_request_uri, None::<&str>)
|
||||
.map_err(|e| {
|
||||
TheseusSerializableError::Theseus(
|
||||
theseus::ErrorKind::OtherError(format!(
|
||||
"Failed to open auth request URI: {e}"
|
||||
))
|
||||
.into(),
|
||||
)
|
||||
.as_error()
|
||||
})?),
|
||||
)
|
||||
.min_inner_size(420.0, 632.0)
|
||||
.inner_size(420.0, 632.0)
|
||||
.max_inner_size(420.0, 632.0)
|
||||
.zoom_hotkeys_enabled(false)
|
||||
.title("Sign into Modrinth")
|
||||
.always_on_top(true)
|
||||
.center()
|
||||
.build()?;
|
||||
})?;
|
||||
|
||||
window.request_user_attention(Some(UserAttentionType::Critical))?;
|
||||
let Some(auth_code) = auth_code.await.unwrap()? else {
|
||||
return Err(TheseusSerializableError::Theseus(
|
||||
theseus::ErrorKind::OtherError("Login canceled".into()).into(),
|
||||
));
|
||||
};
|
||||
|
||||
while (Utc::now() - start) < Duration::minutes(10) {
|
||||
if window.title().is_err() {
|
||||
// user closed window, cancelling flow
|
||||
return Ok(None);
|
||||
}
|
||||
let credentials = mr_auth::authenticate_finish_flow(&auth_code).await?;
|
||||
|
||||
if window
|
||||
.url()?
|
||||
.as_str()
|
||||
.starts_with("https://launcher-files.modrinth.com")
|
||||
{
|
||||
let url = window.url()?;
|
||||
|
||||
let code = url.query_pairs().find(|(key, _)| key == "code");
|
||||
|
||||
window.close()?;
|
||||
|
||||
return if let Some((_, code)) = code {
|
||||
let val = mr_auth::authenticate_finish_flow(&code).await?;
|
||||
|
||||
Ok(Some(val))
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
if let Some(main_window) = app.get_window("main") {
|
||||
main_window.set_focus().ok();
|
||||
}
|
||||
|
||||
window.close()?;
|
||||
Ok(None)
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -85,3 +76,8 @@ pub async fn logout() -> Result<()> {
|
||||
pub async fn get() -> Result<Option<ModrinthCredentials>> {
|
||||
Ok(theseus::mr_auth::get_credentials().await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn cancel_modrinth_login() {
|
||||
oauth_utils::auth_code_reply::stop_listeners();
|
||||
}
|
||||
|
||||
159
apps/app/src/api/oauth_utils/auth_code_reply.rs
Normal file
159
apps/app/src/api/oauth_utils/auth_code_reply.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
//! A minimal OAuth 2.0 authorization code grant flow redirection/reply loopback URI HTTP
|
||||
//! server implementation, compliant with [RFC 6749]'s authorization code grant flow and
|
||||
//! [RFC 8252]'s best current practices for OAuth 2.0 in native apps.
|
||||
//!
|
||||
//! This server is needed for the step 4 of the OAuth authentication dance represented in
|
||||
//! figure 1 of [RFC 8252].
|
||||
//!
|
||||
//! Further reading: https://www.oauth.com/oauth2-servers/oauth-native-apps/redirect-urls-for-native-apps/
|
||||
//!
|
||||
//! [RFC 6749]: https://datatracker.ietf.org/doc/html/rfc6749
|
||||
//! [RFC 8252]: https://datatracker.ietf.org/doc/html/rfc8252
|
||||
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
sync::{LazyLock, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use hyper::body::Incoming;
|
||||
use hyper_util::rt::{TokioIo, TokioTimer};
|
||||
use theseus::ErrorKind;
|
||||
use tokio::{
|
||||
net::TcpListener,
|
||||
sync::{broadcast, oneshot},
|
||||
};
|
||||
|
||||
static SERVER_SHUTDOWN: LazyLock<broadcast::Sender<()>> =
|
||||
LazyLock::new(|| broadcast::channel(1024).0);
|
||||
|
||||
/// Starts a temporary HTTP server to receive OAuth 2.0 authorization code grant flow redirects
|
||||
/// on a loopback interface with an ephemeral port. The caller can know the bound socket address
|
||||
/// by listening on the counterpart channel for `listen_socket_tx`.
|
||||
///
|
||||
/// If the server is stopped before receiving an authorization code, `Ok(None)` is returned.
|
||||
pub async fn listen(
|
||||
listen_socket_tx: oneshot::Sender<Result<SocketAddr, theseus::Error>>,
|
||||
) -> Result<Option<String>, theseus::Error> {
|
||||
// IPv4 is tried first for the best compatibility and performance with most systems.
|
||||
// IPv6 is also tried in case IPv4 is not available. Resolving "localhost" is avoided
|
||||
// to prevent failures deriving from improper name resolution setup. Any available
|
||||
// ephemeral port is used to prevent conflicts with other services. This is all as per
|
||||
// RFC 8252's recommendations
|
||||
const ANY_LOOPBACK_SOCKET: &[SocketAddr] = &[
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
|
||||
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0),
|
||||
];
|
||||
|
||||
let listener = match TcpListener::bind(ANY_LOOPBACK_SOCKET).await {
|
||||
Ok(listener) => {
|
||||
listen_socket_tx
|
||||
.send(listener.local_addr().map_err(|e| {
|
||||
ErrorKind::OtherError(format!(
|
||||
"Failed to get auth code reply socket address: {e}"
|
||||
))
|
||||
.into()
|
||||
}))
|
||||
.ok();
|
||||
|
||||
listener
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg =
|
||||
format!("Failed to bind auth code reply socket: {e}");
|
||||
|
||||
listen_socket_tx
|
||||
.send(Err(ErrorKind::OtherError(error_msg.clone()).into()))
|
||||
.ok();
|
||||
|
||||
return Err(ErrorKind::OtherError(error_msg).into());
|
||||
}
|
||||
};
|
||||
|
||||
let mut auth_code = Mutex::new(None);
|
||||
let mut shutdown_notification = SERVER_SHUTDOWN.subscribe();
|
||||
|
||||
while auth_code.get_mut().unwrap().is_none() {
|
||||
let client_socket = tokio::select! {
|
||||
biased;
|
||||
_ = shutdown_notification.recv() => {
|
||||
break;
|
||||
}
|
||||
conn_accept_result = listener.accept() => {
|
||||
match conn_accept_result {
|
||||
Ok((socket, _)) => socket,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to accept auth code reply: {e}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = hyper::server::conn::http1::Builder::new()
|
||||
.keep_alive(false)
|
||||
.header_read_timeout(Duration::from_secs(5))
|
||||
.timer(TokioTimer::new())
|
||||
.auto_date_header(false)
|
||||
.serve_connection(
|
||||
TokioIo::new(client_socket),
|
||||
hyper::service::service_fn(|req| handle_reply(req, &auth_code)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to handle auth code reply: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(auth_code.into_inner().unwrap())
|
||||
}
|
||||
|
||||
/// Stops any active OAuth 2.0 authorization code grant flow reply listening HTTP servers.
|
||||
pub fn stop_listeners() {
|
||||
SERVER_SHUTDOWN.send(()).ok();
|
||||
}
|
||||
|
||||
async fn handle_reply(
|
||||
req: hyper::Request<Incoming>,
|
||||
auth_code_out: &Mutex<Option<String>>,
|
||||
) -> Result<hyper::Response<String>, hyper::http::Error> {
|
||||
if req.method() != hyper::Method::GET {
|
||||
return hyper::Response::builder()
|
||||
.status(hyper::StatusCode::METHOD_NOT_ALLOWED)
|
||||
.header("Allow", "GET")
|
||||
.body("".into());
|
||||
}
|
||||
|
||||
// The authorization code is guaranteed to be sent as a "code" query parameter
|
||||
// in the request URI query string as per RFC 6749 § 4.1.2
|
||||
let auth_code = req.uri().query().and_then(|query_string| {
|
||||
query_string
|
||||
.split('&')
|
||||
.filter_map(|query_pair| query_pair.split_once('='))
|
||||
.find_map(|(key, value)| (key == "code").then_some(value))
|
||||
});
|
||||
|
||||
let response = if let Some(auth_code) = auth_code {
|
||||
*auth_code_out.lock().unwrap() = Some(auth_code.to_string());
|
||||
|
||||
hyper::Response::builder()
|
||||
.status(hyper::StatusCode::OK)
|
||||
.header("Content-Type", "text/html;charset=utf-8")
|
||||
.body(
|
||||
include_str!("auth_code_reply/page.html")
|
||||
.replace("{{title}}", "Success")
|
||||
.replace("{{message}}", "You have successfully signed in! You can close this page now."),
|
||||
)
|
||||
} else {
|
||||
hyper::Response::builder()
|
||||
.status(hyper::StatusCode::BAD_REQUEST)
|
||||
.header("Content-Type", "text/html;charset=utf-8")
|
||||
.body(
|
||||
include_str!("auth_code_reply/page.html")
|
||||
.replace("{{title}}", "Error")
|
||||
.replace("{{message}}", "Authorization code not found. Please try signing in again."),
|
||||
)
|
||||
}?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
1
apps/app/src/api/oauth_utils/auth_code_reply/page.html
Normal file
1
apps/app/src/api/oauth_utils/auth_code_reply/page.html
Normal file
File diff suppressed because one or more lines are too long
3
apps/app/src/api/oauth_utils/mod.rs
Normal file
3
apps/app/src/api/oauth_utils/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
//! Assorted utilities for OAuth 2.0 authorization flows.
|
||||
|
||||
pub mod auth_code_reply;
|
||||
@@ -10,12 +10,15 @@ use crate::api::{Result, TheseusSerializableError};
|
||||
use dashmap::DashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use theseus::prelude::canonicalize;
|
||||
use theseus::util::utils;
|
||||
use url::Url;
|
||||
|
||||
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("utils")
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
get_artifact,
|
||||
init_authlib_patching,
|
||||
apply_migration_fix,
|
||||
init_update_launcher,
|
||||
get_os,
|
||||
should_disable_mouseover,
|
||||
highlight_in_folder,
|
||||
@@ -27,9 +30,39 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
||||
.build()
|
||||
}
|
||||
|
||||
/// [AR] Feature. Ely.by
|
||||
#[tauri::command]
|
||||
pub async fn get_artifact(downloadurl: &str, filename: &str, ostype: &str, autoupdatesupported: bool) -> Result<()> {
|
||||
theseus::download::init_download(downloadurl, filename, ostype, autoupdatesupported).await;
|
||||
pub async fn init_authlib_patching(
|
||||
minecraft_version: &str,
|
||||
is_mojang: bool,
|
||||
) -> Result<bool> {
|
||||
let result =
|
||||
utils::init_authlib_patching(minecraft_version, is_mojang).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// [AR] Migration. Patch
|
||||
#[tauri::command]
|
||||
pub async fn apply_migration_fix(eol: &str) -> Result<bool> {
|
||||
let result = utils::apply_migration_fix(eol).await?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// [AR] Feature. Updater
|
||||
#[tauri::command]
|
||||
pub async fn init_update_launcher(
|
||||
download_url: &str,
|
||||
filename: &str,
|
||||
os_type: &str,
|
||||
auto_update_supported: bool,
|
||||
) -> Result<()> {
|
||||
let _ = utils::init_update_launcher(
|
||||
download_url,
|
||||
filename,
|
||||
os_type,
|
||||
auto_update_supported,
|
||||
)
|
||||
.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ fn main() {
|
||||
*/
|
||||
let _log_guard = theseus::start_logger();
|
||||
|
||||
tracing::info!("Initialized tracing subscriber. Loading Modrinth App!");
|
||||
tracing::info!("Initialized tracing subscriber. Loading AstralRinth App!");
|
||||
|
||||
let mut builder = tauri::Builder::default();
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
]
|
||||
},
|
||||
"productName": "AstralRinth App",
|
||||
"version": "0.10.302",
|
||||
"version": "0.10.305",
|
||||
"mainBinaryName": "AstralRinth App",
|
||||
"identifier": "AstralRinthApp",
|
||||
"plugins": {
|
||||
@@ -63,6 +63,7 @@
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"title": "AstralRinth",
|
||||
"label": "main",
|
||||
"width": 1280,
|
||||
"minHeight": 700,
|
||||
"minWidth": 1100,
|
||||
@@ -86,7 +87,7 @@
|
||||
"capabilities": ["core", "plugins"],
|
||||
"csp": {
|
||||
"default-src": "'self' customprotocol: asset:",
|
||||
"connect-src": "ipc: https://git.astralium.su http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs 'self' data: blob:",
|
||||
"connect-src": "ipc: https://git.astralium.su https://authserver.ely.by http://ipc.localhost https://modrinth.com https://*.modrinth.com https://*.posthog.com https://*.sentry.io https://api.mclo.gs 'self' data: blob:",
|
||||
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
|
||||
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
|
||||
"style-src": "'unsafe-inline' 'self'",
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM rust:1.88.0 AS build
|
||||
|
||||
WORKDIR /usr/src/daedalus
|
||||
COPY . .
|
||||
RUN cargo build --release --package daedalus_client
|
||||
RUN --mount=type=cache,target=/usr/src/daedalus/target \
|
||||
--mount=type=cache,target=/usr/local/cargo/git/db \
|
||||
--mount=type=cache,target=/usr/local/cargo/registry \
|
||||
cargo build --release --package daedalus_client
|
||||
|
||||
FROM build AS artifacts
|
||||
|
||||
RUN --mount=type=cache,target=/usr/src/daedalus/target \
|
||||
mkdir /daedalus \
|
||||
&& cp /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
@@ -11,7 +21,7 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
|
||||
WORKDIR /daedalus_client
|
||||
COPY --from=artifacts /daedalus /daedalus
|
||||
|
||||
CMD /daedalus/daedalus_client
|
||||
WORKDIR /daedalus_client
|
||||
CMD ["/daedalus/daedalus_client"]
|
||||
|
||||
@@ -19,8 +19,6 @@ From there, you can create the database and perform all database migrations with
|
||||
sqlx database setup
|
||||
```
|
||||
|
||||
Finally, if on Linux, you will need the OpenSSL library. On Debian-based systems, this involves the `pkg-config` and `libssl-dev` packages.
|
||||
|
||||
To enable labrinth to create a project, you need to add two things.
|
||||
|
||||
1. An entry in the `loaders` table.
|
||||
|
||||
@@ -38,9 +38,10 @@
|
||||
"@intercom/messenger-js-sdk": "^0.0.14",
|
||||
"@ltd/j-toml": "^1.38.0",
|
||||
"@modrinth/assets": "workspace:*",
|
||||
"@modrinth/blog": "workspace:*",
|
||||
"@modrinth/moderation": "workspace:*",
|
||||
"@modrinth/ui": "workspace:*",
|
||||
"@modrinth/utils": "workspace:*",
|
||||
"@modrinth/blog": "workspace:*",
|
||||
"@pinia/nuxt": "^0.5.1",
|
||||
"@types/three": "^0.172.0",
|
||||
"@vintl/vintl": "^4.4.1",
|
||||
@@ -58,6 +59,7 @@
|
||||
"markdown-it": "14.1.0",
|
||||
"pathe": "^1.1.2",
|
||||
"pinia": "^2.1.7",
|
||||
"prettier": "^3.6.2",
|
||||
"qrcode.vue": "^3.4.0",
|
||||
"semver": "^7.5.4",
|
||||
"three": "^0.172.0",
|
||||
|
||||
@@ -197,13 +197,13 @@
|
||||
}
|
||||
|
||||
> :where(
|
||||
input + *,
|
||||
.input-group + *,
|
||||
.textarea-wrapper + *,
|
||||
.chips + *,
|
||||
.resizable-textarea-wrapper + *,
|
||||
.input-div + *
|
||||
) {
|
||||
input + *,
|
||||
.input-group + *,
|
||||
.textarea-wrapper + *,
|
||||
.chips + *,
|
||||
.resizable-textarea-wrapper + *,
|
||||
.input-div + *
|
||||
) {
|
||||
&:not(:empty) {
|
||||
margin-block-start: var(--spacing-card-md);
|
||||
}
|
||||
|
||||
@@ -115,10 +115,12 @@ html {
|
||||
--shadow-inset-sm: inset 0px -1px 2px hsla(221, 39%, 11%, 0.15);
|
||||
|
||||
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
|
||||
--shadow-raised: 0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
|
||||
--shadow-raised:
|
||||
0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
|
||||
1px 2px 2.2px -1.7px hsl(var(--shadow-color) / 0.12),
|
||||
4.4px 8.8px 9.7px -3.4px hsl(var(--shadow-color) / 0.09);
|
||||
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
||||
--shadow-floating:
|
||||
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
||||
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, hsla(0, 0%, 0%, 0.1) 0px 2px 4px -1px;
|
||||
|
||||
--shadow-card: rgba(50, 50, 100, 0.1) 0px 2px 4px 0px;
|
||||
@@ -150,8 +152,8 @@ html {
|
||||
rgba(255, 255, 255, 0.35) 0%,
|
||||
rgba(255, 255, 255, 0.2695) 100%
|
||||
);
|
||||
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16),
|
||||
inset 2px 2px 64px rgba(255, 255, 255, 0.45);
|
||||
--landing-blob-shadow:
|
||||
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(255, 255, 255, 0.45);
|
||||
|
||||
--landing-card-bg: rgba(255, 255, 255, 0.8);
|
||||
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
|
||||
@@ -251,13 +253,15 @@ html {
|
||||
|
||||
--shadow-raised-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
|
||||
--shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1);
|
||||
--shadow-floating: hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
||||
--shadow-floating:
|
||||
hsla(0, 0%, 0%, 0) 0px 0px 0px 0px, hsla(0, 0%, 0%, 0) 0px 0px 0px 0px,
|
||||
hsla(0, 0%, 0%, 0.1) 0px 4px 6px -1px, rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
|
||||
|
||||
--shadow-card: rgba(0, 0, 0, 0.25) 0px 2px 4px 0px;
|
||||
|
||||
--landing-maze-bg: url("https://cdn.modrinth.com/landing-new/landing.webp");
|
||||
--landing-maze-gradient-bg: linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
|
||||
--landing-maze-gradient-bg:
|
||||
linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
|
||||
url("https://cdn.modrinth.com/landing-new/landing-lower.webp");
|
||||
--landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%);
|
||||
|
||||
@@ -284,7 +288,8 @@ html {
|
||||
rgba(44, 48, 79, 0.35) 0%,
|
||||
rgba(32, 35, 50, 0.2695) 100%
|
||||
);
|
||||
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
|
||||
--landing-blob-shadow:
|
||||
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
|
||||
|
||||
--landing-card-bg: rgba(59, 63, 85, 0.15);
|
||||
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
|
||||
@@ -360,8 +365,9 @@ body {
|
||||
// Defaults
|
||||
background-color: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
--font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
|
||||
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
--font-standard:
|
||||
Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell,
|
||||
Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
||||
--mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
|
||||
font-family: var(--font-standard);
|
||||
font-size: 16px;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
<template>
|
||||
<div
|
||||
class="vue-notification-group experimental-styles-within"
|
||||
:class="{ 'intercom-present': isIntercomPresent }"
|
||||
:class="{
|
||||
'intercom-present': isIntercomPresent,
|
||||
rightwards: moveNotificationsRight,
|
||||
}"
|
||||
>
|
||||
<transition-group name="notifs">
|
||||
<div
|
||||
@@ -82,6 +85,7 @@ import {
|
||||
CopyIcon,
|
||||
} from "@modrinth/assets";
|
||||
const notifications = useNotifications();
|
||||
const { isVisible: moveNotificationsRight } = useNotificationRightwards();
|
||||
|
||||
const isIntercomPresent = ref(false);
|
||||
|
||||
@@ -160,6 +164,15 @@ function copyToClipboard(notif) {
|
||||
bottom: 5rem;
|
||||
}
|
||||
|
||||
&.rightwards {
|
||||
right: unset !important;
|
||||
left: 1.5rem;
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
left: 0.75rem;
|
||||
}
|
||||
}
|
||||
|
||||
.vue-notification-wrapper {
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
<template>
|
||||
<NewModal ref="modal" header="Moderation shortcuts" :closable="true">
|
||||
<div>
|
||||
<div class="keybinds-sections">
|
||||
<div class="grid grid-cols-2 gap-x-12 gap-y-3">
|
||||
<div
|
||||
v-for="keybind in keybinds"
|
||||
:key="keybind.id"
|
||||
class="keybind-item flex items-center justify-between gap-4"
|
||||
:class="{
|
||||
'col-span-2': keybinds.length % 2 === 1 && keybinds[keybinds.length - 1] === keybind,
|
||||
}"
|
||||
>
|
||||
<span class="text-sm text-secondary">{{ keybind.description }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<kbd
|
||||
v-for="(key, index) in parseKeybindDisplay(keybind.keybind)"
|
||||
:key="`${keybind.id}-key-${index}`"
|
||||
class="keybind-key"
|
||||
>
|
||||
{{ key }}
|
||||
</kbd>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue";
|
||||
import NewModal from "@modrinth/ui/src/components/modal/NewModal.vue";
|
||||
import { keybinds, type KeybindListener, normalizeKeybind } from "@modrinth/moderation";
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>();
|
||||
|
||||
function parseKeybindDisplay(keybind: KeybindListener["keybind"]): string[] {
|
||||
const keybinds = Array.isArray(keybind) ? keybind : [keybind];
|
||||
const normalized = keybinds[0];
|
||||
const def = normalizeKeybind(normalized);
|
||||
|
||||
const keys = [];
|
||||
|
||||
if (def.ctrl || def.meta) {
|
||||
keys.push(isMac() ? "CMD" : "CTRL");
|
||||
}
|
||||
if (def.shift) keys.push("SHIFT");
|
||||
if (def.alt) keys.push("ALT");
|
||||
|
||||
const mainKey = def.key
|
||||
.replace("ArrowLeft", "←")
|
||||
.replace("ArrowRight", "→")
|
||||
.replace("ArrowUp", "↑")
|
||||
.replace("ArrowDown", "↓")
|
||||
.replace("Enter", "↵")
|
||||
.replace("Space", "SPACE")
|
||||
.replace("Escape", "ESC")
|
||||
.toUpperCase();
|
||||
|
||||
keys.push(mainKey);
|
||||
|
||||
return keys;
|
||||
}
|
||||
|
||||
function isMac() {
|
||||
return navigator.platform.toUpperCase().indexOf("MAC") >= 0;
|
||||
}
|
||||
|
||||
function show(event?: MouseEvent) {
|
||||
modal.value?.show(event);
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value?.hide();
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
show,
|
||||
hide,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.keybind-key {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--color-bg);
|
||||
border: 1px solid var(--color-divider);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: var(--color-contrast);
|
||||
|
||||
+ .keybind-key {
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.keybind-item {
|
||||
min-height: 2rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.keybinds-sections {
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,513 @@
|
||||
<template>
|
||||
<div>
|
||||
<h2 v-if="modPackData" class="m-0 mb-2 text-lg font-extrabold">
|
||||
Modpack permissions ({{ Math.min(modPackData.length, currentIndex + 1) }} /
|
||||
{{ modPackData.length }})
|
||||
</h2>
|
||||
|
||||
<div v-if="!modPackData">Loading data...</div>
|
||||
|
||||
<div v-else-if="modPackData.length === 0">
|
||||
<p>All permissions already obtained.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!modPackData[currentIndex]">
|
||||
<p>All permission checks complete!</p>
|
||||
</div>
|
||||
|
||||
<div v-else>
|
||||
<div v-if="modPackData[currentIndex].type === 'unknown'">
|
||||
<p>What is the approval type of {{ modPackData[currentIndex].file_name }}?</p>
|
||||
<div class="input-group">
|
||||
<ButtonStyled
|
||||
v-for="(option, index) in fileApprovalTypes"
|
||||
:key="index"
|
||||
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
|
||||
@click="setStatus(currentIndex, option.id)"
|
||||
>
|
||||
<button>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
<div v-if="modPackData[currentIndex].status !== 'unidentified'" class="flex flex-col gap-1">
|
||||
<label for="proof">
|
||||
<span class="label__title">Proof</span>
|
||||
</label>
|
||||
<input
|
||||
id="proof"
|
||||
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).proof"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter proof of status..."
|
||||
@input="persistAll()"
|
||||
/>
|
||||
<label for="link">
|
||||
<span class="label__title">Link</span>
|
||||
</label>
|
||||
<input
|
||||
id="link"
|
||||
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).url"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter link of project..."
|
||||
@input="persistAll()"
|
||||
/>
|
||||
<label for="title">
|
||||
<span class="label__title">Title</span>
|
||||
</label>
|
||||
<input
|
||||
id="title"
|
||||
v-model="(modPackData[currentIndex] as ModerationUnknownModpackItem).title"
|
||||
type="text"
|
||||
autocomplete="off"
|
||||
placeholder="Enter title of project..."
|
||||
@input="persistAll()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="modPackData[currentIndex].type === 'flame'">
|
||||
<p>
|
||||
What is the approval type of {{ modPackData[currentIndex].title }} (<a
|
||||
:href="modPackData[currentIndex].url"
|
||||
target="_blank"
|
||||
class="text-link"
|
||||
>{{ modPackData[currentIndex].url }}</a
|
||||
>)?
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<ButtonStyled
|
||||
v-for="(option, index) in fileApprovalTypes"
|
||||
:key="index"
|
||||
:color="modPackData[currentIndex].status === option.id ? 'brand' : 'standard'"
|
||||
@click="setStatus(currentIndex, option.id)"
|
||||
>
|
||||
<button>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
['unidentified', 'no', 'with-attribution'].includes(
|
||||
modPackData[currentIndex].status || '',
|
||||
)
|
||||
"
|
||||
>
|
||||
<p v-if="modPackData[currentIndex].status === 'unidentified'">
|
||||
Does this project provide identification and permission for
|
||||
<strong>{{ modPackData[currentIndex].file_name }}</strong
|
||||
>?
|
||||
</p>
|
||||
<p v-else-if="modPackData[currentIndex].status === 'with-attribution'">
|
||||
Does this project provide attribution for
|
||||
<strong>{{ modPackData[currentIndex].file_name }}</strong
|
||||
>?
|
||||
</p>
|
||||
<p v-else>
|
||||
Does this project provide proof of permission for
|
||||
<strong>{{ modPackData[currentIndex].file_name }}</strong
|
||||
>?
|
||||
</p>
|
||||
<div class="input-group">
|
||||
<ButtonStyled
|
||||
v-for="(option, index) in filePermissionTypes"
|
||||
:key="index"
|
||||
:color="modPackData[currentIndex].approved === option.id ? 'brand' : 'standard'"
|
||||
@click="setApproval(currentIndex, option.id)"
|
||||
>
|
||||
<button>
|
||||
{{ option.name }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 flex gap-2">
|
||||
<ButtonStyled>
|
||||
<button :disabled="currentIndex <= 0" @click="goToPrevious">
|
||||
<LeftArrowIcon aria-hidden="true" />
|
||||
Previous
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="modPackData && currentIndex < modPackData.length" color="blue">
|
||||
<button :disabled="!canGoNext" @click="goToNext">
|
||||
<RightArrowIcon aria-hidden="true" />
|
||||
{{ currentIndex + 1 >= modPackData.length ? "Complete" : "Next" }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { LeftArrowIcon, RightArrowIcon } from "@modrinth/assets";
|
||||
import type {
|
||||
ModerationJudgements,
|
||||
ModerationModpackItem,
|
||||
ModerationModpackResponse,
|
||||
ModerationUnknownModpackItem,
|
||||
ModerationFlameModpackItem,
|
||||
ModerationModpackPermissionApprovalType,
|
||||
ModerationPermissionType,
|
||||
} from "@modrinth/utils";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string;
|
||||
modelValue?: ModerationJudgements;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
complete: [];
|
||||
"update:modelValue": [judgements: ModerationJudgements];
|
||||
}>();
|
||||
|
||||
const persistedModPackData = useLocalStorage<ModerationModpackItem[] | null>(
|
||||
`modpack-permissions-${props.projectId}`,
|
||||
null,
|
||||
{
|
||||
serializer: {
|
||||
read: (v: any) => (v ? JSON.parse(v) : null),
|
||||
write: (v: any) => JSON.stringify(v),
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const persistedIndex = useLocalStorage<number>(`modpack-permissions-index-${props.projectId}`, 0);
|
||||
|
||||
const modPackData = useSessionStorage<ModerationModpackItem[] | null>(
|
||||
`modpack-permissions-data-${props.projectId}`,
|
||||
null,
|
||||
{
|
||||
serializer: {
|
||||
read: (v: any) => (v ? JSON.parse(v) : null),
|
||||
write: (v: any) => JSON.stringify(v),
|
||||
},
|
||||
},
|
||||
);
|
||||
const permanentNoFiles = useSessionStorage<ModerationModpackItem[]>(
|
||||
`modpack-permissions-permanent-no-${props.projectId}`,
|
||||
[],
|
||||
{
|
||||
serializer: {
|
||||
read: (v: any) => (v ? JSON.parse(v) : []),
|
||||
write: (v: any) => JSON.stringify(v),
|
||||
},
|
||||
},
|
||||
);
|
||||
const currentIndex = ref(0);
|
||||
|
||||
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
|
||||
{
|
||||
id: "yes",
|
||||
name: "Yes",
|
||||
},
|
||||
{
|
||||
id: "with-attribution-and-source",
|
||||
name: "With attribution and source",
|
||||
},
|
||||
{
|
||||
id: "with-attribution",
|
||||
name: "With attribution",
|
||||
},
|
||||
{
|
||||
id: "no",
|
||||
name: "No",
|
||||
},
|
||||
{
|
||||
id: "permanent-no",
|
||||
name: "Permanent no",
|
||||
},
|
||||
{
|
||||
id: "unidentified",
|
||||
name: "Unidentified",
|
||||
},
|
||||
];
|
||||
|
||||
const filePermissionTypes: ModerationPermissionType[] = [
|
||||
{ id: "yes", name: "Yes" },
|
||||
{ id: "no", name: "No" },
|
||||
];
|
||||
|
||||
function persistAll() {
|
||||
persistedModPackData.value = modPackData.value;
|
||||
persistedIndex.value = currentIndex.value;
|
||||
}
|
||||
|
||||
watch(
|
||||
modPackData,
|
||||
(newValue) => {
|
||||
persistedModPackData.value = newValue;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
watch(currentIndex, (newValue) => {
|
||||
persistedIndex.value = newValue;
|
||||
});
|
||||
|
||||
function loadPersistedData(): void {
|
||||
if (persistedModPackData.value) {
|
||||
modPackData.value = persistedModPackData.value;
|
||||
}
|
||||
currentIndex.value = persistedIndex.value;
|
||||
}
|
||||
|
||||
function clearPersistedData(): void {
|
||||
persistedModPackData.value = null;
|
||||
persistedIndex.value = 0;
|
||||
}
|
||||
|
||||
async function fetchModPackData(): Promise<void> {
|
||||
try {
|
||||
const data = (await useBaseFetch(`moderation/project/${props.projectId}`, {
|
||||
internal: true,
|
||||
})) as ModerationModpackResponse;
|
||||
|
||||
const permanentNoItems: ModerationModpackItem[] = Object.entries(data.identified || {})
|
||||
.filter(([_, file]) => file.status === "permanent-no")
|
||||
.map(
|
||||
([sha1, file]): ModerationModpackItem => ({
|
||||
sha1,
|
||||
file_name: file.file_name,
|
||||
type: "identified",
|
||||
status: file.status,
|
||||
approved: null,
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => a.file_name.localeCompare(b.file_name));
|
||||
|
||||
permanentNoFiles.value = permanentNoItems;
|
||||
|
||||
const sortedData: ModerationModpackItem[] = [
|
||||
...Object.entries(data.identified || {})
|
||||
.filter(
|
||||
([_, file]) =>
|
||||
file.status !== "yes" &&
|
||||
file.status !== "with-attribution-and-source" &&
|
||||
file.status !== "permanent-no",
|
||||
)
|
||||
.map(
|
||||
([sha1, file]): ModerationModpackItem => ({
|
||||
sha1,
|
||||
file_name: file.file_name,
|
||||
type: "identified",
|
||||
status: file.status,
|
||||
approved: null,
|
||||
...(file.status === "unidentified" && {
|
||||
proof: "",
|
||||
url: "",
|
||||
title: "",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
|
||||
...Object.entries(data.unknown_files || {})
|
||||
.map(
|
||||
([sha1, fileName]): ModerationUnknownModpackItem => ({
|
||||
sha1,
|
||||
file_name: fileName,
|
||||
type: "unknown",
|
||||
status: null,
|
||||
approved: null,
|
||||
proof: "",
|
||||
url: "",
|
||||
title: "",
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
|
||||
...Object.entries(data.flame_files || {})
|
||||
.map(
|
||||
([sha1, info]): ModerationFlameModpackItem => ({
|
||||
sha1,
|
||||
file_name: info.file_name,
|
||||
type: "flame",
|
||||
status: null,
|
||||
approved: null,
|
||||
id: info.id,
|
||||
title: info.title || info.file_name,
|
||||
url: info.url || `https://www.curseforge.com/minecraft/mc-mods/${info.id}`,
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
|
||||
];
|
||||
|
||||
if (modPackData.value) {
|
||||
const existingMap = new Map(modPackData.value.map((item) => [item.sha1, item]));
|
||||
|
||||
sortedData.forEach((item) => {
|
||||
const existing = existingMap.get(item.sha1);
|
||||
if (existing) {
|
||||
Object.assign(item, {
|
||||
status: existing.status,
|
||||
approved: existing.approved,
|
||||
...(item.type === "unknown" && {
|
||||
proof: (existing as ModerationUnknownModpackItem).proof || "",
|
||||
url: (existing as ModerationUnknownModpackItem).url || "",
|
||||
title: (existing as ModerationUnknownModpackItem).title || "",
|
||||
}),
|
||||
...(item.type === "flame" && {
|
||||
url: (existing as ModerationFlameModpackItem).url || item.url,
|
||||
title: (existing as ModerationFlameModpackItem).title || item.title,
|
||||
}),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
modPackData.value = sortedData;
|
||||
persistAll();
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch modpack data:", error);
|
||||
modPackData.value = [];
|
||||
permanentNoFiles.value = [];
|
||||
persistAll();
|
||||
}
|
||||
}
|
||||
|
||||
function goToPrevious(): void {
|
||||
if (currentIndex.value > 0) {
|
||||
currentIndex.value--;
|
||||
persistAll();
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
modPackData,
|
||||
(newValue) => {
|
||||
persistedModPackData.value = newValue;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
function goToNext(): void {
|
||||
if (modPackData.value && currentIndex.value < modPackData.value.length) {
|
||||
currentIndex.value++;
|
||||
|
||||
if (currentIndex.value >= modPackData.value.length) {
|
||||
const judgements = getJudgements();
|
||||
emit("update:modelValue", judgements);
|
||||
emit("complete");
|
||||
clearPersistedData();
|
||||
} else {
|
||||
persistAll();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(index: number, status: ModerationModpackPermissionApprovalType["id"]): void {
|
||||
if (modPackData.value && modPackData.value[index]) {
|
||||
modPackData.value[index].status = status;
|
||||
modPackData.value[index].approved = null;
|
||||
persistAll();
|
||||
emit("update:modelValue", getJudgements());
|
||||
}
|
||||
}
|
||||
|
||||
function setApproval(index: number, approved: ModerationPermissionType["id"]): void {
|
||||
if (modPackData.value && modPackData.value[index]) {
|
||||
modPackData.value[index].approved = approved;
|
||||
persistAll();
|
||||
emit("update:modelValue", getJudgements());
|
||||
}
|
||||
}
|
||||
|
||||
const canGoNext = computed(() => {
|
||||
if (!modPackData.value || !modPackData.value[currentIndex.value]) return false;
|
||||
const current = modPackData.value[currentIndex.value];
|
||||
return current.status !== null;
|
||||
});
|
||||
|
||||
function getJudgements(): ModerationJudgements {
|
||||
if (!modPackData.value) return {};
|
||||
|
||||
const judgements: ModerationJudgements = {};
|
||||
|
||||
modPackData.value.forEach((item) => {
|
||||
if (item.type === "flame") {
|
||||
judgements[item.sha1] = {
|
||||
type: "flame",
|
||||
id: item.id,
|
||||
status: item.status,
|
||||
link: item.url,
|
||||
title: item.title,
|
||||
file_name: item.file_name,
|
||||
};
|
||||
} else if (item.type === "unknown") {
|
||||
judgements[item.sha1] = {
|
||||
type: "unknown",
|
||||
status: item.status,
|
||||
proof: item.proof,
|
||||
link: item.url,
|
||||
title: item.title,
|
||||
file_name: item.file_name,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
return judgements;
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadPersistedData();
|
||||
if (!modPackData.value) {
|
||||
fetchModPackData();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
modPackData,
|
||||
(newValue) => {
|
||||
if (newValue && newValue.length === 0) {
|
||||
emit("complete");
|
||||
clearPersistedData();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.projectId,
|
||||
() => {
|
||||
clearPersistedData();
|
||||
loadPersistedData();
|
||||
if (!modPackData.value) {
|
||||
fetchModPackData();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function getModpackFiles(): {
|
||||
interactive: ModerationModpackItem[];
|
||||
permanentNo: ModerationModpackItem[];
|
||||
} {
|
||||
return {
|
||||
interactive: modPackData.value || [],
|
||||
permanentNo: permanentNoFiles.value,
|
||||
};
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getModpackFiles,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.modpack-buttons {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -172,6 +172,7 @@ const flags = useFeatureFlags();
|
||||
|
||||
.markdown-body {
|
||||
grid-area: body;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.reporter-info {
|
||||
|
||||
@@ -31,9 +31,9 @@
|
||||
class="flex cursor-pointer items-center gap-1 bg-transparent p-0"
|
||||
@click="
|
||||
versionFilter &&
|
||||
(unlockFilterAccordion.isOpen
|
||||
? unlockFilterAccordion.close()
|
||||
: unlockFilterAccordion.open())
|
||||
(unlockFilterAccordion.isOpen
|
||||
? unlockFilterAccordion.close()
|
||||
: unlockFilterAccordion.open())
|
||||
"
|
||||
>
|
||||
<TagItem
|
||||
|
||||
@@ -194,13 +194,12 @@ export class ModrinthServer {
|
||||
}
|
||||
|
||||
async testNodeReachability(): Promise<boolean> {
|
||||
if (!this.general?.datacenter) {
|
||||
console.warn("No datacenter info available for ping test");
|
||||
if (!this.general?.node?.instance) {
|
||||
console.warn("No node instance available for ping test");
|
||||
return false;
|
||||
}
|
||||
|
||||
const datacenter = this.general.datacenter;
|
||||
const wsUrl = `wss://${datacenter}.nodes.modrinth.com/pingtest`;
|
||||
const wsUrl = `wss://${this.general.node.instance}/pingtest`;
|
||||
|
||||
try {
|
||||
return await new Promise((resolve) => {
|
||||
|
||||
@@ -112,7 +112,8 @@ export async function useServersFetch<T>(
|
||||
const response = await $fetch<T>(fullUrl, {
|
||||
method,
|
||||
headers,
|
||||
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
|
||||
body:
|
||||
body && contentType === "application/json" ? JSON.stringify(body) : (body ?? undefined),
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
|
||||
12
apps/frontend/src/composables/util.ts
Normal file
12
apps/frontend/src/composables/util.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const useNotificationRightwards = () => {
|
||||
const isVisible = useState("moderation-checklist-notifications", () => false);
|
||||
|
||||
const setVisible = (visible: boolean) => {
|
||||
isVisible.value = visible;
|
||||
};
|
||||
|
||||
return {
|
||||
isVisible: readonly(isVisible),
|
||||
setVisible,
|
||||
};
|
||||
};
|
||||
@@ -700,7 +700,6 @@ import {
|
||||
PackageOpenIcon,
|
||||
DiscordIcon,
|
||||
BlueskyIcon,
|
||||
TumblrIcon,
|
||||
TwitterIcon,
|
||||
MastodonIcon,
|
||||
GithubIcon,
|
||||
@@ -1185,13 +1184,6 @@ const socialLinks = [
|
||||
icon: MastodonIcon,
|
||||
rel: "me",
|
||||
},
|
||||
{
|
||||
label: formatMessage(
|
||||
defineMessage({ id: "layout.footer.social.tumblr", defaultMessage: "Tumblr" }),
|
||||
),
|
||||
href: "https://tumblr.com/modrinth",
|
||||
icon: TumblrIcon,
|
||||
},
|
||||
{
|
||||
label: formatMessage(defineMessage({ id: "layout.footer.social.x", defaultMessage: "X" })),
|
||||
href: "https://x.com/modrinth",
|
||||
@@ -1346,6 +1338,15 @@ const footerLinks = [
|
||||
}),
|
||||
),
|
||||
},
|
||||
{
|
||||
href: "/legal/copyright",
|
||||
label: formatMessage(
|
||||
defineMessage({
|
||||
id: "layout.footer.legal.copyright-policy",
|
||||
defaultMessage: "Copyright Policy and DMCA",
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -383,15 +383,15 @@
|
||||
"layout.footer.about": {
|
||||
"message": "About"
|
||||
},
|
||||
"layout.footer.about.news": {
|
||||
"message": "News"
|
||||
},
|
||||
"layout.footer.about.careers": {
|
||||
"message": "Careers"
|
||||
},
|
||||
"layout.footer.about.changelog": {
|
||||
"message": "Changelog"
|
||||
},
|
||||
"layout.footer.about.news": {
|
||||
"message": "News"
|
||||
},
|
||||
"layout.footer.about.rewards-program": {
|
||||
"message": "Rewards Program"
|
||||
},
|
||||
@@ -404,6 +404,9 @@
|
||||
"layout.footer.legal-disclaimer": {
|
||||
"message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT."
|
||||
},
|
||||
"layout.footer.legal.copyright-policy": {
|
||||
"message": "Copyright Policy and DMCA"
|
||||
},
|
||||
"layout.footer.legal.privacy-policy": {
|
||||
"message": "Privacy Policy"
|
||||
},
|
||||
@@ -458,9 +461,6 @@
|
||||
"layout.footer.social.mastodon": {
|
||||
"message": "Mastodon"
|
||||
},
|
||||
"layout.footer.social.tumblr": {
|
||||
"message": "Tumblr"
|
||||
},
|
||||
"layout.footer.social.x": {
|
||||
"message": "X"
|
||||
},
|
||||
|
||||
@@ -29,12 +29,11 @@
|
||||
class="settings-header__icon"
|
||||
/>
|
||||
<div class="settings-header__text">
|
||||
<h1 class="wrap-as-needed">
|
||||
{{ project.title }}
|
||||
</h1>
|
||||
<h1 class="wrap-as-needed">{{ project.title }}</h1>
|
||||
<ProjectStatusBadge :status="project.status" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Project settings</h2>
|
||||
<NavStack>
|
||||
<NavStackItem
|
||||
@@ -111,6 +110,7 @@
|
||||
</NavStack>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<div class="normal-page__content">
|
||||
<ProjectMemberHeader
|
||||
v-if="currentMember"
|
||||
@@ -145,6 +145,7 @@
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="experimental-styles-within">
|
||||
<NewModal ref="settingsModal">
|
||||
<template #title>
|
||||
@@ -174,9 +175,11 @@
|
||||
<div
|
||||
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
|
||||
></div>
|
||||
|
||||
<div
|
||||
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
|
||||
>
|
||||
@@ -219,8 +222,7 @@
|
||||
:href="`modrinth://mod/${project.slug}`"
|
||||
@click="() => installWithApp()"
|
||||
>
|
||||
<ModrinthIcon aria-hidden="true" />
|
||||
Install with Modrinth App
|
||||
<ModrinthIcon aria-hidden="true" /> Install with Modrinth App
|
||||
<ExternalIcon aria-hidden="true" />
|
||||
</a>
|
||||
</ButtonStyled>
|
||||
@@ -240,6 +242,7 @@
|
||||
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto flex w-fit flex-col gap-2">
|
||||
<ButtonStyled v-if="project.game_versions.length === 1">
|
||||
<div class="disabled button-like">
|
||||
@@ -327,8 +330,7 @@
|
||||
}
|
||||
"
|
||||
>
|
||||
{{ gameVersion }}
|
||||
<CheckIcon v-if="userSelectedGameVersion === gameVersion" />
|
||||
{{ gameVersion }} <CheckIcon v-if="userSelectedGameVersion === gameVersion" />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</ScrollablePanel>
|
||||
@@ -419,7 +421,6 @@
|
||||
</ScrollablePanel>
|
||||
</Accordion>
|
||||
</div>
|
||||
|
||||
<AutomaticAccordion div class="flex flex-col gap-2">
|
||||
<VersionSummary
|
||||
v-if="filteredRelease"
|
||||
@@ -470,10 +471,14 @@
|
||||
class="new-page sidebar"
|
||||
:class="{
|
||||
'alt-layout': cosmetics.leftContentLayout,
|
||||
'ultimate-sidebar':
|
||||
'checklist-open':
|
||||
showModerationChecklist &&
|
||||
!collapsedModerationChecklist &&
|
||||
!flags.alwaysShowChecklistAsPopup,
|
||||
'checklist-collapsed':
|
||||
showModerationChecklist &&
|
||||
collapsedModerationChecklist &&
|
||||
!flags.alwaysShowChecklistAsPopup,
|
||||
}"
|
||||
>
|
||||
<div class="normal-page__header relative my-4">
|
||||
@@ -485,11 +490,11 @@
|
||||
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
|
||||
>
|
||||
<button @click="(event) => downloadModal.show(event)">
|
||||
<DownloadIcon aria-hidden="true" />
|
||||
Download
|
||||
<DownloadIcon aria-hidden="true" /> Download
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<div class="contents sm:hidden">
|
||||
<ButtonStyled
|
||||
size="large"
|
||||
@@ -554,9 +559,11 @@
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
|
||||
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
|
||||
Modrinth Servers is the easiest way to play with your friends without hassle!
|
||||
</p>
|
||||
|
||||
<p class="m-0 text-wrap text-sm font-bold text-primary">
|
||||
Starting at $5<span class="text-xs"> / month</span>
|
||||
</p>
|
||||
@@ -621,6 +628,7 @@
|
||||
{{ option.name }}
|
||||
</Checkbox>
|
||||
</div>
|
||||
|
||||
<div v-else class="menu-text">
|
||||
<p class="popout-text">No collections found.</p>
|
||||
</div>
|
||||
@@ -628,8 +636,7 @@
|
||||
class="btn collection-button"
|
||||
@click="(event) => $refs.modal_collection.show(event)"
|
||||
>
|
||||
<PlusIcon aria-hidden="true" />
|
||||
Create new collection
|
||||
<PlusIcon aria-hidden="true" /> Create new collection
|
||||
</button>
|
||||
</template>
|
||||
</PopoutMenu>
|
||||
@@ -712,25 +719,14 @@
|
||||
:dropdown-id="`${baseId}-more-options`"
|
||||
>
|
||||
<MoreVerticalIcon aria-hidden="true" />
|
||||
<template #analytics>
|
||||
<ChartIcon aria-hidden="true" />
|
||||
Analytics
|
||||
</template>
|
||||
<template #analytics> <ChartIcon aria-hidden="true" /> Analytics </template>
|
||||
<template #moderation-checklist>
|
||||
<ScaleIcon aria-hidden="true" />
|
||||
Review project
|
||||
</template>
|
||||
<template #report>
|
||||
<ReportIcon aria-hidden="true" />
|
||||
Report
|
||||
</template>
|
||||
<template #copy-id>
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
Copy ID
|
||||
<ScaleIcon aria-hidden="true" /> Review project
|
||||
</template>
|
||||
<template #report> <ReportIcon aria-hidden="true" /> Report </template>
|
||||
<template #copy-id> <ClipboardCopyIcon aria-hidden="true" /> Copy ID </template>
|
||||
<template #copy-permalink>
|
||||
<ClipboardCopyIcon aria-hidden="true" />
|
||||
Copy permanent link
|
||||
<ClipboardCopyIcon aria-hidden="true" /> Copy permanent link
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
@@ -756,6 +752,7 @@
|
||||
updates unless the author decides to unarchive the project.
|
||||
</MessageBanner>
|
||||
</div>
|
||||
|
||||
<div class="normal-page__sidebar">
|
||||
<ProjectSidebarCompatibility
|
||||
:project="project"
|
||||
@@ -785,6 +782,7 @@
|
||||
/>
|
||||
<div class="card flex-card experimental-styles-within">
|
||||
<h2>{{ formatMessage(detailsMessages.title) }}</h2>
|
||||
|
||||
<div class="details-list">
|
||||
<div class="details-list__item">
|
||||
<BookTextIcon aria-hidden="true" />
|
||||
@@ -813,53 +811,48 @@
|
||||
<span v-else>{{ licenseIdDisplay }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="project.approved"
|
||||
v-tooltip="$dayjs(project.approved).format('MMMM D, YYYY [at] h:mm A')"
|
||||
class="details-list__item"
|
||||
>
|
||||
<CalendarIcon aria-hidden="true" />
|
||||
<div>
|
||||
{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}
|
||||
</div>
|
||||
<div>{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
v-tooltip="$dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')"
|
||||
class="details-list__item"
|
||||
>
|
||||
<CalendarIcon aria-hidden="true" />
|
||||
<div>
|
||||
{{ formatMessage(detailsMessages.created, { date: createdDate }) }}
|
||||
</div>
|
||||
<div>{{ formatMessage(detailsMessages.created, { date: createdDate }) }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="project.status === 'processing' && project.queued"
|
||||
v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')"
|
||||
class="details-list__item"
|
||||
>
|
||||
<ScaleIcon aria-hidden="true" />
|
||||
<div>
|
||||
{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}
|
||||
</div>
|
||||
<div>{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="versions.length > 0 && project.updated"
|
||||
v-tooltip="$dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')"
|
||||
class="details-list__item"
|
||||
>
|
||||
<VersionIcon aria-hidden="true" />
|
||||
<div>
|
||||
{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}
|
||||
</div>
|
||||
<div>{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="normal-page__content">
|
||||
<div class="overflow-x-auto">
|
||||
<NavTabs :links="navLinks" class="mb-4" />
|
||||
</div>
|
||||
<div class="overflow-x-auto"><NavTabs :links="navLinks" class="mb-4" /></div>
|
||||
<NuxtPage
|
||||
v-model:project="project"
|
||||
v-model:versions="versions"
|
||||
@@ -877,8 +870,10 @@
|
||||
@delete-version="deleteVersion"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="normal-page__ultimate-sidebar">
|
||||
<ModerationChecklist
|
||||
<!-- Uncomment this to enable the old moderation checklist. -->
|
||||
<!-- <ModerationChecklist
|
||||
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
||||
:project="project"
|
||||
:future-projects="futureProjects"
|
||||
@@ -886,11 +881,25 @@
|
||||
:collapsed="collapsedModerationChecklist"
|
||||
@exit="showModerationChecklist = false"
|
||||
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
||||
/>
|
||||
/> -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
||||
class="moderation-checklist"
|
||||
>
|
||||
<NewModerationChecklist
|
||||
:project="project"
|
||||
:future-project-ids="futureProjectIds"
|
||||
:collapsed="collapsedModerationChecklist"
|
||||
@exit="showModerationChecklist = false"
|
||||
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import {
|
||||
BookmarkIcon,
|
||||
@@ -950,16 +959,16 @@ import {
|
||||
isUnderReview,
|
||||
renderString,
|
||||
} from "@modrinth/utils";
|
||||
import { navigateTo } from "#app";
|
||||
import dayjs from "dayjs";
|
||||
import { Tooltip } from "floating-vue";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import { navigateTo } from "#app";
|
||||
import Accordion from "~/components/ui/Accordion.vue";
|
||||
// import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
||||
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
|
||||
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
||||
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
||||
import MessageBanner from "~/components/ui/MessageBanner.vue";
|
||||
import ModerationChecklist from "~/components/ui/ModerationChecklist.vue";
|
||||
import NavStack from "~/components/ui/NavStack.vue";
|
||||
import NavStackItem from "~/components/ui/NavStackItem.vue";
|
||||
import NavTabs from "~/components/ui/NavTabs.vue";
|
||||
@@ -967,6 +976,7 @@ import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
|
||||
import { userCollectProject } from "~/composables/user.js";
|
||||
import { reportProject } from "~/utils/report-helpers.ts";
|
||||
import { saveFeatureFlags } from "~/composables/featureFlags.ts";
|
||||
import NewModerationChecklist from "~/components/ui/moderation/NewModerationChecklist.vue";
|
||||
|
||||
const data = useNuxtApp();
|
||||
const route = useNativeRoute();
|
||||
@@ -980,6 +990,7 @@ const flags = useFeatureFlags();
|
||||
const cosmetics = useCosmetics();
|
||||
|
||||
const { formatMessage } = useVIntl();
|
||||
const { setVisible } = useNotificationRightwards();
|
||||
|
||||
const settingsModal = ref();
|
||||
const downloadModal = ref();
|
||||
@@ -1551,12 +1562,28 @@ async function copyPermalink() {
|
||||
|
||||
const collapsedChecklist = ref(false);
|
||||
|
||||
const showModerationChecklist = ref(false);
|
||||
const collapsedModerationChecklist = ref(false);
|
||||
const futureProjects = ref([]);
|
||||
const showModerationChecklist = useLocalStorage(
|
||||
`show-moderation-checklist-${project.value.id}`,
|
||||
false,
|
||||
);
|
||||
const collapsedModerationChecklist = useLocalStorage("collapsed-moderation-checklist", false);
|
||||
|
||||
const futureProjectIds = useLocalStorage("moderation-future-projects", []);
|
||||
|
||||
watch(futureProjectIds, (newValue) => {
|
||||
console.log("Future project IDs updated:", newValue);
|
||||
});
|
||||
|
||||
watch(
|
||||
showModerationChecklist,
|
||||
(newValue) => {
|
||||
setVisible(newValue);
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
if (import.meta.client && history && history.state && history.state.showChecklist) {
|
||||
showModerationChecklist.value = true;
|
||||
futureProjects.value = history.state.projects;
|
||||
}
|
||||
|
||||
function closeDownloadModal(event) {
|
||||
@@ -1626,6 +1653,7 @@ const navLinks = computed(() => {
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.settings-header {
|
||||
display: flex;
|
||||
@@ -1781,4 +1809,16 @@ const navLinks = computed(() => {
|
||||
left: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.moderation-checklist {
|
||||
position: fixed;
|
||||
bottom: 1rem;
|
||||
right: 1rem;
|
||||
overflow-y: auto;
|
||||
z-index: 50;
|
||||
|
||||
> div {
|
||||
box-shadow: 0 0 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -705,9 +705,9 @@ export default defineNuxtComponent({
|
||||
}
|
||||
|
||||
.gallery-body {
|
||||
flex-grow: 1;
|
||||
width: calc(100% - 2 * var(--spacing-card-md));
|
||||
padding: var(--spacing-card-sm) var(--spacing-card-md);
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
.gallery-info {
|
||||
h2 {
|
||||
|
||||
@@ -150,9 +150,26 @@
|
||||
</template>
|
||||
</span>
|
||||
<span class="text-sm text-secondary">
|
||||
<span
|
||||
v-if="charge.status === 'cancelled' && $dayjs(charge.due).isBefore($dayjs())"
|
||||
class="font-bold"
|
||||
>
|
||||
Ended:
|
||||
</span>
|
||||
<span v-else-if="charge.status === 'cancelled'" class="font-bold">Ends:</span>
|
||||
<span v-else-if="charge.type === 'refund'" class="font-bold">Issued:</span>
|
||||
<span v-else class="font-bold">Due:</span>
|
||||
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
|
||||
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
|
||||
</span>
|
||||
<span v-if="charge.last_attempt != null" class="text-sm text-secondary">
|
||||
<span v-if="charge.status === 'failed'" class="font-bold">Last attempt:</span>
|
||||
<span v-else class="font-bold">Charged:</span>
|
||||
{{ dayjs(charge.last_attempt).format("MMMM D, YYYY [at] h:mma") }}
|
||||
<span class="text-secondary"
|
||||
>({{ formatRelativeTime(charge.last_attempt) }})
|
||||
</span>
|
||||
</span>
|
||||
<div class="flex w-full items-center gap-1 text-xs text-secondary">
|
||||
{{ charge.status }}
|
||||
⋅
|
||||
|
||||
@@ -1421,7 +1421,8 @@ useSeoMeta({
|
||||
width: 25rem;
|
||||
height: 25rem;
|
||||
opacity: 0.75;
|
||||
background: radial-gradient(
|
||||
background:
|
||||
radial-gradient(
|
||||
50% 50% at 50% 50%,
|
||||
rgba(5, 206, 69, 0.19) 0%,
|
||||
rgba(15, 19, 49, 0.25) 100%
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<template>
|
||||
<div>
|
||||
<template v-if="flow">
|
||||
<div v-if="subtleLauncherRedirectUri">
|
||||
<iframe
|
||||
:src="subtleLauncherRedirectUri"
|
||||
class="fixed left-0 top-0 z-[9999] m-0 h-full w-full border-0 p-0"
|
||||
></iframe>
|
||||
</div>
|
||||
<div v-else>
|
||||
<template v-if="flow && !subtleLauncherRedirectUri">
|
||||
<label for="two-factor-code">
|
||||
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
|
||||
<span class="label__description">
|
||||
@@ -189,6 +195,7 @@ const auth = await useAuth();
|
||||
const route = useNativeRoute();
|
||||
|
||||
const redirectTarget = route.query.redirect || "";
|
||||
const subtleLauncherRedirectUri = ref();
|
||||
|
||||
if (route.query.code && !route.fullPath.includes("new_account=true")) {
|
||||
await finishSignIn();
|
||||
@@ -262,7 +269,32 @@ async function begin2FASignIn() {
|
||||
|
||||
async function finishSignIn(token) {
|
||||
if (route.query.launcher) {
|
||||
await navigateTo(`https://launcher-files.modrinth.com/?code=${token}`, { external: true });
|
||||
if (!token) {
|
||||
token = auth.value.token;
|
||||
}
|
||||
|
||||
const usesLocalhostRedirectionScheme =
|
||||
["4", "6"].includes(route.query.ipver) && Number(route.query.port) < 65536;
|
||||
|
||||
const redirectUrl = usesLocalhostRedirectionScheme
|
||||
? `http://${route.query.ipver === "4" ? "127.0.0.1" : "[::1]"}:${route.query.port}/?code=${token}`
|
||||
: `https://launcher-files.modrinth.com/?code=${token}`;
|
||||
|
||||
if (usesLocalhostRedirectionScheme) {
|
||||
// When using this redirection scheme, the auth token is very visible in the URL to the user.
|
||||
// While we could make it harder to find with a POST request, such is security by obscurity:
|
||||
// the user and other applications would still be able to sniff the token in the request body.
|
||||
// So, to make the UX a little better by not changing the displayed URL, while keeping the
|
||||
// token hidden from very casual observation and keeping the protocol as close to OAuth's
|
||||
// standard flows as possible, let's execute the redirect within an iframe that visually
|
||||
// covers the entire page.
|
||||
subtleLauncherRedirectUri.value = redirectUrl;
|
||||
} else {
|
||||
await navigateTo(redirectUrl, {
|
||||
external: true,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -247,16 +247,14 @@ async function createAccount() {
|
||||
},
|
||||
});
|
||||
|
||||
if (route.query.launcher) {
|
||||
await navigateTo(`https://launcher-files.modrinth.com/?code=${res.session}`, {
|
||||
external: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await useAuth(res.session);
|
||||
await useUser();
|
||||
|
||||
if (route.query.launcher) {
|
||||
await navigateTo({ path: "/auth/sign-in", query: route.query });
|
||||
return;
|
||||
}
|
||||
|
||||
if (route.query.redirect) {
|
||||
await navigateTo(route.query.redirect);
|
||||
} else {
|
||||
|
||||
@@ -266,12 +266,12 @@ const getRangeOfMethod = (method) => {
|
||||
|
||||
const maxWithdrawAmount = computed(() => {
|
||||
const interval = selectedMethod.value.interval;
|
||||
return interval?.standard ? interval.standard.max : interval?.fixed?.values.slice(-1)[0] ?? 0;
|
||||
return interval?.standard ? interval.standard.max : (interval?.fixed?.values.slice(-1)[0] ?? 0);
|
||||
});
|
||||
|
||||
const minWithdrawAmount = computed(() => {
|
||||
const interval = selectedMethod.value.interval;
|
||||
return interval?.standard ? interval.standard.min : interval?.fixed?.values?.[0] ?? fees.value;
|
||||
return interval?.standard ? interval.standard.min : (interval?.fixed?.values?.[0] ?? fees.value);
|
||||
});
|
||||
|
||||
const withdrawAccount = computed(() => {
|
||||
|
||||
@@ -212,6 +212,10 @@ if (projects.value) {
|
||||
|
||||
async function goToProjects() {
|
||||
const project = projectsFiltered.value[0];
|
||||
const remainingProjectIds = projectsFiltered.value.slice(1).map((p) => p.id);
|
||||
|
||||
localStorage.setItem("moderation-future-projects", JSON.stringify(remainingProjectIds));
|
||||
|
||||
await router.push({
|
||||
name: "type-id",
|
||||
params: {
|
||||
@@ -220,7 +224,6 @@ async function goToProjects() {
|
||||
},
|
||||
state: {
|
||||
showChecklist: true,
|
||||
projects: projectsFiltered.value.slice(1).map((x) => (x.slug ? x.slug : x.id)),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,7 +149,8 @@ onMounted(() => {
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.main-hero {
|
||||
background: linear-gradient(360deg, rgba(199, 138, 255, 0.2) 10.92%, var(--color-bg) 100%),
|
||||
background:
|
||||
linear-gradient(360deg, rgba(199, 138, 255, 0.2) 10.92%, var(--color-bg) 100%),
|
||||
var(--color-accent-contrast);
|
||||
margin-top: -5rem;
|
||||
padding: 11.25rem 1rem 8rem;
|
||||
|
||||
@@ -45,8 +45,9 @@
|
||||
<h2
|
||||
class="relative m-0 max-w-2xl text-base font-normal leading-[155%] text-secondary md:text-[1.2rem]"
|
||||
>
|
||||
Modrinth Servers is the easiest way to host your own Minecraft server. Seamlessly install
|
||||
and play your favorite mods and modpacks, all within the Modrinth platform.
|
||||
Modrinth Servers is the easiest way to host your own Minecraft: Java Edition server.
|
||||
Seamlessly install and play your favorite mods and modpacks, all within the Modrinth
|
||||
platform.
|
||||
</h2>
|
||||
<div class="relative flex w-full flex-wrap items-center gap-8 align-middle sm:w-fit">
|
||||
<div
|
||||
@@ -427,11 +428,8 @@
|
||||
Do Modrinth Servers have DDoS protection?
|
||||
</summary>
|
||||
<p class="m-0 ml-6 leading-[160%]">
|
||||
Yes. All Modrinth Servers come with DDoS protection powered by
|
||||
<a href="https://us.ovhcloud.com/security/anti-ddos/" target="_blank"
|
||||
>OVHcloud® Anti-DDoS infrastructure</a
|
||||
>
|
||||
which has over 17Tbps capacity. Your server is safe on Modrinth.
|
||||
Yes. All Modrinth Servers come with DDoS protection, with up to 17Tbps capacity in
|
||||
some locations.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -443,8 +441,9 @@
|
||||
Where are Modrinth Servers located? Can I choose a region?
|
||||
</summary>
|
||||
<p class="m-0 ml-6 leading-[160%]">
|
||||
We have servers in both North America in Vint Hill, Virginia, and Europe in Limburg,
|
||||
Germany. More regions to come in the future!
|
||||
We have servers available in North America and Europe at the moment that you can
|
||||
choose upon purchase. More regions to come in the future! If you'd like to switch
|
||||
your region, please contact support.
|
||||
</p>
|
||||
</details>
|
||||
|
||||
@@ -461,7 +460,7 @@
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details pyro-hash="players" class="group" :open="$route.hash === '#players'">
|
||||
<details pyro-hash="performance" class="group" :open="$route.hash === '#performance'">
|
||||
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
|
||||
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||
<RightArrowIcon />
|
||||
@@ -482,7 +481,7 @@
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details pyro-hash="players" class="group" :open="$route.hash === '#prices'">
|
||||
<details pyro-hash="prices" class="group" :open="$route.hash === '#prices'">
|
||||
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
|
||||
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||
<RightArrowIcon />
|
||||
@@ -493,6 +492,24 @@
|
||||
All prices are listed in United States Dollars (USD).
|
||||
</p>
|
||||
</details>
|
||||
|
||||
<details pyro-hash="versions" class="group" :open="$route.hash === '#versions'">
|
||||
<summary class="flex cursor-pointer items-center py-3 font-medium text-contrast">
|
||||
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||
<RightArrowIcon />
|
||||
</span>
|
||||
What Minecraft versions and loaders can be used?
|
||||
</summary>
|
||||
<p class="m-0 ml-6 leading-[160%]">
|
||||
Modrinth Servers can run any version of Minecraft: Java Edition going all the way
|
||||
back to version 1.2.5, including snapshot versions.
|
||||
</p>
|
||||
<p class="m-0 ml-6 mt-3 leading-[160%]">
|
||||
We also support a wide range of mod and plugin loaders, including Fabric, Quilt,
|
||||
Forge, and NeoForge for mods, as well as Paper and Purpur for plugins. Availability
|
||||
depends on whether the mod or plugin loader supports the selected Minecraft version.
|
||||
</p>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1020,7 +1020,7 @@ const nodeUnavailableDetails = computed(() => [
|
||||
{
|
||||
label: "Error message",
|
||||
value: nodeAccessible.value
|
||||
? server.moduleErrors?.general?.error.message ?? "Unknown"
|
||||
? (server.moduleErrors?.general?.error.message ?? "Unknown")
|
||||
: "Unable to reach node. Ping test failed.",
|
||||
type: "block" as const,
|
||||
},
|
||||
@@ -1277,7 +1277,8 @@ useHead({
|
||||
background-repeat: no-repeat;
|
||||
filter: blur(1rem);
|
||||
content: "";
|
||||
background-image: linear-gradient(
|
||||
background-image:
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
rgba(from var(--color-raised-bg) r g b / 0.2),
|
||||
rgb(from var(--color-raised-bg) r g b / 0.8)
|
||||
|
||||
@@ -101,7 +101,7 @@
|
||||
<span :class="{ invisible: 'current_file' in op && !op.current_file }">
|
||||
{{
|
||||
"current_file" in op
|
||||
? op.current_file?.split("/")?.pop() ?? "unknown"
|
||||
? (op.current_file?.split("/")?.pop() ?? "unknown")
|
||||
: "unknown"
|
||||
}}
|
||||
</span>
|
||||
|
||||
6
apps/frontend/src/public/.well-known/security.txt
Normal file
6
apps/frontend/src/public/.well-known/security.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
Contact: mailto:jai@modrinth.com
|
||||
Expires: 2025-12-31T00:00:00.000Z
|
||||
Preferred-Languages: en
|
||||
Canonical: https://modrinth.com/.well-known/security.txt
|
||||
Policy: https://modrinth.com/legal/security
|
||||
Hiring: https://careers.modrinth.com/
|
||||
@@ -98,13 +98,6 @@
|
||||
"date": "2023-02-01T20:00:00.000Z",
|
||||
"link": "https://modrinth.com/news/article/accelerating-development"
|
||||
},
|
||||
{
|
||||
"title": "Two years of Modrinth: a retrospective",
|
||||
"summary": "The history of Modrinth as we know it from December 2020 to December 2022.",
|
||||
"thumbnail": "https://modrinth.com/news/default.webp",
|
||||
"date": "2023-01-07T00:00:00.000Z",
|
||||
"link": "https://modrinth.com/news/article/two-years-of-modrinth-history"
|
||||
},
|
||||
{
|
||||
"title": "Modrinth's Anniversary Update",
|
||||
"summary": "Marking two years of Modrinth and discussing our New Year's Resolutions for 2023.",
|
||||
@@ -112,6 +105,13 @@
|
||||
"date": "2023-01-07T00:00:00.000Z",
|
||||
"link": "https://modrinth.com/news/article/two-years-of-modrinth"
|
||||
},
|
||||
{
|
||||
"title": "Two years of Modrinth: a retrospective",
|
||||
"summary": "The history of Modrinth as we know it from December 2020 to December 2022.",
|
||||
"thumbnail": "https://modrinth.com/news/default.webp",
|
||||
"date": "2023-01-07T00:00:00.000Z",
|
||||
"link": "https://modrinth.com/news/article/two-years-of-modrinth-history"
|
||||
},
|
||||
{
|
||||
"title": "Creators can now make money on Modrinth!",
|
||||
"summary": "Introducing the Creator Monetization Program allowing creators to earn revenue from their projects.",
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "PostgreSQL",
|
||||
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled')",
|
||||
"query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled' OR status = 'failed')",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -102,5 +102,5 @@
|
||||
true
|
||||
]
|
||||
},
|
||||
"hash": "99cca53fd3f35325e2da3b671532bf98b8c7ad8e7cb9158e4eb9c5bac66d20b2"
|
||||
"hash": "cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055"
|
||||
}
|
||||
@@ -36,7 +36,7 @@ paste.workspace = true
|
||||
meilisearch-sdk = { workspace = true, features = ["reqwest"] }
|
||||
rust-s3.workspace = true
|
||||
reqwest = { workspace = true, features = ["http2", "rustls-tls-webpki-roots", "json", "multipart"] }
|
||||
hyper-tls.workspace = true
|
||||
hyper-rustls.workspace = true
|
||||
hyper-util.workspace = true
|
||||
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM rust:1.88.0 AS build
|
||||
|
||||
WORKDIR /usr/src/labrinth
|
||||
COPY . .
|
||||
RUN SQLX_OFFLINE=true cargo build --release --package labrinth
|
||||
RUN --mount=type=cache,target=/usr/src/labrinth/target \
|
||||
--mount=type=cache,target=/usr/local/cargo/git/db \
|
||||
--mount=type=cache,target=/usr/local/cargo/registry \
|
||||
SQLX_OFFLINE=true cargo build --release --package labrinth
|
||||
|
||||
FROM build AS artifacts
|
||||
|
||||
RUN --mount=type=cache,target=/usr/src/labrinth/target \
|
||||
mkdir /labrinth \
|
||||
&& cp /usr/src/labrinth/target/release/labrinth /labrinth/labrinth \
|
||||
&& cp -r /usr/src/labrinth/apps/labrinth/migrations /labrinth \
|
||||
&& cp -r /usr/src/labrinth/apps/labrinth/assets /labrinth
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
@@ -11,13 +24,11 @@ LABEL org.opencontainers.image.description="Modrinth API"
|
||||
LABEL org.opencontainers.image.licenses=AGPL-3.0
|
||||
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates openssl dumb-init curl \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates dumb-init curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /usr/src/labrinth/target/release/labrinth /labrinth/labrinth
|
||||
COPY --from=build /usr/src/labrinth/apps/labrinth/migrations/* /labrinth/migrations/
|
||||
COPY --from=build /usr/src/labrinth/apps/labrinth/assets /labrinth/assets
|
||||
WORKDIR /labrinth
|
||||
COPY --from=artifacts /labrinth /labrinth
|
||||
|
||||
WORKDIR /labrinth
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["/labrinth/labrinth"]
|
||||
|
||||
@@ -43,7 +43,9 @@ pub enum AuthenticationError {
|
||||
InvalidAuthMethod,
|
||||
#[error("GitHub Token from incorrect Client ID")]
|
||||
InvalidClientId,
|
||||
#[error("User email/account is already registered on Modrinth")]
|
||||
#[error(
|
||||
"User email is already registered on Modrinth. Try 'Forgot password' to access your account."
|
||||
)]
|
||||
DuplicateUser,
|
||||
#[error("Invalid state sent, you probably need to get a new websocket")]
|
||||
SocketError,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use hyper_tls::{HttpsConnector, native_tls};
|
||||
use hyper_util::client::legacy::connect::HttpConnector;
|
||||
use hyper_rustls::HttpsConnectorBuilder;
|
||||
use hyper_util::rt::TokioExecutor;
|
||||
|
||||
mod fetch;
|
||||
@@ -15,13 +14,11 @@ pub async fn init_client_with_database(
|
||||
database: &str,
|
||||
) -> clickhouse::error::Result<clickhouse::Client> {
|
||||
let client = {
|
||||
let mut http_connector = HttpConnector::new();
|
||||
http_connector.enforce_http(false); // allow https URLs
|
||||
|
||||
let tls_connector =
|
||||
native_tls::TlsConnector::builder().build().unwrap().into();
|
||||
let https_connector =
|
||||
HttpsConnector::from((http_connector, tls_connector));
|
||||
let https_connector = HttpsConnectorBuilder::new()
|
||||
.with_native_roots()?
|
||||
.https_or_http()
|
||||
.enable_all_versions()
|
||||
.build();
|
||||
let hyper_client =
|
||||
hyper_util::client::legacy::Client::builder(TokioExecutor::new())
|
||||
.build(https_connector);
|
||||
|
||||
@@ -197,7 +197,7 @@ impl DBCharge {
|
||||
) -> Result<Option<DBCharge>, DatabaseError> {
|
||||
let user_subscription_id = user_subscription_id.0;
|
||||
let res = select_charges_with_predicate!(
|
||||
"WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled')",
|
||||
"WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled' OR status = 'failed')",
|
||||
user_subscription_id
|
||||
)
|
||||
.fetch_optional(exec)
|
||||
|
||||
@@ -223,8 +223,8 @@ impl TempUser {
|
||||
stripe_customer_id: None,
|
||||
totp_secret: None,
|
||||
username,
|
||||
email: self.email,
|
||||
email_verified: true,
|
||||
email: self.email.clone(),
|
||||
email_verified: self.email.is_some(),
|
||||
avatar_url,
|
||||
raw_avatar_url,
|
||||
bio: self.bio,
|
||||
@@ -1419,15 +1419,15 @@ pub async fn create_account_with_password(
|
||||
.hash_password(new_account.password.as_bytes(), &salt)?
|
||||
.to_string();
|
||||
|
||||
if crate::database::models::DBUser::get_by_email(
|
||||
if !crate::database::models::DBUser::get_by_case_insensitive_email(
|
||||
&new_account.email,
|
||||
&**pool,
|
||||
)
|
||||
.await?
|
||||
.is_some()
|
||||
.is_empty()
|
||||
{
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Email is already registered on Modrinth!".to_string(),
|
||||
"Email is already registered on Modrinth! Try 'Forgot password' to access your account.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -2220,6 +2220,18 @@ pub async fn set_email(
|
||||
.await?
|
||||
.1;
|
||||
|
||||
if !crate::database::models::DBUser::get_by_case_insensitive_email(
|
||||
&email.email,
|
||||
&**pool,
|
||||
)
|
||||
.await?
|
||||
.is_empty()
|
||||
{
|
||||
return Err(ApiError::InvalidInput(
|
||||
"Email is already registered on Modrinth! Try 'Forgot password' in incognito to access and delete your other account.".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut transaction = pool.begin().await?;
|
||||
|
||||
sqlx::query!(
|
||||
|
||||
@@ -13,7 +13,9 @@
|
||||
"app:build": "turbo run build --filter=@modrinth/app",
|
||||
"app:fix": "turbo run fix --filter=@modrinth/app",
|
||||
"app:intl:extract": "pnpm run --filter=@modrinth/app-frontend intl:extract",
|
||||
"blog:fix": "turbo run fix --filter=@modrinth/blog",
|
||||
"pages:build": "NITRO_PRESET=cloudflare-pages pnpm --filter frontend run build",
|
||||
"moderation:fix": "turbo run fix --filter=@modrinth/moderation",
|
||||
"build": "turbo run build --continue",
|
||||
"lint": "turbo run lint --continue",
|
||||
"test": "turbo run test --continue",
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
# SQLite database file location
|
||||
DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db
|
||||
MODRINTH_URL=http://localhost:3000/
|
||||
MODRINTH_API_URL=http://127.0.0.1:8000/v2/
|
||||
MODRINTH_API_URL_V3=http://127.0.0.1:8000/v3/
|
||||
MODRINTH_SOCKET_URL=ws://127.0.0.1:8000/
|
||||
MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/
|
||||
|
||||
# SQLite database file used by sqlx for type checking. Uncomment this to a valid path
|
||||
# in your system and run `cargo sqlx database setup` to generate an empty database that
|
||||
# can be used for developing the app DB schema
|
||||
#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db
|
||||
|
||||
10
packages/app-lib/.env.prod
Normal file
10
packages/app-lib/.env.prod
Normal file
@@ -0,0 +1,10 @@
|
||||
MODRINTH_URL=https://modrinth.com/
|
||||
MODRINTH_API_URL=https://api.modrinth.com/v2/
|
||||
MODRINTH_API_URL_V3=https://api.modrinth.com/v3/
|
||||
MODRINTH_SOCKET_URL=wss://api.modrinth.com/
|
||||
MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/
|
||||
|
||||
# SQLite database file used by sqlx for type checking. Uncomment this to a valid path
|
||||
# in your system and run `cargo sqlx database setup` to generate an empty database that
|
||||
# can be used for developing the app DB schema
|
||||
#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db
|
||||
10
packages/app-lib/.env.staging
Normal file
10
packages/app-lib/.env.staging
Normal file
@@ -0,0 +1,10 @@
|
||||
MODRINTH_URL=https://staging.modrinth.com/
|
||||
MODRINTH_API_URL=https://staging-api.modrinth.com/v2/
|
||||
MODRINTH_API_URL_V3=https://staging-api.modrinth.com/v3/
|
||||
MODRINTH_SOCKET_URL=wss://staging-api.modrinth.com/
|
||||
MODRINTH_LAUNCHER_META_URL=https://launcher-meta.modrinth.com/
|
||||
|
||||
# SQLite database file used by sqlx for type checking. Uncomment this to a valid path
|
||||
# in your system and run `cargo sqlx database setup` to generate an empty database that
|
||||
# can be used for developing the app DB schema
|
||||
#DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT\n uuid, active, username, access_token, refresh_token, expires\n FROM minecraft_users\n WHERE active = TRUE\n ",
|
||||
"query": "\n SELECT\n uuid, active, username, access_token, refresh_token, expires, account_type\n FROM minecraft_users\n WHERE active = TRUE\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -32,6 +32,11 @@
|
||||
"name": "expires",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "account_type",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -43,8 +48,9 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "bf7d47350092d87c478009adaab131168e87bb37aa65c2156ad2cb6198426d8c"
|
||||
"hash": "57214178fb3a0ccd8f67457e9732a706cbc4a4f5190c9320d1ad6111b9711d63"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n SELECT\n uuid, active, username, access_token, refresh_token, expires\n FROM minecraft_users\n ",
|
||||
"query": "\n SELECT\n uuid, active, username, access_token, refresh_token, expires, account_type\n FROM minecraft_users\n ",
|
||||
"describe": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -32,6 +32,11 @@
|
||||
"name": "expires",
|
||||
"ordinal": 5,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "account_type",
|
||||
"ordinal": 6,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -43,8 +48,9 @@
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "727e3e1bc8625bbcb833920059bb8cea926ac6c65d613904eff1d740df30acda"
|
||||
"hash": "5c803f3d90c147210e8e7a7a6d7234d3801bc38c23e1e02fbd8fa08ae51e8f08"
|
||||
}
|
||||
12
packages/app-lib/.sqlx/query-8f7d4406ddae4a158eabb20fc6a8ffb21c4e22c7ff33459df5049ee441fa0467.json
generated
Normal file
12
packages/app-lib/.sqlx/query-8f7d4406ddae4a158eabb20fc6a8ffb21c4e22c7ff33459df5049ee441fa0467.json
generated
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n INSERT INTO minecraft_users (uuid, active, username, access_token, refresh_token, expires, account_type)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ON CONFLICT (uuid) DO UPDATE SET\n active = $2,\n username = $3,\n access_token = $4,\n refresh_token = $5,\n expires = $6,\n account_type = $7\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 7
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "8f7d4406ddae4a158eabb20fc6a8ffb21c4e22c7ff33459df5049ee441fa0467"
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"db_name": "SQLite",
|
||||
"query": "\n INSERT INTO minecraft_users (uuid, active, username, access_token, refresh_token, expires)\n VALUES ($1, $2, $3, $4, $5, $6)\n ON CONFLICT (uuid) DO UPDATE SET\n active = $2,\n username = $3,\n access_token = $4,\n refresh_token = $5,\n expires = $6\n ",
|
||||
"describe": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 6
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "d719cf2f6f87c5ea7ea6ace2d6a1828ee58a724f06a91633b8a40b4e04d0b9a0"
|
||||
}
|
||||
@@ -82,6 +82,7 @@ ariadne.workspace = true
|
||||
winreg.workspace = true
|
||||
|
||||
[build-dependencies]
|
||||
dotenvy.workspace = true
|
||||
dunce.workspace = true
|
||||
|
||||
[features]
|
||||
|
||||
@@ -4,12 +4,31 @@ use std::process::{Command, exit};
|
||||
use std::{env, fs};
|
||||
|
||||
fn main() {
|
||||
println!("cargo::rerun-if-changed=.env");
|
||||
println!("cargo::rerun-if-changed=java/gradle");
|
||||
println!("cargo::rerun-if-changed=java/src");
|
||||
println!("cargo::rerun-if-changed=java/build.gradle.kts");
|
||||
println!("cargo::rerun-if-changed=java/settings.gradle.kts");
|
||||
println!("cargo::rerun-if-changed=java/gradle.properties");
|
||||
|
||||
set_env();
|
||||
build_java_jars();
|
||||
}
|
||||
|
||||
fn set_env() {
|
||||
for (var_name, var_value) in
|
||||
dotenvy::dotenv_iter().into_iter().flatten().flatten()
|
||||
{
|
||||
if var_name == "DATABASE_URL" {
|
||||
// The sqlx database URL is a build-time detail that should not be exposed to the crate
|
||||
continue;
|
||||
}
|
||||
|
||||
println!("cargo::rustc-env={var_name}={var_value}");
|
||||
}
|
||||
}
|
||||
|
||||
fn build_java_jars() {
|
||||
let out_dir =
|
||||
dunce::canonicalize(PathBuf::from(env::var_os("OUT_DIR").unwrap()))
|
||||
.unwrap();
|
||||
@@ -37,6 +56,7 @@ fn main() {
|
||||
.current_dir(dunce::canonicalize("java").unwrap())
|
||||
.status()
|
||||
.expect("Failed to wait on Gradle build");
|
||||
|
||||
if !exit_status.success() {
|
||||
println!("cargo::error=Gradle build failed with {exit_status}");
|
||||
exit(exit_status.code().unwrap_or(1));
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
-- [AR] - SQL Migration
|
||||
ALTER TABLE minecraft_users ADD COLUMN account_type varchar(32) NOT NULL DEFAULT 'unknown';
|
||||
|
||||
UPDATE minecraft_users SET account_type = 'microsoft' WHERE access_token != 'null';
|
||||
UPDATE minecraft_users SET account_type = 'pirate' WHERE access_token == 'null';
|
||||
@@ -1,55 +0,0 @@
|
||||
use std::process::exit;
|
||||
|
||||
use reqwest;
|
||||
use tokio::fs::File as AsyncFile;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::process::Command;
|
||||
|
||||
async fn download_file(download_url: &str, local_filename: &str, os_type: &str, auto_update_supported: bool) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let download_dir = dirs::download_dir().ok_or("[download_file] • Failed to determine download directory")?;
|
||||
let full_path = download_dir.join(local_filename);
|
||||
let response = reqwest::get(download_url).await?;
|
||||
let bytes = response.bytes().await?;
|
||||
let mut dest_file = AsyncFile::create(&full_path).await?;
|
||||
dest_file.write_all(&bytes).await?;
|
||||
println!("[download_file] • File downloaded to: {:?}", full_path);
|
||||
if auto_update_supported {
|
||||
let status;
|
||||
if os_type.to_lowercase() == "Windows".to_lowercase() {
|
||||
status = Command::new("explorer")
|
||||
.arg(download_dir.display().to_string())
|
||||
.status()
|
||||
.await
|
||||
.expect("[download_file] • Failed to open downloads folder");
|
||||
} else if os_type.to_lowercase() == "MacOS".to_lowercase() {
|
||||
status = Command::new("open")
|
||||
.arg(full_path.to_str().unwrap_or_default())
|
||||
.status()
|
||||
.await
|
||||
.expect("[download_file] • Failed to execute command");
|
||||
} else {
|
||||
status = Command::new(".")
|
||||
.arg(full_path.to_str().unwrap_or_default())
|
||||
.status()
|
||||
.await
|
||||
.expect("[download_file] • Failed to execute command");
|
||||
}
|
||||
if status.success() {
|
||||
println!("[download_file] • File opened successfully!");
|
||||
} else {
|
||||
eprintln!("[download_file] • Failed to open the file. Exit code: {:?}", status.code());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn init_download(download_url: &str, local_filename: &str, os_type: &str, auto_update_supported: bool) {
|
||||
println!("[init_download] • Initialize downloading from • {:?}", download_url);
|
||||
println!("[init_download] • Save local file name • {:?}", local_filename);
|
||||
if let Err(e) = download_file(download_url, local_filename, os_type, auto_update_supported).await {
|
||||
eprintln!("[init_download] • An error occurred! Failed to download the file: {}", e);
|
||||
} else {
|
||||
println!("[init_download] • Code finishes without errors.");
|
||||
exit(0)
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,16 @@ pub async fn offline_auth(
|
||||
crate::state::offline_auth(name, &state.pool).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn elyby_auth(
|
||||
uuid: uuid::Uuid,
|
||||
login: &str,
|
||||
access_token: &str
|
||||
) -> crate::Result<Credentials> {
|
||||
let state = State::get().await?;
|
||||
crate::state::elyby_auth(uuid, login, access_token, &state.pool).await
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> {
|
||||
let state = State::get().await?;
|
||||
|
||||
@@ -12,8 +12,8 @@ pub mod pack;
|
||||
pub mod process;
|
||||
pub mod profile;
|
||||
pub mod settings;
|
||||
pub mod update; // [AR] Feature
|
||||
pub mod tags;
|
||||
pub mod download; // AstralRinth
|
||||
pub mod worlds;
|
||||
|
||||
pub mod data {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::state::ModrinthCredentials;
|
||||
|
||||
#[tracing::instrument]
|
||||
pub fn authenticate_begin_flow() -> String {
|
||||
pub fn authenticate_begin_flow() -> &'static str {
|
||||
crate::state::get_login_url()
|
||||
}
|
||||
|
||||
|
||||
604
packages/app-lib/src/api/pack/import/curseforge_profile.rs
Normal file
604
packages/app-lib/src/api/pack/import/curseforge_profile.rs
Normal file
@@ -0,0 +1,604 @@
|
||||
use std::io::Cursor;
|
||||
|
||||
use async_zip::base::read::seek::ZipFileReader;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
State,
|
||||
event::{LoadingBarType, ProfilePayloadType},
|
||||
prelude::ModLoader,
|
||||
state::{LinkedData, ProfileInstallStage},
|
||||
util::fetch::fetch,
|
||||
};
|
||||
|
||||
use super::copy_dotminecraft;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CurseForgeManifest {
|
||||
pub minecraft: CurseForgeMinecraft,
|
||||
pub manifest_type: String,
|
||||
pub manifest_version: i32,
|
||||
pub name: String,
|
||||
pub version: String,
|
||||
pub author: String,
|
||||
pub files: Vec<CurseForgeFile>,
|
||||
pub overrides: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CurseForgeMinecraft {
|
||||
pub version: String,
|
||||
pub mod_loaders: Vec<CurseForgeModLoader>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct CurseForgeModLoader {
|
||||
pub id: String,
|
||||
pub primary: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||
pub struct CurseForgeFile {
|
||||
#[serde(rename = "projectID")]
|
||||
pub project_id: u32,
|
||||
#[serde(rename = "fileID")]
|
||||
pub file_id: u32,
|
||||
pub required: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
pub struct CurseForgeProfileMetadata {
|
||||
pub name: String,
|
||||
pub download_url: String,
|
||||
}
|
||||
|
||||
/// Fetch CurseForge profile metadata from profile code
|
||||
pub async fn fetch_curseforge_profile_metadata(
|
||||
profile_code: &str,
|
||||
) -> crate::Result<CurseForgeProfileMetadata> {
|
||||
let state = State::get().await?;
|
||||
|
||||
// Make initial request to get redirect URL
|
||||
let url = format!(
|
||||
"https://api.curseforge.com/v1/shared-profile/{}",
|
||||
profile_code
|
||||
);
|
||||
|
||||
// Try to fetch the profile - the CurseForge API should redirect to the ZIP file
|
||||
let response = fetch(&url, None, &state.fetch_semaphore, &state.pool).await;
|
||||
|
||||
let download_url = match response {
|
||||
Ok(_bytes) => {
|
||||
// If we get bytes back, use the original URL
|
||||
url
|
||||
}
|
||||
Err(e) => {
|
||||
// If we get an error, it might contain redirect information
|
||||
let error_msg = format!("{:?}", e);
|
||||
if let Some(redirect_start) =
|
||||
error_msg.find("https://shared-profile-media.forgecdn.net/")
|
||||
{
|
||||
let redirect_end = error_msg[redirect_start..]
|
||||
.find(' ')
|
||||
.unwrap_or(error_msg.len() - redirect_start);
|
||||
error_msg[redirect_start..redirect_start + redirect_end]
|
||||
.to_string()
|
||||
} else {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"Failed to fetch CurseForge profile metadata: {}",
|
||||
e
|
||||
))
|
||||
.into());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Now fetch the ZIP file and extract the name from manifest.json
|
||||
let zip_bytes =
|
||||
fetch(&download_url, None, &state.fetch_semaphore, &state.pool).await?;
|
||||
|
||||
// Create a cursor for the ZIP data
|
||||
let cursor = std::io::Cursor::new(zip_bytes);
|
||||
let mut zip_reader =
|
||||
ZipFileReader::with_tokio(cursor).await.map_err(|e| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Failed to read profile ZIP: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
// Find and extract manifest.json
|
||||
let manifest_index = zip_reader
|
||||
.file()
|
||||
.entries()
|
||||
.iter()
|
||||
.position(|f| {
|
||||
f.filename().as_str().unwrap_or_default() == "manifest.json"
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"No manifest.json found in profile".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut manifest_content = String::new();
|
||||
let mut reader = zip_reader
|
||||
.reader_with_entry(manifest_index)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Failed to read manifest.json: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
reader.read_to_string_checked(&mut manifest_content).await?;
|
||||
|
||||
// Parse the manifest to get the actual name
|
||||
let manifest: CurseForgeManifest = serde_json::from_str(&manifest_content)?;
|
||||
|
||||
let profile_name = if manifest.name.is_empty() {
|
||||
format!("CurseForge Profile {}", profile_code)
|
||||
} else {
|
||||
manifest.name.clone()
|
||||
};
|
||||
|
||||
Ok(CurseForgeProfileMetadata {
|
||||
name: profile_name,
|
||||
download_url,
|
||||
})
|
||||
}
|
||||
|
||||
/// Import a CurseForge profile from profile code
|
||||
pub async fn import_curseforge_profile(
|
||||
profile_code: &str,
|
||||
profile_path: &str,
|
||||
) -> crate::Result<()> {
|
||||
let state = State::get().await?;
|
||||
|
||||
// Initialize loading bar
|
||||
let loading_bar = crate::event::emit::init_loading(
|
||||
LoadingBarType::CurseForgeProfileDownload {
|
||||
profile_name: profile_path.to_string(),
|
||||
},
|
||||
100.0,
|
||||
"Importing CurseForge profile...",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// First, fetch the profile metadata to get the download URL
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
10.0,
|
||||
Some("Fetching profile metadata..."),
|
||||
)?;
|
||||
let metadata = fetch_curseforge_profile_metadata(profile_code).await?;
|
||||
|
||||
// Download the profile ZIP file
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
5.0,
|
||||
Some("Downloading profile ZIP..."),
|
||||
)?;
|
||||
let zip_bytes = fetch(
|
||||
&metadata.download_url,
|
||||
None,
|
||||
&state.fetch_semaphore,
|
||||
&state.pool,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Create a cursor for the ZIP data
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
5.0,
|
||||
Some("Extracting ZIP contents..."),
|
||||
)?;
|
||||
let cursor = Cursor::new(zip_bytes);
|
||||
let mut zip_reader =
|
||||
ZipFileReader::with_tokio(cursor).await.map_err(|e| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Failed to read profile ZIP: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
// Find and extract manifest.json
|
||||
let manifest_index = zip_reader
|
||||
.file()
|
||||
.entries()
|
||||
.iter()
|
||||
.position(|f| {
|
||||
f.filename().as_str().unwrap_or_default() == "manifest.json"
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
crate::ErrorKind::InputError(
|
||||
"No manifest.json found in profile".to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut manifest_content = String::new();
|
||||
let mut reader = zip_reader
|
||||
.reader_with_entry(manifest_index)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Failed to read manifest.json: {}",
|
||||
e
|
||||
))
|
||||
})?;
|
||||
|
||||
reader.read_to_string_checked(&mut manifest_content).await?;
|
||||
|
||||
// Parse the manifest
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
5.0,
|
||||
Some("Parsing profile manifest..."),
|
||||
)?;
|
||||
let manifest: CurseForgeManifest = serde_json::from_str(&manifest_content)?;
|
||||
|
||||
// Determine modloader and version
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
5.0,
|
||||
Some("Configuring profile..."),
|
||||
)?;
|
||||
let (mod_loader, loader_version) = if let Some(primary_loader) =
|
||||
manifest.minecraft.mod_loaders.iter().find(|l| l.primary)
|
||||
{
|
||||
parse_modloader(&primary_loader.id)
|
||||
} else if let Some(first_loader) = manifest.minecraft.mod_loaders.first() {
|
||||
parse_modloader(&first_loader.id)
|
||||
} else {
|
||||
(ModLoader::Vanilla, None)
|
||||
};
|
||||
|
||||
let game_version = manifest.minecraft.version.clone();
|
||||
|
||||
// Get appropriate loader version if needed
|
||||
let final_loader_version = if mod_loader != ModLoader::Vanilla {
|
||||
crate::launcher::get_loader_version_from_profile(
|
||||
&game_version,
|
||||
mod_loader,
|
||||
loader_version.as_deref(),
|
||||
)
|
||||
.await?
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Set profile data
|
||||
crate::api::profile::edit(profile_path, |prof| {
|
||||
prof.name = if manifest.name.is_empty() {
|
||||
format!("CurseForge Profile {}", profile_code)
|
||||
} else {
|
||||
manifest.name.clone()
|
||||
};
|
||||
prof.install_stage = ProfileInstallStage::PackInstalling;
|
||||
prof.game_version = game_version.clone();
|
||||
prof.loader_version = final_loader_version.clone().map(|x| x.id);
|
||||
prof.loader = mod_loader;
|
||||
|
||||
// Set linked data for modpack management
|
||||
prof.linked_data = Some(LinkedData {
|
||||
project_id: String::new(),
|
||||
version_id: String::new(),
|
||||
locked: false,
|
||||
});
|
||||
|
||||
async { Ok(()) }
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Create a temporary directory to extract overrides
|
||||
let temp_dir = state
|
||||
.directories
|
||||
.caches_dir()
|
||||
.join(format!("curseforge_profile_{}", profile_code));
|
||||
tokio::fs::create_dir_all(&temp_dir).await?;
|
||||
|
||||
// Extract overrides directory if it exists
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
10.0,
|
||||
Some("Extracting profile files..."),
|
||||
)?;
|
||||
let overrides_dir = temp_dir.join(&manifest.overrides);
|
||||
tokio::fs::create_dir_all(&overrides_dir).await?;
|
||||
|
||||
// Extract all files that are in the overrides directory
|
||||
// First collect the entries we need to extract to avoid borrowing conflicts
|
||||
let entries_to_extract: Vec<(usize, String)> = {
|
||||
let zip_file = zip_reader.file();
|
||||
zip_file
|
||||
.entries()
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter_map(|(index, entry)| {
|
||||
let file_path = entry.filename().as_str().unwrap_or_default();
|
||||
if file_path.starts_with(&format!("{}/", manifest.overrides)) {
|
||||
Some((index, file_path.to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Now extract each file
|
||||
for (index, file_path) in entries_to_extract {
|
||||
let relative_path = file_path
|
||||
.strip_prefix(&format!("{}/", manifest.overrides))
|
||||
.unwrap();
|
||||
let output_path = overrides_dir.join(relative_path);
|
||||
|
||||
// Create parent directories
|
||||
if let Some(parent) = output_path.parent() {
|
||||
tokio::fs::create_dir_all(parent).await?;
|
||||
}
|
||||
|
||||
// Extract file
|
||||
let mut reader =
|
||||
zip_reader.reader_with_entry(index).await.map_err(|e| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Failed to read file {}: {}",
|
||||
file_path, e
|
||||
))
|
||||
})?;
|
||||
|
||||
let mut file_content = Vec::new();
|
||||
reader.read_to_end_checked(&mut file_content).await?;
|
||||
|
||||
tokio::fs::write(&output_path, file_content).await?;
|
||||
}
|
||||
|
||||
// Copy overrides to profile
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
5.0,
|
||||
Some("Copying profile files..."),
|
||||
)?;
|
||||
let _loading_bar = copy_dotminecraft(
|
||||
profile_path,
|
||||
overrides_dir,
|
||||
&state.io_semaphore,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Download and install mods from CurseForge
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
10.0,
|
||||
Some("Downloading mods..."),
|
||||
)?;
|
||||
install_curseforge_mods(
|
||||
&manifest.files,
|
||||
profile_path,
|
||||
&state,
|
||||
&loading_bar,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Clean up temporary directory
|
||||
tokio::fs::remove_dir_all(&temp_dir).await.ok();
|
||||
|
||||
// Install Minecraft if needed
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
20.0,
|
||||
Some("Installing Minecraft..."),
|
||||
)?;
|
||||
if let Some(profile_val) = crate::api::profile::get(profile_path).await? {
|
||||
crate::launcher::install_minecraft(
|
||||
&profile_val,
|
||||
Some(_loading_bar),
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Mark the profile as fully installed
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
20.0,
|
||||
Some("Finalizing profile..."),
|
||||
)?;
|
||||
crate::api::profile::edit(profile_path, |prof| {
|
||||
prof.install_stage = ProfileInstallStage::Installed;
|
||||
async { Ok(()) }
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Emit profile sync event to trigger file system watcher refresh
|
||||
crate::event::emit::emit_profile(profile_path, ProfilePayloadType::Synced)
|
||||
.await?;
|
||||
|
||||
// Complete the loading bar
|
||||
crate::event::emit::emit_loading(
|
||||
&loading_bar,
|
||||
5.0,
|
||||
Some("Import completed!"),
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Parse CurseForge modloader ID into ModLoader and version
|
||||
fn parse_modloader(id: &str) -> (ModLoader, Option<String>) {
|
||||
if id.starts_with("forge-") {
|
||||
let version = id.strip_prefix("forge-").unwrap_or("").to_string();
|
||||
(ModLoader::Forge, Some(version))
|
||||
} else if id.starts_with("fabric-") {
|
||||
let version = id.strip_prefix("fabric-").unwrap_or("").to_string();
|
||||
(ModLoader::Fabric, Some(version))
|
||||
} else if id.starts_with("quilt-") {
|
||||
let version = id.strip_prefix("quilt-").unwrap_or("").to_string();
|
||||
(ModLoader::Quilt, Some(version))
|
||||
} else if id.starts_with("neoforge-") {
|
||||
let version = id.strip_prefix("neoforge-").unwrap_or("").to_string();
|
||||
(ModLoader::NeoForge, Some(version))
|
||||
} else {
|
||||
(ModLoader::Vanilla, None)
|
||||
}
|
||||
}
|
||||
|
||||
/// Install mods from CurseForge files list
|
||||
async fn install_curseforge_mods(
|
||||
files: &[CurseForgeFile],
|
||||
profile_path: &str,
|
||||
state: &State,
|
||||
loading_bar: &crate::event::LoadingBarId,
|
||||
) -> crate::Result<()> {
|
||||
if files.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let num_files = files.len();
|
||||
tracing::info!("Installing {} CurseForge mods", num_files);
|
||||
|
||||
// Download mods sequentially to track progress properly
|
||||
for (index, file) in files.iter().enumerate() {
|
||||
// Update progress message with current mod
|
||||
let progress_message =
|
||||
format!("Downloading mod {} of {}", index + 1, num_files);
|
||||
crate::event::emit::emit_loading(
|
||||
loading_bar,
|
||||
0.0, // Don't increment here, just update message
|
||||
Some(&progress_message),
|
||||
)?;
|
||||
|
||||
download_curseforge_mod(file, profile_path, state).await?;
|
||||
|
||||
// Emit progress for each downloaded mod (20% total for mods, divided by number of mods)
|
||||
let mod_progress = 20.0 / num_files as f64;
|
||||
crate::event::emit::emit_loading(
|
||||
loading_bar,
|
||||
mod_progress,
|
||||
Some(&format!("Downloaded mod {} of {}", index + 1, num_files)),
|
||||
)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download a single mod from CurseForge
|
||||
async fn download_curseforge_mod(
|
||||
file: &CurseForgeFile,
|
||||
profile_path: &str,
|
||||
_state: &State,
|
||||
) -> crate::Result<()> {
|
||||
// Log the download attempt
|
||||
tracing::info!(
|
||||
"Downloading CurseForge mod: project_id={}, file_id={}",
|
||||
file.project_id,
|
||||
file.file_id
|
||||
);
|
||||
|
||||
// Get profile path and create mods directory first
|
||||
let profile_full_path =
|
||||
crate::api::profile::get_full_path(profile_path).await?;
|
||||
let mods_dir = profile_full_path.join("mods");
|
||||
tokio::fs::create_dir_all(&mods_dir).await?;
|
||||
|
||||
// First, get the file metadata to get the correct filename
|
||||
let metadata_url = format!(
|
||||
"https://www.curseforge.com/api/v1/mods/{}/files/{}",
|
||||
file.project_id, file.file_id
|
||||
);
|
||||
|
||||
tracing::info!("Fetching metadata from: {}", metadata_url);
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let metadata_response =
|
||||
client.get(&metadata_url).send().await.map_err(|e| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Failed to fetch metadata for mod {}/{}: {}",
|
||||
file.project_id, file.file_id, e
|
||||
))
|
||||
})?;
|
||||
|
||||
if !metadata_response.status().is_success() {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"HTTP error fetching metadata for mod {}/{}: {}",
|
||||
file.project_id,
|
||||
file.file_id,
|
||||
metadata_response.status()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
// Parse the metadata JSON to get the filename
|
||||
let metadata_json: serde_json::Value =
|
||||
metadata_response.json().await.map_err(|e| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Failed to parse metadata JSON for mod {}/{}: {}",
|
||||
file.project_id, file.file_id, e
|
||||
))
|
||||
})?;
|
||||
|
||||
let original_filename = metadata_json
|
||||
.get("data")
|
||||
.and_then(|data| data.get("fileName"))
|
||||
.and_then(|name| name.as_str())
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| {
|
||||
// Fallback to the old format if API response is unexpected
|
||||
format!("mod_{}_{}.jar", file.project_id, file.file_id)
|
||||
});
|
||||
|
||||
tracing::info!("Original filename: {}", original_filename);
|
||||
|
||||
// Now download the mod using the direct download URL
|
||||
let download_url = format!(
|
||||
"https://www.curseforge.com/api/v1/mods/{}/files/{}/download",
|
||||
file.project_id, file.file_id
|
||||
);
|
||||
|
||||
tracing::info!("Downloading from: {}", download_url);
|
||||
|
||||
let response = client.get(&download_url).send().await.map_err(|e| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Failed to download mod {}/{}: {}",
|
||||
file.project_id, file.file_id, e
|
||||
))
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(crate::ErrorKind::InputError(format!(
|
||||
"HTTP error downloading mod {}/{}: {}",
|
||||
file.project_id,
|
||||
file.file_id,
|
||||
response.status()
|
||||
))
|
||||
.into());
|
||||
}
|
||||
|
||||
// Write the file with its original name
|
||||
let final_path = mods_dir.join(&original_filename);
|
||||
let bytes = response.bytes().await.map_err(|e| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Failed to read response bytes for mod {}/{}: {}",
|
||||
file.project_id, file.file_id, e
|
||||
))
|
||||
})?;
|
||||
|
||||
tokio::fs::write(&final_path, &bytes).await.map_err(|e| {
|
||||
crate::ErrorKind::InputError(format!(
|
||||
"Failed to write mod file {:?}: {}",
|
||||
final_path, e
|
||||
))
|
||||
})?;
|
||||
|
||||
tracing::info!(
|
||||
"Successfully downloaded mod: {} ({} bytes)",
|
||||
original_filename,
|
||||
bytes.len()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -284,6 +284,12 @@ async fn import_mmc_unmanaged(
|
||||
component.version.clone().unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
if component.uid.starts_with("net.neoforged") {
|
||||
return Some((
|
||||
PackDependency::NeoForge,
|
||||
component.version.clone().unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
if component.uid.starts_with("org.quiltmc.quilt-loader") {
|
||||
return Some((
|
||||
PackDependency::QuiltLoader,
|
||||
|
||||
@@ -19,6 +19,7 @@ use crate::{
|
||||
|
||||
pub mod atlauncher;
|
||||
pub mod curseforge;
|
||||
pub mod curseforge_profile;
|
||||
pub mod gdlauncher;
|
||||
pub mod mmc;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user