You've already forked AstralRinth
forked from didirus/AstralRinth
Compare commits
61 Commits
AR-0.10.30
...
release
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
|
|
eb595cdc3e | ||
|
|
572cd065ed | ||
|
|
76dc8a0897 | ||
|
|
4723de6269 | ||
|
|
e15fa35bad | ||
| 7716a0c524 | |||
|
|
2cc6bc8ce4 | ||
|
|
5d19d31b2c | ||
|
|
c1b95ede07 | ||
|
|
058185c7fd | ||
|
|
6fb125cf0f | ||
|
|
a945e9b005 | ||
|
|
b943638afb | ||
|
|
207dc0e2bb | ||
|
|
359fbd4738 | ||
|
|
f7700acce4 | ||
|
|
87a3e2d022 | ||
|
|
5d17663040 | ||
|
|
cff3c72f94 | ||
|
|
fadf475f06 | ||
|
|
7228499737 | ||
|
|
bca467a634 |
@@ -2,5 +2,8 @@
|
|||||||
[target.'cfg(windows)']
|
[target.'cfg(windows)']
|
||||||
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"]
|
rustflags = ["-C", "link-args=/STACK:16777220", "--cfg", "tokio_unstable"]
|
||||||
|
|
||||||
|
[target.x86_64-pc-windows-msvc]
|
||||||
|
linker = "rust-lld"
|
||||||
|
|
||||||
[build]
|
[build]
|
||||||
rustflags = ["--cfg", "tokio_unstable"]
|
rustflags = ["--cfg", "tokio_unstable"]
|
||||||
|
|||||||
9
.github/workflows/astralrinth-build.yml
vendored
9
.github/workflows/astralrinth-build.yml
vendored
@@ -144,5 +144,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: App bundle (${{ matrix.artifact-target-name }})
|
name: App bundle (${{ matrix.artifact-target-name }})
|
||||||
path: |
|
path: |
|
||||||
target/release/bundle/**
|
target/release/bundle/appimage/AstralRinth App_*.AppImage*
|
||||||
target/*/release/bundle/**
|
target/release/bundle/deb/AstralRinth App_*.deb*
|
||||||
|
target/release/bundle/rpm/AstralRinth App-*.rpm*
|
||||||
|
target/universal-apple-darwin/release/bundle/macos/AstralRinth App.app.tar.gz*
|
||||||
|
target/universal-apple-darwin/release/bundle/dmg/AstralRinth App_*.dmg*
|
||||||
|
target/release/bundle/nsis/AstralRinth App_*-setup.exe*
|
||||||
|
target/release/bundle/nsis/AstralRinth App_*-setup.nsis.zip*
|
||||||
|
|||||||
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"
|
hex = "0.4.3"
|
||||||
hickory-resolver = "0.25.2"
|
hickory-resolver = "0.25.2"
|
||||||
hmac = "0.12.1"
|
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"
|
hyper-util = "0.1.14"
|
||||||
iana-time-zone = "0.1.63"
|
iana-time-zone = "0.1.63"
|
||||||
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
|
image = { version = "0.25.6", default-features = false, features = ["rayon"] }
|
||||||
|
|||||||
@@ -61,9 +61,10 @@ import { renderString } from '@modrinth/utils'
|
|||||||
import { useFetch } from '@/helpers/fetch.js'
|
import { useFetch } from '@/helpers/fetch.js'
|
||||||
// import { check } from '@tauri-apps/plugin-updater'
|
// import { check } from '@tauri-apps/plugin-updater'
|
||||||
import NavButton from '@/components/ui/NavButton.vue'
|
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 { get_user } from '@/helpers/cache.js'
|
||||||
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
||||||
|
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
|
||||||
// import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
// import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
||||||
// import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
|
// import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
|
||||||
import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
||||||
@@ -283,6 +284,8 @@ const incompatibilityWarningModal = ref()
|
|||||||
|
|
||||||
const credentials = ref()
|
const credentials = ref()
|
||||||
|
|
||||||
|
const modrinthLoginFlowWaitModal = ref()
|
||||||
|
|
||||||
async function fetchCredentials() {
|
async function fetchCredentials() {
|
||||||
const creds = await getCreds().catch(handleError)
|
const creds = await getCreds().catch(handleError)
|
||||||
if (creds && creds.user_id) {
|
if (creds && creds.user_id) {
|
||||||
@@ -292,8 +295,24 @@ async function fetchCredentials() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function signIn() {
|
async function signIn() {
|
||||||
await login().catch(handleError)
|
modrinthLoginFlowWaitModal.value.show()
|
||||||
await fetchCredentials()
|
|
||||||
|
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() {
|
async function logOut() {
|
||||||
@@ -422,6 +441,9 @@ function handleAuxClick(e) {
|
|||||||
<Suspense>
|
<Suspense>
|
||||||
<AppSettingsModal ref="settingsModal" />
|
<AppSettingsModal ref="settingsModal" />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
<Suspense>
|
||||||
|
<AuthGrantFlowWaitModal ref="modrinthLoginFlowWaitModal" @flow-cancel="cancelLogin" />
|
||||||
|
</Suspense>
|
||||||
<Suspense>
|
<Suspense>
|
||||||
<InstanceCreationModal ref="installationModal" />
|
<InstanceCreationModal ref="installationModal" />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@@ -136,7 +136,7 @@ const filteredResults = computed(() => {
|
|||||||
|
|
||||||
if (sortBy.value === 'Game version') {
|
if (sortBy.value === 'Game version') {
|
||||||
instances.sort((a, b) => {
|
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])
|
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
|
return instanceMap
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,17 +1,9 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div v-if="mode !== 'isolated'" ref="button"
|
||||||
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="button-base mt-2 px-3 py-2 bg-button-bg rounded-xl flex items-center gap-2"
|
||||||
:class="{ expanded: mode === 'expanded' }"
|
:class="{ expanded: mode === 'expanded' }" @click="toggleMenu">
|
||||||
@click="toggleMenu"
|
<Avatar size="36px" :src="selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
||||||
>
|
" />
|
||||||
<Avatar
|
|
||||||
size="36px"
|
|
||||||
:src="
|
|
||||||
selectedAccount ? avatarUrl : 'https://launcher-files.modrinth.com/assets/steve_head.png'
|
|
||||||
"
|
|
||||||
/>
|
|
||||||
<div class="flex flex-col w-full">
|
<div class="flex flex-col w-full">
|
||||||
<span>
|
<span>
|
||||||
<component :is="getAccountType(selectedAccount)" v-if="selectedAccount" class="vector-icon" />
|
<component :is="getAccountType(selectedAccount)" v-if="selectedAccount" class="vector-icon" />
|
||||||
@@ -32,30 +24,23 @@
|
|||||||
</h4>
|
</h4>
|
||||||
<p>Selected</p>
|
<p>Selected</p>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button v-tooltip="'Log out'" icon-only color="raised" @click="logout(selectedAccount.profile.id)">
|
||||||
v-tooltip="'Log out'"
|
|
||||||
icon-only
|
|
||||||
color="raised"
|
|
||||||
@click="logout(selectedAccount.profile.id)"
|
|
||||||
>
|
|
||||||
<TrashIcon />
|
<TrashIcon />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="login-section account">
|
<div v-else class="login-section account">
|
||||||
<h4>Not signed in</h4>
|
<h4>Not signed in</h4>
|
||||||
<Button
|
<Button v-tooltip="'Log via Microsoft'" :disabled="microsoftLoginDisabled" icon-only @click="login()">
|
||||||
v-tooltip="'Log in'"
|
<MicrosoftIcon v-if="!microsoftLoginDisabled" />
|
||||||
:disabled="loginDisabled"
|
|
||||||
icon-only
|
|
||||||
color="primary"
|
|
||||||
@click="login()"
|
|
||||||
>
|
|
||||||
<MicrosoftIcon v-if="!loginDisabled"/>
|
|
||||||
<SpinnerIcon v-else class="animate-spin" />
|
<SpinnerIcon v-else class="animate-spin" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button v-tooltip="'Add offline'" icon-only @click="tryOfflineLogin()">
|
<Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
|
||||||
<PirateIcon />
|
<PirateIcon />
|
||||||
</Button>
|
</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>
|
||||||
<div v-if="displayAccounts.length > 0" class="account-group">
|
<div v-if="displayAccounts.length > 0" class="account-group">
|
||||||
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
|
<div v-for="account in displayAccounts" :key="account.profile.id" class="account-row">
|
||||||
@@ -72,53 +57,135 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="accounts.length > 0" class="login-section account centered">
|
<div v-if="accounts.length > 0" class="login-section account centered">
|
||||||
<Button v-tooltip="'Log in'" icon-only @click="login()">
|
<Button v-tooltip="'Log via Microsoft'" icon-only @click="login()">
|
||||||
<MicrosoftIcon />
|
<MicrosoftIcon v-if="!microsoftLoginDisabled" />
|
||||||
|
<SpinnerIcon v-else class="animate-spin" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button v-tooltip="'Add offline'" icon-only @click="tryOfflineLogin()">
|
<Button v-tooltip="'Add offline account'" icon-only @click="showOfflineLoginModal()">
|
||||||
<PirateIcon />
|
<PirateIcon />
|
||||||
</Button>
|
</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>
|
||||||
</Card>
|
</Card>
|
||||||
</transition>
|
</transition>
|
||||||
<ModalWrapper ref="loginOfflineModal" class="modal" header="Add new offline account">
|
<ModalWrapper ref="addElybyModal" class="modal" header="Authenticate with Ely.by">
|
||||||
<div class="modal-body">
|
<ModalWrapper ref="requestElybyTwoFactorCodeModal" class="modal"
|
||||||
<div class="label">Enter offline username</div>
|
header="Ely.by requested 2FA code for authentication">
|
||||||
<input type="text" v-model="playerName" placeholder="Provide offline player name" />
|
<div class="flex flex-col gap-4 px-6 py-5">
|
||||||
<Button icon-only color="secondary" @click="offlineLoginFinally()">
|
<label class="label">Enter your 2FA code</label>
|
||||||
Continue
|
<input v-model="elybyTwoFactorCode" type="text" placeholder="Your 2FA code here..." class="input" />
|
||||||
</Button>
|
<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>
|
</div>
|
||||||
</ModalWrapper>
|
</ModalWrapper>
|
||||||
<ModalWrapper ref="loginErrorModal" class="modal" header="Error while proceed">
|
<ModalWrapper ref="addOfflineModal" class="modal" header="Add new offline account">
|
||||||
<div class="modal-body">
|
<div class="flex flex-col gap-4 px-6 py-5">
|
||||||
<div class="label">Error occurred while adding offline account</div>
|
<label class="label">Enter your player name</label>
|
||||||
<Button color="primary" @click="retryOfflineLogin()">
|
<input v-model="offlinePlayerName" type="text" placeholder="Your player name here..." class="input" />
|
||||||
Try again
|
<div class="mt-6 ml-auto">
|
||||||
</Button>
|
<Button icon-only color="primary" class="continue-button" @click="addOfflineProfile()">
|
||||||
|
Login
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalWrapper>
|
</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="modal-body">
|
||||||
<div class="label">Unexcepted error</div>
|
<label class="label">An unexpected error has occurred. Please try again later.</label>
|
||||||
</div>
|
</div>
|
||||||
</ModalWrapper>
|
</ModalWrapper>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
DropdownIcon,
|
DropdownIcon,
|
||||||
PlusIcon,
|
|
||||||
TrashIcon,
|
TrashIcon,
|
||||||
LogInIcon,
|
|
||||||
PirateIcon as Offline,
|
PirateIcon as Offline,
|
||||||
MicrosoftIcon as License,
|
MicrosoftIcon as License,
|
||||||
|
ElyByIcon as Elyby,
|
||||||
MicrosoftIcon,
|
MicrosoftIcon,
|
||||||
PirateIcon,
|
PirateIcon,
|
||||||
SpinnerIcon } from '@modrinth/assets'
|
ElyByIcon,
|
||||||
|
SpinnerIcon
|
||||||
|
} from '@modrinth/assets'
|
||||||
import { Avatar, Button, Card } from '@modrinth/ui'
|
import { Avatar, Button, Card } from '@modrinth/ui'
|
||||||
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
import { ref, computed, onMounted, onBeforeUnmount, onUnmounted } from 'vue'
|
||||||
import {
|
import {
|
||||||
|
elyby_auth_authenticate,
|
||||||
|
elyby_login,
|
||||||
offline_login,
|
offline_login,
|
||||||
users,
|
users,
|
||||||
remove_user,
|
remove_user,
|
||||||
@@ -145,48 +212,180 @@ defineProps({
|
|||||||
const emit = defineEmits(['change'])
|
const emit = defineEmits(['change'])
|
||||||
|
|
||||||
const accounts = ref({})
|
const accounts = ref({})
|
||||||
const loginDisabled = ref(false)
|
const microsoftLoginDisabled = ref(false)
|
||||||
|
const elybyLoginDisabled = ref(false)
|
||||||
const defaultUser = ref()
|
const defaultUser = ref()
|
||||||
const loginOfflineModal = ref(null)
|
|
||||||
const loginErrorModal = ref(null)
|
|
||||||
const unexpectedErrorModal = ref(null)
|
|
||||||
const playerName = ref('')
|
|
||||||
|
|
||||||
async function tryOfflineLogin() { // [AR] Feature
|
// [AR] • Feature
|
||||||
loginOfflineModal.value.show()
|
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() { // [AR] Feature
|
// [AR] • Feature
|
||||||
const name = playerName.value
|
function showOfflineLoginModal() {
|
||||||
if (name.length > 1 && name.length < 20 && name !== '') {
|
addOfflineModal.value?.show()
|
||||||
const loggedIn = await offline_login(name).catch(handleError)
|
}
|
||||||
loginOfflineModal.value.hide()
|
|
||||||
if (loggedIn) {
|
// [AR] • Feature
|
||||||
await setAccount(loggedIn)
|
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()
|
await refreshValues()
|
||||||
} else {
|
} else {
|
||||||
unexpectedErrorModal.value.show()
|
exceptionErrorModal.value?.show()
|
||||||
}
|
}
|
||||||
playerName.value = ''
|
} catch (error) {
|
||||||
} else {
|
handleError(error)
|
||||||
playerName.value = ''
|
exceptionErrorModal.value?.show()
|
||||||
loginOfflineModal.value.hide()
|
} finally {
|
||||||
loginErrorModal.value.show()
|
clearOfflineFields()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function retryOfflineLogin() { // [AR] Feature
|
// [AR] • Feature
|
||||||
loginErrorModal.value.hide()
|
async function addElybyProfile() {
|
||||||
tryOfflineLogin()
|
if (!elybyLogin.value || !elybyPassword.value) {
|
||||||
}
|
addElybyModal.value?.hide()
|
||||||
|
inputElybyErrorModal.value?.show()
|
||||||
|
clearElybyFields()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
elybyLoginDisabled.value = true
|
||||||
|
|
||||||
function getAccountType(account) { // [AR] Feature
|
const login = elybyLogin.value.trim()
|
||||||
if (account.access_token != "null" && account.access_token != null && account.access_token != "") {
|
let password = elybyPassword.value.trim()
|
||||||
return License
|
const twoFactorCode = elybyTwoFactorCode.value.trim()
|
||||||
} else {
|
if (password && twoFactorCode) {
|
||||||
return Offline
|
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 equippedSkin = ref(null)
|
||||||
const headUrlCache = ref(new Map())
|
const headUrlCache = ref(new Map())
|
||||||
|
|
||||||
@@ -212,13 +411,13 @@ async function refreshValues() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setLoginDisabled(value) {
|
function setLoginDisabled(value) {
|
||||||
loginDisabled.value = value
|
microsoftLoginDisabled.value = value
|
||||||
}
|
}
|
||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
refreshValues,
|
refreshValues,
|
||||||
setLoginDisabled,
|
setLoginDisabled,
|
||||||
loginDisabled,
|
loginDisabled: microsoftLoginDisabled,
|
||||||
})
|
})
|
||||||
await refreshValues()
|
await refreshValues()
|
||||||
|
|
||||||
@@ -264,7 +463,7 @@ async function setAccount(account) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function login() {
|
async function login() {
|
||||||
loginDisabled.value = true
|
microsoftLoginDisabled.value = true
|
||||||
const loggedIn = await login_flow().catch(handleSevereError)
|
const loggedIn = await login_flow().catch(handleSevereError)
|
||||||
|
|
||||||
if (loggedIn) {
|
if (loggedIn) {
|
||||||
@@ -273,7 +472,7 @@ async function login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
trackEvent('AccountLogIn')
|
trackEvent('AccountLogIn')
|
||||||
loginDisabled.value = false
|
microsoftLoginDisabled.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = async (id) => {
|
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>
|
||||||
@@ -163,6 +163,14 @@
|
|||||||
<div v-else class="table-content empty">No profiles found</div>
|
<div v-else class="table-content empty">No profiles found</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="button-row">
|
<div class="button-row">
|
||||||
|
<Button
|
||||||
|
v-if="selectedProfileType.name === 'Curseforge'"
|
||||||
|
@click="showCurseForgeProfileModal"
|
||||||
|
:disabled="loading"
|
||||||
|
>
|
||||||
|
<CodeIcon />
|
||||||
|
Import from Profile Code
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
:disabled="
|
:disabled="
|
||||||
loading ||
|
loading ||
|
||||||
@@ -194,10 +202,12 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ModalWrapper>
|
</ModalWrapper>
|
||||||
|
<CurseForgeProfileImportModal ref="curseforgeProfileModal" :close-parent="hide" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||||
|
import CurseForgeProfileImportModal from '@/components/ui/CurseForgeProfileImportModal.vue'
|
||||||
import {
|
import {
|
||||||
CodeIcon,
|
CodeIcon,
|
||||||
FolderOpenIcon,
|
FolderOpenIcon,
|
||||||
@@ -283,6 +293,11 @@ const hide = () => {
|
|||||||
unlistener.value = null
|
unlistener.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const showCurseForgeProfileModal = () => {
|
||||||
|
curseforgeProfileModal.value?.show()
|
||||||
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (unlistener.value) {
|
if (unlistener.value) {
|
||||||
unlistener.value()
|
unlistener.value()
|
||||||
@@ -305,12 +320,16 @@ const [
|
|||||||
get_game_versions().then(shallowRef).catch(handleError),
|
get_game_versions().then(shallowRef).catch(handleError),
|
||||||
get_loaders()
|
get_loaders()
|
||||||
.then((value) =>
|
.then((value) =>
|
||||||
value
|
ref(
|
||||||
.filter((item) => item.supported_project_types.includes('modpack'))
|
value
|
||||||
.map((item) => item.name.toLowerCase()),
|
.filter((item) => item.supported_project_types.includes('modpack'))
|
||||||
|
.map((item) => item.name.toLowerCase()),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.then(ref)
|
.catch((err) => {
|
||||||
.catch(handleError),
|
handleError(err)
|
||||||
|
return ref([])
|
||||||
|
}),
|
||||||
])
|
])
|
||||||
loaders.value.unshift('vanilla')
|
loaders.value.unshift('vanilla')
|
||||||
|
|
||||||
@@ -334,6 +353,7 @@ const game_versions = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const modal = ref(null)
|
const modal = ref(null)
|
||||||
|
const curseforgeProfileModal = ref(null)
|
||||||
|
|
||||||
const check_valid = computed(() => {
|
const check_valid = computed(() => {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { edit, get_optimal_jre_key } from '@/helpers/profile'
|
|||||||
import { handleError } from '@/store/notifications'
|
import { handleError } from '@/store/notifications'
|
||||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||||
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
import JavaSelector from '@/components/ui/JavaSelector.vue'
|
||||||
import { get_max_memory } from '@/helpers/jre'
|
|
||||||
import { get } from '@/helpers/settings.ts'
|
import { get } from '@/helpers/settings.ts'
|
||||||
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
|
import type { InstanceSettingsTabProps, AppSettings, MemorySettings } from '../../../helpers/types'
|
||||||
|
import useMemorySlider from '@/composables/useMemorySlider'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
@@ -34,7 +34,7 @@ const envVars = ref(
|
|||||||
|
|
||||||
const overrideMemorySettings = ref(!!props.instance.memory)
|
const overrideMemorySettings = ref(!!props.instance.memory)
|
||||||
const memory = ref(props.instance.memory ?? globalSettings.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 editProfileObject = computed(() => {
|
||||||
const editProfile: {
|
const editProfile: {
|
||||||
@@ -156,6 +156,8 @@ const messages = defineMessages({
|
|||||||
:min="512"
|
:min="512"
|
||||||
:max="maxMemory"
|
:max="maxMemory"
|
||||||
:step="64"
|
:step="64"
|
||||||
|
:snap-points="snapPoints"
|
||||||
|
:snap-range="512"
|
||||||
unit="MB"
|
unit="MB"
|
||||||
/>
|
/>
|
||||||
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
|
<h2 id="project-name" class="mt-4 mb-1 text-lg font-extrabold text-contrast block">
|
||||||
|
|||||||
@@ -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">
|
<script setup lang="ts">
|
||||||
import { get, set } from '@/helpers/settings.ts'
|
import { get, set } from '@/helpers/settings.ts'
|
||||||
import { ref, watch } from 'vue'
|
import { ref, watch } from 'vue'
|
||||||
import { get_max_memory } from '@/helpers/jre'
|
|
||||||
import { handleError } from '@/store/notifications'
|
|
||||||
import { Slider, Toggle } from '@modrinth/ui'
|
import { Slider, Toggle } from '@modrinth/ui'
|
||||||
|
import useMemorySlider from '@/composables/useMemorySlider'
|
||||||
|
|
||||||
const fetchSettings = await get()
|
const fetchSettings = await get()
|
||||||
fetchSettings.launchArgs = fetchSettings.extra_launch_args.join(' ')
|
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 settings = ref(fetchSettings)
|
||||||
|
|
||||||
const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
|
const { maxMemory, snapPoints } = await useMemorySlider()
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
settings,
|
settings,
|
||||||
@@ -107,6 +106,8 @@ watch(
|
|||||||
:min="512"
|
:min="512"
|
||||||
:max="maxMemory"
|
:max="maxMemory"
|
||||||
:step="64"
|
:step="64"
|
||||||
|
:snap-points="snapPoints"
|
||||||
|
:snap-range="512"
|
||||||
unit="MB"
|
unit="MB"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ import {
|
|||||||
type Cape,
|
type Cape,
|
||||||
type SkinModel,
|
type SkinModel,
|
||||||
get_normalized_skin_texture,
|
get_normalized_skin_texture,
|
||||||
|
determineModelType,
|
||||||
} from '@/helpers/skins.ts'
|
} from '@/helpers/skins.ts'
|
||||||
import { handleError } from '@/store/notifications'
|
import { handleError } from '@/store/notifications'
|
||||||
import {
|
import {
|
||||||
@@ -253,7 +254,7 @@ async function showNew(e: MouseEvent, skinTextureUrl: string) {
|
|||||||
mode.value = 'new'
|
mode.value = 'new'
|
||||||
currentSkin.value = null
|
currentSkin.value = null
|
||||||
uploadedTextureUrl.value = skinTextureUrl
|
uploadedTextureUrl.value = skinTextureUrl
|
||||||
variant.value = 'CLASSIC'
|
variant.value = await determineModelType(skinTextureUrl)
|
||||||
selectedCape.value = undefined
|
selectedCape.value = undefined
|
||||||
visibleCapeList.value = []
|
visibleCapeList.value = []
|
||||||
initVisibleCapeList()
|
initVisibleCapeList()
|
||||||
|
|||||||
@@ -128,6 +128,14 @@ const messages = defineMessages({
|
|||||||
id: 'instance.worlds.game_already_open',
|
id: 'instance.worlds.game_already_open',
|
||||||
defaultMessage: 'Instance is 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: {
|
copyAddress: {
|
||||||
id: 'instance.worlds.copy_address',
|
id: 'instance.worlds.copy_address',
|
||||||
defaultMessage: 'Copy address',
|
defaultMessage: 'Copy address',
|
||||||
@@ -302,39 +310,33 @@ const messages = defineMessages({
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
|
<div class="flex gap-1 justify-end smart-clickable:allow-pointer-events">
|
||||||
<template v-if="world.type === 'singleplayer' || serverStatus">
|
<ButtonStyled
|
||||||
<ButtonStyled
|
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
|
||||||
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
|
color="red"
|
||||||
color="red"
|
>
|
||||||
>
|
<button @click="emit('stop')">
|
||||||
<button @click="emit('stop')">
|
<StopCircleIcon aria-hidden="true" />
|
||||||
<StopCircleIcon aria-hidden="true" />
|
{{ formatMessage(commonMessages.stopButton) }}
|
||||||
{{ formatMessage(commonMessages.stopButton) }}
|
</button>
|
||||||
</button>
|
</ButtonStyled>
|
||||||
</ButtonStyled>
|
<ButtonStyled v-else>
|
||||||
<ButtonStyled v-else>
|
<button
|
||||||
<button
|
v-tooltip="
|
||||||
v-tooltip="
|
!serverStatus
|
||||||
serverIncompatible
|
? formatMessage(messages.noContact)
|
||||||
? 'Server is incompatible'
|
: serverIncompatible
|
||||||
|
? formatMessage(messages.incompatibleServer)
|
||||||
: !supportsQuickPlay
|
: !supportsQuickPlay
|
||||||
? formatMessage(messages.noQuickPlay)
|
? formatMessage(messages.noQuickPlay)
|
||||||
: playingOtherWorld || locked
|
: playingOtherWorld || locked
|
||||||
? formatMessage(messages.gameAlreadyOpen)
|
? formatMessage(messages.gameAlreadyOpen)
|
||||||
: null
|
: null
|
||||||
"
|
"
|
||||||
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
|
:disabled="!supportsQuickPlay || playingOtherWorld || startingInstance"
|
||||||
@click="emit('play')"
|
@click="emit('play')"
|
||||||
>
|
>
|
||||||
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
<SpinnerIcon v-if="startingInstance && playingWorld" class="animate-spin" />
|
||||||
<PlayIcon v-else aria-hidden="true" />
|
<PlayIcon v-else aria-hidden="true" />
|
||||||
{{ formatMessage(commonMessages.playButton) }}
|
|
||||||
</button>
|
|
||||||
</ButtonStyled>
|
|
||||||
</template>
|
|
||||||
<ButtonStyled v-else>
|
|
||||||
<button class="invisible">
|
|
||||||
<PlayIcon aria-hidden="true" />
|
|
||||||
{{ formatMessage(commonMessages.playButton) }}
|
{{ formatMessage(commonMessages.playButton) }}
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</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 })
|
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.
|
* Authenticate a user with Hydra - part 1.
|
||||||
* This begins the authentication flow quasi-synchronously.
|
* 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) {
|
export async function get_default_launcher_path(launcherType) {
|
||||||
return await invoke('plugin:import|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() {
|
export async function get() {
|
||||||
return await invoke('plugin:mr-auth|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 type { Skin, Cape } from '../skins'
|
||||||
import { get_normalized_skin_texture, determineModelType } from '../skins'
|
import { get_normalized_skin_texture, determineModelType } from '../skins'
|
||||||
import { reactive } from 'vue'
|
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 { skinPreviewStorage } from '../storage/skin-preview-storage'
|
||||||
import { headStorage } from '../storage/head-storage'
|
import { headStorage } from '../storage/head-storage'
|
||||||
import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
|
import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
|
||||||
@@ -120,6 +126,9 @@ class BatchSkinRenderer {
|
|||||||
if (capeUrl) {
|
if (capeUrl) {
|
||||||
const capeTexture = await loadTexture(capeUrl)
|
const capeTexture = await loadTexture(capeUrl)
|
||||||
applyCapeTexture(model, capeTexture)
|
applyCapeTexture(model, capeTexture)
|
||||||
|
} else {
|
||||||
|
const transparentTexture = createTransparentTexture()
|
||||||
|
applyCapeTexture(model, null, transparentTexture)
|
||||||
}
|
}
|
||||||
|
|
||||||
const group = new THREE.Group()
|
const group = new THREE.Group()
|
||||||
|
|||||||
@@ -62,15 +62,12 @@ export async function determineModelType(texture: string): Promise<'SLIM' | 'CLA
|
|||||||
|
|
||||||
context.drawImage(image, 0, 0)
|
context.drawImage(image, 0, 0)
|
||||||
|
|
||||||
const armX = 44
|
const armX = 54
|
||||||
const armY = 16
|
const armY = 20
|
||||||
const armWidth = 4
|
const armWidth = 2
|
||||||
const armHeight = 12
|
const armHeight = 12
|
||||||
|
|
||||||
const imageData = context.getImageData(armX, armY, armWidth, armHeight).data
|
const imageData = context.getImageData(armX, armY, armWidth, armHeight).data
|
||||||
|
for (let alphaIndex = 3; alphaIndex < imageData.length; alphaIndex += 4) {
|
||||||
for (let y = 0; y < armHeight; y++) {
|
|
||||||
const alphaIndex = (3 + y * armWidth) * 4 + 3
|
|
||||||
if (imageData[alphaIndex] !== 0) {
|
if (imageData[alphaIndex] !== 0) {
|
||||||
resolve('CLASSIC')
|
resolve('CLASSIC')
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -10,20 +10,20 @@ export async function getOS() {
|
|||||||
return await invoke('plugin:utils|get_os')
|
return await invoke('plugin:utils|get_os')
|
||||||
}
|
}
|
||||||
|
|
||||||
// [AR] Feature
|
// [AR] Feature. Updater
|
||||||
export async function initUpdateLauncher(downloadurl, filename, ostype, autoupdatesupported) {
|
export async function initUpdateLauncher(downloadUrl, filename, osType, autoUpdateSupported) {
|
||||||
console.log('Downloading build', downloadurl, filename, ostype, autoupdatesupported)
|
console.log('Downloading build', downloadUrl, filename, osType, autoUpdateSupported)
|
||||||
return await invoke('plugin:utils|init_update_launcher', { downloadurl, filename, ostype, autoupdatesupported })
|
return await invoke('plugin:utils|init_update_launcher', { downloadUrl, filename, osType, autoUpdateSupported })
|
||||||
}
|
}
|
||||||
|
|
||||||
// [AR] Patch fix
|
// [AR] Migration. Patch
|
||||||
export async function applyMigrationFix(eol) {
|
export async function applyMigrationFix(eol) {
|
||||||
return await invoke('plugin:utils|apply_migration_fix', { eol })
|
return await invoke('plugin:utils|apply_migration_fix', { eol })
|
||||||
}
|
}
|
||||||
|
|
||||||
// [AR] Feature
|
// [AR] Feature. Ely.by
|
||||||
export async function initAuthlibPatching(minecraftversion, ismojang) {
|
export async function initAuthlibPatching(minecraftVersion, isMojang) {
|
||||||
return await invoke('plugin:utils|init_authlib_patching', { minecraftversion, ismojang })
|
return await invoke('plugin:utils|init_authlib_patching', { minecraftVersion, isMojang })
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function openPath(path) {
|
export async function openPath(path) {
|
||||||
|
|||||||
@@ -377,6 +377,12 @@
|
|||||||
"instance.worlds.hardcore": {
|
"instance.worlds.hardcore": {
|
||||||
"message": "Hardcore mode"
|
"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": {
|
"instance.worlds.no_quick_play": {
|
||||||
"message": "You can only jump straight into worlds on Minecraft 1.20+"
|
"message": "You can only jump straight into worlds on Minecraft 1.20+"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ async function refreshSearch() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
results.value = rawResults.result
|
results.value = rawResults.result
|
||||||
currentPage.value = Math.max(1, Math.min(pageCount.value, currentPage.value))
|
currentPage.value = 1
|
||||||
|
|
||||||
const persistentParams: LocationQuery = {}
|
const persistentParams: LocationQuery = {}
|
||||||
|
|
||||||
@@ -266,6 +266,7 @@ async function onSearchChangeToTop() {
|
|||||||
|
|
||||||
function clearSearch() {
|
function clearSearch() {
|
||||||
query.value = ''
|
query.value = ''
|
||||||
|
currentPage.value = 1
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
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.");
|
println!("A browser window will now open, follow the login flow there.");
|
||||||
let login = minecraft_auth::begin_login().await?;
|
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: ");
|
println!("Please enter URL code: ");
|
||||||
let mut input = String::new();
|
let mut input = String::new();
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ thiserror.workspace = true
|
|||||||
daedalus.workspace = true
|
daedalus.workspace = true
|
||||||
chrono.workspace = true
|
chrono.workspace = true
|
||||||
either.workspace = true
|
either.workspace = true
|
||||||
|
hyper = { workspace = true, features = ["server"] }
|
||||||
|
hyper-util.workspace = true
|
||||||
|
|
||||||
url.workspace = true
|
url.workspace = true
|
||||||
urlencoding.workspace = true
|
urlencoding.workspace = true
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ fn main() {
|
|||||||
InlinedPlugin::new()
|
InlinedPlugin::new()
|
||||||
.commands(&[
|
.commands(&[
|
||||||
"offline_login",
|
"offline_login",
|
||||||
|
"elyby_login",
|
||||||
|
"elyby_auth_authenticate",
|
||||||
"login",
|
"login",
|
||||||
"remove_user",
|
"remove_user",
|
||||||
"get_default_user",
|
"get_default_user",
|
||||||
@@ -121,7 +123,12 @@ fn main() {
|
|||||||
.plugin(
|
.plugin(
|
||||||
"mr-auth",
|
"mr-auth",
|
||||||
InlinedPlugin::new()
|
InlinedPlugin::new()
|
||||||
.commands(&["modrinth_login", "logout", "get"])
|
.commands(&[
|
||||||
|
"modrinth_login",
|
||||||
|
"logout",
|
||||||
|
"get",
|
||||||
|
"cancel_modrinth_login",
|
||||||
|
])
|
||||||
.default_permission(
|
.default_permission(
|
||||||
DefaultPermissionRule::AllowAllCommands,
|
DefaultPermissionRule::AllowAllCommands,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -19,17 +19,16 @@
|
|||||||
"window-state:default",
|
"window-state:default",
|
||||||
"window-state:allow-restore-state",
|
"window-state:allow-restore-state",
|
||||||
"window-state:allow-save-window-state",
|
"window-state:allow-save-window-state",
|
||||||
|
|
||||||
{
|
{
|
||||||
"identifier": "http:default",
|
"identifier": "http:default",
|
||||||
"allow": [
|
"allow": [{ "url": "https://modrinth.com/*" }, { "url": "https://*.modrinth.com/*" }]
|
||||||
{ "url": "https://modrinth.com/*" },
|
|
||||||
{ "url": "https://*.modrinth.com/*" }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
|
|
||||||
"auth:default",
|
"auth:default",
|
||||||
"import:default",
|
"import:default",
|
||||||
|
"import:allow-fetch-curseforge-profile-metadata",
|
||||||
|
"import:allow-import-curseforge-profile",
|
||||||
"jre:default",
|
"jre:default",
|
||||||
"logs:default",
|
"logs:default",
|
||||||
"metadata:default",
|
"metadata:default",
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ use crate::api::Result;
|
|||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use tauri::plugin::TauriPlugin;
|
use tauri::plugin::TauriPlugin;
|
||||||
use tauri::{Manager, Runtime, UserAttentionType};
|
use tauri::{Manager, Runtime, UserAttentionType};
|
||||||
|
use tauri_plugin_http::reqwest::Client;
|
||||||
use theseus::prelude::*;
|
use theseus::prelude::*;
|
||||||
|
|
||||||
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||||
tauri::plugin::Builder::<R>::new("auth")
|
tauri::plugin::Builder::<R>::new("auth")
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
offline_login,
|
offline_login,
|
||||||
|
elyby_login,
|
||||||
|
elyby_auth_authenticate,
|
||||||
login,
|
login,
|
||||||
remove_user,
|
remove_user,
|
||||||
get_default_user,
|
get_default_user,
|
||||||
@@ -17,14 +20,65 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ### AR • Feature
|
||||||
/// Create new offline user
|
/// Create new offline user
|
||||||
/// This is custom function from Astralium Org.
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn offline_login(name: &str) -> Result<Credentials> {
|
pub async fn offline_login(name: &str) -> Result<Credentials> {
|
||||||
let credentials = minecraft_auth::offline_auth(name).await?;
|
let credentials = minecraft_auth::offline_auth(name).await?;
|
||||||
Ok(credentials)
|
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
|
/// 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)
|
/// This begins the authentication flow quasi-synchronously, returning a URL to visit (that the user will sign in at)
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -42,7 +96,7 @@ pub async fn login<R: Runtime>(
|
|||||||
let window = tauri::WebviewWindowBuilder::new(
|
let window = tauri::WebviewWindowBuilder::new(
|
||||||
&app,
|
&app,
|
||||||
"signin",
|
"signin",
|
||||||
tauri::WebviewUrl::External(flow.redirect_uri.parse().map_err(
|
tauri::WebviewUrl::External(flow.auth_request_uri.parse().map_err(
|
||||||
|_| {
|
|_| {
|
||||||
theseus::ErrorKind::OtherError(
|
theseus::ErrorKind::OtherError(
|
||||||
"Error parsing auth redirect URL".to_string(),
|
"Error parsing auth redirect URL".to_string(),
|
||||||
@@ -86,6 +140,7 @@ pub async fn login<R: Runtime>(
|
|||||||
window.close()?;
|
window.close()?;
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn remove_user(user: uuid::Uuid) -> Result<()> {
|
pub async fn remove_user(user: uuid::Uuid) -> Result<()> {
|
||||||
Ok(minecraft_auth::remove_user(user).await?)
|
Ok(minecraft_auth::remove_user(user).await?)
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use crate::api::Result;
|
use crate::api::Result;
|
||||||
use theseus::pack::import::ImportLauncherType;
|
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;
|
use theseus::pack::import;
|
||||||
|
|
||||||
@@ -12,6 +17,8 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|||||||
import_instance,
|
import_instance,
|
||||||
is_valid_importable_instance,
|
is_valid_importable_instance,
|
||||||
get_default_launcher_path,
|
get_default_launcher_path,
|
||||||
|
fetch_curseforge_profile_metadata,
|
||||||
|
import_curseforge_profile,
|
||||||
])
|
])
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
@@ -68,3 +75,24 @@ pub async fn get_default_launcher_path(
|
|||||||
) -> Result<Option<PathBuf>> {
|
) -> Result<Option<PathBuf>> {
|
||||||
Ok(import::get_default_launcher_path(launcher_type))
|
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 friends;
|
||||||
pub mod worlds;
|
pub mod worlds;
|
||||||
|
|
||||||
|
mod oauth_utils;
|
||||||
|
|
||||||
pub type Result<T> = std::result::Result<T, TheseusSerializableError>;
|
pub type Result<T> = std::result::Result<T, TheseusSerializableError>;
|
||||||
|
|
||||||
// // Main returnable Theseus GUI error
|
// // Main returnable Theseus GUI error
|
||||||
|
|||||||
@@ -1,79 +1,70 @@
|
|||||||
use crate::api::Result;
|
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::plugin::TauriPlugin;
|
||||||
use tauri::{Manager, Runtime, UserAttentionType};
|
use tauri_plugin_opener::OpenerExt;
|
||||||
use theseus::prelude::*;
|
use theseus::prelude::*;
|
||||||
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
|
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
|
||||||
tauri::plugin::Builder::new("mr-auth")
|
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()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn modrinth_login<R: Runtime>(
|
pub async fn modrinth_login<R: Runtime>(
|
||||||
app: tauri::AppHandle<R>,
|
app: tauri::AppHandle<R>,
|
||||||
) -> Result<Option<ModrinthCredentials>> {
|
) -> Result<ModrinthCredentials> {
|
||||||
let redirect_uri = mr_auth::authenticate_begin_flow();
|
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") {
|
let auth_request_uri = format!(
|
||||||
window.close()?;
|
"{}?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.opener()
|
||||||
&app,
|
.open_url(auth_request_uri, None::<&str>)
|
||||||
"modrinth-signin",
|
.map_err(|e| {
|
||||||
tauri::WebviewUrl::External(redirect_uri.parse().map_err(|_| {
|
TheseusSerializableError::Theseus(
|
||||||
theseus::ErrorKind::OtherError(
|
theseus::ErrorKind::OtherError(format!(
|
||||||
"Error parsing auth redirect URL".to_string(),
|
"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) {
|
let credentials = mr_auth::authenticate_finish_flow(&auth_code).await?;
|
||||||
if window.title().is_err() {
|
|
||||||
// user closed window, cancelling flow
|
|
||||||
return Ok(None);
|
|
||||||
}
|
|
||||||
|
|
||||||
if window
|
if let Some(main_window) = app.get_window("main") {
|
||||||
.url()?
|
main_window.set_focus().ok();
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
window.close()?;
|
Ok(credentials)
|
||||||
Ok(None)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -85,3 +76,8 @@ pub async fn logout() -> Result<()> {
|
|||||||
pub async fn get() -> Result<Option<ModrinthCredentials>> {
|
pub async fn get() -> Result<Option<ModrinthCredentials>> {
|
||||||
Ok(theseus::mr_auth::get_credentials().await?)
|
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;
|
||||||
@@ -30,37 +30,37 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [AR] Feature
|
/// [AR] Feature. Ely.by
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn init_authlib_patching(
|
pub async fn init_authlib_patching(
|
||||||
minecraftversion: &str,
|
minecraft_version: &str,
|
||||||
ismojang: bool,
|
is_mojang: bool,
|
||||||
) -> Result<bool> {
|
) -> Result<bool> {
|
||||||
let result =
|
let result =
|
||||||
utils::init_authlib_patching(minecraftversion, ismojang).await?;
|
utils::init_authlib_patching(minecraft_version, is_mojang).await?;
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [AR] Patch fix
|
/// [AR] Migration. Patch
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn apply_migration_fix(eol: &str) -> Result<bool> {
|
pub async fn apply_migration_fix(eol: &str) -> Result<bool> {
|
||||||
let result = utils::apply_migration_fix(eol).await?;
|
let result = utils::apply_migration_fix(eol).await?;
|
||||||
Ok(result)
|
Ok(result)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [AR] Feature
|
/// [AR] Feature. Updater
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn init_update_launcher(
|
pub async fn init_update_launcher(
|
||||||
downloadurl: &str,
|
download_url: &str,
|
||||||
filename: &str,
|
filename: &str,
|
||||||
ostype: &str,
|
os_type: &str,
|
||||||
autoupdatesupported: bool,
|
auto_update_supported: bool,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let _ = utils::init_update_launcher(
|
let _ = utils::init_update_launcher(
|
||||||
downloadurl,
|
download_url,
|
||||||
filename,
|
filename,
|
||||||
ostype,
|
os_type,
|
||||||
autoupdatesupported,
|
auto_update_supported,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -41,7 +41,7 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"productName": "AstralRinth App",
|
"productName": "AstralRinth App",
|
||||||
"version": "0.10.304",
|
"version": "0.10.305",
|
||||||
"mainBinaryName": "AstralRinth App",
|
"mainBinaryName": "AstralRinth App",
|
||||||
"identifier": "AstralRinthApp",
|
"identifier": "AstralRinthApp",
|
||||||
"plugins": {
|
"plugins": {
|
||||||
@@ -63,6 +63,7 @@
|
|||||||
"height": 800,
|
"height": 800,
|
||||||
"resizable": true,
|
"resizable": true,
|
||||||
"title": "AstralRinth",
|
"title": "AstralRinth",
|
||||||
|
"label": "main",
|
||||||
"width": 1280,
|
"width": 1280,
|
||||||
"minHeight": 700,
|
"minHeight": 700,
|
||||||
"minWidth": 1100,
|
"minWidth": 1100,
|
||||||
@@ -86,7 +87,7 @@
|
|||||||
"capabilities": ["core", "plugins"],
|
"capabilities": ["core", "plugins"],
|
||||||
"csp": {
|
"csp": {
|
||||||
"default-src": "'self' customprotocol: asset:",
|
"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/"],
|
"font-src": ["https://cdn-raw.modrinth.com/fonts/"],
|
||||||
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
|
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
|
||||||
"style-src": "'unsafe-inline' 'self'",
|
"style-src": "'unsafe-inline' 'self'",
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
FROM rust:1.88.0 AS build
|
FROM rust:1.88.0 AS build
|
||||||
|
|
||||||
WORKDIR /usr/src/daedalus
|
WORKDIR /usr/src/daedalus
|
||||||
COPY . .
|
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
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
@@ -11,7 +21,7 @@ RUN apt-get update \
|
|||||||
&& apt-get install -y --no-install-recommends ca-certificates openssl \
|
&& apt-get install -y --no-install-recommends ca-certificates openssl \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=build /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
|
COPY --from=artifacts /daedalus /daedalus
|
||||||
WORKDIR /daedalus_client
|
|
||||||
|
|
||||||
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
|
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.
|
To enable labrinth to create a project, you need to add two things.
|
||||||
|
|
||||||
1. An entry in the `loaders` table.
|
1. An entry in the `loaders` table.
|
||||||
|
|||||||
@@ -38,9 +38,10 @@
|
|||||||
"@intercom/messenger-js-sdk": "^0.0.14",
|
"@intercom/messenger-js-sdk": "^0.0.14",
|
||||||
"@ltd/j-toml": "^1.38.0",
|
"@ltd/j-toml": "^1.38.0",
|
||||||
"@modrinth/assets": "workspace:*",
|
"@modrinth/assets": "workspace:*",
|
||||||
|
"@modrinth/blog": "workspace:*",
|
||||||
|
"@modrinth/moderation": "workspace:*",
|
||||||
"@modrinth/ui": "workspace:*",
|
"@modrinth/ui": "workspace:*",
|
||||||
"@modrinth/utils": "workspace:*",
|
"@modrinth/utils": "workspace:*",
|
||||||
"@modrinth/blog": "workspace:*",
|
|
||||||
"@pinia/nuxt": "^0.5.1",
|
"@pinia/nuxt": "^0.5.1",
|
||||||
"@types/three": "^0.172.0",
|
"@types/three": "^0.172.0",
|
||||||
"@vintl/vintl": "^4.4.1",
|
"@vintl/vintl": "^4.4.1",
|
||||||
@@ -58,6 +59,7 @@
|
|||||||
"markdown-it": "14.1.0",
|
"markdown-it": "14.1.0",
|
||||||
"pathe": "^1.1.2",
|
"pathe": "^1.1.2",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
|
"prettier": "^3.6.2",
|
||||||
"qrcode.vue": "^3.4.0",
|
"qrcode.vue": "^3.4.0",
|
||||||
"semver": "^7.5.4",
|
"semver": "^7.5.4",
|
||||||
"three": "^0.172.0",
|
"three": "^0.172.0",
|
||||||
|
|||||||
@@ -197,13 +197,13 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
> :where(
|
> :where(
|
||||||
input + *,
|
input + *,
|
||||||
.input-group + *,
|
.input-group + *,
|
||||||
.textarea-wrapper + *,
|
.textarea-wrapper + *,
|
||||||
.chips + *,
|
.chips + *,
|
||||||
.resizable-textarea-wrapper + *,
|
.resizable-textarea-wrapper + *,
|
||||||
.input-div + *
|
.input-div + *
|
||||||
) {
|
) {
|
||||||
&:not(:empty) {
|
&:not(:empty) {
|
||||||
margin-block-start: var(--spacing-card-md);
|
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-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-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),
|
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);
|
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;
|
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;
|
--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.35) 0%,
|
||||||
rgba(255, 255, 255, 0.2695) 100%
|
rgba(255, 255, 255, 0.2695) 100%
|
||||||
);
|
);
|
||||||
--landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16),
|
--landing-blob-shadow:
|
||||||
inset 2px 2px 64px rgba(255, 255, 255, 0.45);
|
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-bg: rgba(255, 255, 255, 0.8);
|
||||||
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
|
--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-lg: 0px 2px 4px hsla(221, 39%, 11%, 0.2);
|
||||||
--shadow-raised: 0px -2px 4px hsla(221, 39%, 11%, 0.1);
|
--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;
|
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;
|
--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-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");
|
url("https://cdn.modrinth.com/landing-new/landing-lower.webp");
|
||||||
--landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%);
|
--landing-maze-outer-bg: linear-gradient(180deg, #06060d 0%, #000000 100%);
|
||||||
|
|
||||||
@@ -284,7 +288,8 @@ html {
|
|||||||
rgba(44, 48, 79, 0.35) 0%,
|
rgba(44, 48, 79, 0.35) 0%,
|
||||||
rgba(32, 35, 50, 0.2695) 100%
|
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-bg: rgba(59, 63, 85, 0.15);
|
||||||
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
|
--landing-card-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16);
|
||||||
@@ -360,8 +365,9 @@ body {
|
|||||||
// Defaults
|
// Defaults
|
||||||
background-color: var(--color-bg);
|
background-color: var(--color-bg);
|
||||||
color: var(--color-text);
|
color: var(--color-text);
|
||||||
--font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
|
--font-standard:
|
||||||
Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
|
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;
|
--mono-font: ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace;
|
||||||
font-family: var(--font-standard);
|
font-family: var(--font-standard);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
class="vue-notification-group experimental-styles-within"
|
class="vue-notification-group experimental-styles-within"
|
||||||
:class="{ 'intercom-present': isIntercomPresent }"
|
:class="{
|
||||||
|
'intercom-present': isIntercomPresent,
|
||||||
|
rightwards: moveNotificationsRight,
|
||||||
|
}"
|
||||||
>
|
>
|
||||||
<transition-group name="notifs">
|
<transition-group name="notifs">
|
||||||
<div
|
<div
|
||||||
@@ -82,6 +85,7 @@ import {
|
|||||||
CopyIcon,
|
CopyIcon,
|
||||||
} from "@modrinth/assets";
|
} from "@modrinth/assets";
|
||||||
const notifications = useNotifications();
|
const notifications = useNotifications();
|
||||||
|
const { isVisible: moveNotificationsRight } = useNotificationRightwards();
|
||||||
|
|
||||||
const isIntercomPresent = ref(false);
|
const isIntercomPresent = ref(false);
|
||||||
|
|
||||||
@@ -160,6 +164,15 @@ function copyToClipboard(notif) {
|
|||||||
bottom: 5rem;
|
bottom: 5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.rightwards {
|
||||||
|
right: unset !important;
|
||||||
|
left: 1.5rem;
|
||||||
|
|
||||||
|
@media screen and (max-width: 500px) {
|
||||||
|
left: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.vue-notification-wrapper {
|
.vue-notification-wrapper {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
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 {
|
.markdown-body {
|
||||||
grid-area: body;
|
grid-area: body;
|
||||||
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.reporter-info {
|
.reporter-info {
|
||||||
|
|||||||
@@ -31,9 +31,9 @@
|
|||||||
class="flex cursor-pointer items-center gap-1 bg-transparent p-0"
|
class="flex cursor-pointer items-center gap-1 bg-transparent p-0"
|
||||||
@click="
|
@click="
|
||||||
versionFilter &&
|
versionFilter &&
|
||||||
(unlockFilterAccordion.isOpen
|
(unlockFilterAccordion.isOpen
|
||||||
? unlockFilterAccordion.close()
|
? unlockFilterAccordion.close()
|
||||||
: unlockFilterAccordion.open())
|
: unlockFilterAccordion.open())
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<TagItem
|
<TagItem
|
||||||
|
|||||||
@@ -194,13 +194,12 @@ export class ModrinthServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async testNodeReachability(): Promise<boolean> {
|
async testNodeReachability(): Promise<boolean> {
|
||||||
if (!this.general?.datacenter) {
|
if (!this.general?.node?.instance) {
|
||||||
console.warn("No datacenter info available for ping test");
|
console.warn("No node instance available for ping test");
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const datacenter = this.general.datacenter;
|
const wsUrl = `wss://${this.general.node.instance}/pingtest`;
|
||||||
const wsUrl = `wss://${datacenter}.nodes.modrinth.com/pingtest`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await new Promise((resolve) => {
|
return await new Promise((resolve) => {
|
||||||
|
|||||||
@@ -112,7 +112,8 @@ export async function useServersFetch<T>(
|
|||||||
const response = await $fetch<T>(fullUrl, {
|
const response = await $fetch<T>(fullUrl, {
|
||||||
method,
|
method,
|
||||||
headers,
|
headers,
|
||||||
body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
|
body:
|
||||||
|
body && contentType === "application/json" ? JSON.stringify(body) : (body ?? undefined),
|
||||||
timeout: 10000,
|
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,
|
PackageOpenIcon,
|
||||||
DiscordIcon,
|
DiscordIcon,
|
||||||
BlueskyIcon,
|
BlueskyIcon,
|
||||||
TumblrIcon,
|
|
||||||
TwitterIcon,
|
TwitterIcon,
|
||||||
MastodonIcon,
|
MastodonIcon,
|
||||||
GithubIcon,
|
GithubIcon,
|
||||||
@@ -1185,13 +1184,6 @@ const socialLinks = [
|
|||||||
icon: MastodonIcon,
|
icon: MastodonIcon,
|
||||||
rel: "me",
|
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" })),
|
label: formatMessage(defineMessage({ id: "layout.footer.social.x", defaultMessage: "X" })),
|
||||||
href: "https://x.com/modrinth",
|
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": {
|
"layout.footer.about": {
|
||||||
"message": "About"
|
"message": "About"
|
||||||
},
|
},
|
||||||
"layout.footer.about.news": {
|
|
||||||
"message": "News"
|
|
||||||
},
|
|
||||||
"layout.footer.about.careers": {
|
"layout.footer.about.careers": {
|
||||||
"message": "Careers"
|
"message": "Careers"
|
||||||
},
|
},
|
||||||
"layout.footer.about.changelog": {
|
"layout.footer.about.changelog": {
|
||||||
"message": "Changelog"
|
"message": "Changelog"
|
||||||
},
|
},
|
||||||
|
"layout.footer.about.news": {
|
||||||
|
"message": "News"
|
||||||
|
},
|
||||||
"layout.footer.about.rewards-program": {
|
"layout.footer.about.rewards-program": {
|
||||||
"message": "Rewards Program"
|
"message": "Rewards Program"
|
||||||
},
|
},
|
||||||
@@ -404,6 +404,9 @@
|
|||||||
"layout.footer.legal-disclaimer": {
|
"layout.footer.legal-disclaimer": {
|
||||||
"message": "NOT AN OFFICIAL MINECRAFT SERVICE. NOT APPROVED BY OR ASSOCIATED WITH MOJANG OR MICROSOFT."
|
"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": {
|
"layout.footer.legal.privacy-policy": {
|
||||||
"message": "Privacy Policy"
|
"message": "Privacy Policy"
|
||||||
},
|
},
|
||||||
@@ -458,9 +461,6 @@
|
|||||||
"layout.footer.social.mastodon": {
|
"layout.footer.social.mastodon": {
|
||||||
"message": "Mastodon"
|
"message": "Mastodon"
|
||||||
},
|
},
|
||||||
"layout.footer.social.tumblr": {
|
|
||||||
"message": "Tumblr"
|
|
||||||
},
|
|
||||||
"layout.footer.social.x": {
|
"layout.footer.social.x": {
|
||||||
"message": "X"
|
"message": "X"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,12 +29,11 @@
|
|||||||
class="settings-header__icon"
|
class="settings-header__icon"
|
||||||
/>
|
/>
|
||||||
<div class="settings-header__text">
|
<div class="settings-header__text">
|
||||||
<h1 class="wrap-as-needed">
|
<h1 class="wrap-as-needed">{{ project.title }}</h1>
|
||||||
{{ project.title }}
|
|
||||||
</h1>
|
|
||||||
<ProjectStatusBadge :status="project.status" />
|
<ProjectStatusBadge :status="project.status" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h2>Project settings</h2>
|
<h2>Project settings</h2>
|
||||||
<NavStack>
|
<NavStack>
|
||||||
<NavStackItem
|
<NavStackItem
|
||||||
@@ -111,6 +110,7 @@
|
|||||||
</NavStack>
|
</NavStack>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="normal-page__content">
|
<div class="normal-page__content">
|
||||||
<ProjectMemberHeader
|
<ProjectMemberHeader
|
||||||
v-if="currentMember"
|
v-if="currentMember"
|
||||||
@@ -145,6 +145,7 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="experimental-styles-within">
|
<div v-else class="experimental-styles-within">
|
||||||
<NewModal ref="settingsModal">
|
<NewModal ref="settingsModal">
|
||||||
<template #title>
|
<template #title>
|
||||||
@@ -174,9 +175,11 @@
|
|||||||
<div
|
<div
|
||||||
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
|
class="animation-ring-3 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-40"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
|
class="animation-ring-2 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight opacity-60"
|
||||||
></div>
|
></div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="animation-ring-1 flex items-center justify-center rounded-full border-4 border-solid border-brand bg-brand-highlight"
|
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}`"
|
:href="`modrinth://mod/${project.slug}`"
|
||||||
@click="() => installWithApp()"
|
@click="() => installWithApp()"
|
||||||
>
|
>
|
||||||
<ModrinthIcon aria-hidden="true" />
|
<ModrinthIcon aria-hidden="true" /> Install with Modrinth App
|
||||||
Install with Modrinth App
|
|
||||||
<ExternalIcon aria-hidden="true" />
|
<ExternalIcon aria-hidden="true" />
|
||||||
</a>
|
</a>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
@@ -240,6 +242,7 @@
|
|||||||
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
|
<div class="flex h-[2px] w-full rounded-2xl bg-button-bg"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mx-auto flex w-fit flex-col gap-2">
|
<div class="mx-auto flex w-fit flex-col gap-2">
|
||||||
<ButtonStyled v-if="project.game_versions.length === 1">
|
<ButtonStyled v-if="project.game_versions.length === 1">
|
||||||
<div class="disabled button-like">
|
<div class="disabled button-like">
|
||||||
@@ -327,8 +330,7 @@
|
|||||||
}
|
}
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ gameVersion }}
|
{{ gameVersion }} <CheckIcon v-if="userSelectedGameVersion === gameVersion" />
|
||||||
<CheckIcon v-if="userSelectedGameVersion === gameVersion" />
|
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</ScrollablePanel>
|
</ScrollablePanel>
|
||||||
@@ -419,7 +421,6 @@
|
|||||||
</ScrollablePanel>
|
</ScrollablePanel>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AutomaticAccordion div class="flex flex-col gap-2">
|
<AutomaticAccordion div class="flex flex-col gap-2">
|
||||||
<VersionSummary
|
<VersionSummary
|
||||||
v-if="filteredRelease"
|
v-if="filteredRelease"
|
||||||
@@ -470,10 +471,14 @@
|
|||||||
class="new-page sidebar"
|
class="new-page sidebar"
|
||||||
:class="{
|
:class="{
|
||||||
'alt-layout': cosmetics.leftContentLayout,
|
'alt-layout': cosmetics.leftContentLayout,
|
||||||
'ultimate-sidebar':
|
'checklist-open':
|
||||||
showModerationChecklist &&
|
showModerationChecklist &&
|
||||||
!collapsedModerationChecklist &&
|
!collapsedModerationChecklist &&
|
||||||
!flags.alwaysShowChecklistAsPopup,
|
!flags.alwaysShowChecklistAsPopup,
|
||||||
|
'checklist-collapsed':
|
||||||
|
showModerationChecklist &&
|
||||||
|
collapsedModerationChecklist &&
|
||||||
|
!flags.alwaysShowChecklistAsPopup,
|
||||||
}"
|
}"
|
||||||
>
|
>
|
||||||
<div class="normal-page__header relative my-4">
|
<div class="normal-page__header relative my-4">
|
||||||
@@ -485,11 +490,11 @@
|
|||||||
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
|
:color="route.name === 'type-id-version-version' ? `standard` : `brand`"
|
||||||
>
|
>
|
||||||
<button @click="(event) => downloadModal.show(event)">
|
<button @click="(event) => downloadModal.show(event)">
|
||||||
<DownloadIcon aria-hidden="true" />
|
<DownloadIcon aria-hidden="true" /> Download
|
||||||
Download
|
|
||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="contents sm:hidden">
|
<div class="contents sm:hidden">
|
||||||
<ButtonStyled
|
<ButtonStyled
|
||||||
size="large"
|
size="large"
|
||||||
@@ -554,9 +559,11 @@
|
|||||||
</button>
|
</button>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="m-0 text-wrap text-sm font-medium leading-tight text-secondary">
|
<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!
|
Modrinth Servers is the easiest way to play with your friends without hassle!
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<p class="m-0 text-wrap text-sm font-bold text-primary">
|
<p class="m-0 text-wrap text-sm font-bold text-primary">
|
||||||
Starting at $5<span class="text-xs"> / month</span>
|
Starting at $5<span class="text-xs"> / month</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -621,6 +628,7 @@
|
|||||||
{{ option.name }}
|
{{ option.name }}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="menu-text">
|
<div v-else class="menu-text">
|
||||||
<p class="popout-text">No collections found.</p>
|
<p class="popout-text">No collections found.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -628,8 +636,7 @@
|
|||||||
class="btn collection-button"
|
class="btn collection-button"
|
||||||
@click="(event) => $refs.modal_collection.show(event)"
|
@click="(event) => $refs.modal_collection.show(event)"
|
||||||
>
|
>
|
||||||
<PlusIcon aria-hidden="true" />
|
<PlusIcon aria-hidden="true" /> Create new collection
|
||||||
Create new collection
|
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
</PopoutMenu>
|
</PopoutMenu>
|
||||||
@@ -712,25 +719,14 @@
|
|||||||
:dropdown-id="`${baseId}-more-options`"
|
:dropdown-id="`${baseId}-more-options`"
|
||||||
>
|
>
|
||||||
<MoreVerticalIcon aria-hidden="true" />
|
<MoreVerticalIcon aria-hidden="true" />
|
||||||
<template #analytics>
|
<template #analytics> <ChartIcon aria-hidden="true" /> Analytics </template>
|
||||||
<ChartIcon aria-hidden="true" />
|
|
||||||
Analytics
|
|
||||||
</template>
|
|
||||||
<template #moderation-checklist>
|
<template #moderation-checklist>
|
||||||
<ScaleIcon aria-hidden="true" />
|
<ScaleIcon aria-hidden="true" /> Review project
|
||||||
Review project
|
|
||||||
</template>
|
|
||||||
<template #report>
|
|
||||||
<ReportIcon aria-hidden="true" />
|
|
||||||
Report
|
|
||||||
</template>
|
|
||||||
<template #copy-id>
|
|
||||||
<ClipboardCopyIcon aria-hidden="true" />
|
|
||||||
Copy ID
|
|
||||||
</template>
|
</template>
|
||||||
|
<template #report> <ReportIcon aria-hidden="true" /> Report </template>
|
||||||
|
<template #copy-id> <ClipboardCopyIcon aria-hidden="true" /> Copy ID </template>
|
||||||
<template #copy-permalink>
|
<template #copy-permalink>
|
||||||
<ClipboardCopyIcon aria-hidden="true" />
|
<ClipboardCopyIcon aria-hidden="true" /> Copy permanent link
|
||||||
Copy permanent link
|
|
||||||
</template>
|
</template>
|
||||||
</OverflowMenu>
|
</OverflowMenu>
|
||||||
</ButtonStyled>
|
</ButtonStyled>
|
||||||
@@ -756,6 +752,7 @@
|
|||||||
updates unless the author decides to unarchive the project.
|
updates unless the author decides to unarchive the project.
|
||||||
</MessageBanner>
|
</MessageBanner>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="normal-page__sidebar">
|
<div class="normal-page__sidebar">
|
||||||
<ProjectSidebarCompatibility
|
<ProjectSidebarCompatibility
|
||||||
:project="project"
|
:project="project"
|
||||||
@@ -785,6 +782,7 @@
|
|||||||
/>
|
/>
|
||||||
<div class="card flex-card experimental-styles-within">
|
<div class="card flex-card experimental-styles-within">
|
||||||
<h2>{{ formatMessage(detailsMessages.title) }}</h2>
|
<h2>{{ formatMessage(detailsMessages.title) }}</h2>
|
||||||
|
|
||||||
<div class="details-list">
|
<div class="details-list">
|
||||||
<div class="details-list__item">
|
<div class="details-list__item">
|
||||||
<BookTextIcon aria-hidden="true" />
|
<BookTextIcon aria-hidden="true" />
|
||||||
@@ -813,53 +811,48 @@
|
|||||||
<span v-else>{{ licenseIdDisplay }}</span>
|
<span v-else>{{ licenseIdDisplay }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="project.approved"
|
v-if="project.approved"
|
||||||
v-tooltip="$dayjs(project.approved).format('MMMM D, YYYY [at] h:mm A')"
|
v-tooltip="$dayjs(project.approved).format('MMMM D, YYYY [at] h:mm A')"
|
||||||
class="details-list__item"
|
class="details-list__item"
|
||||||
>
|
>
|
||||||
<CalendarIcon aria-hidden="true" />
|
<CalendarIcon aria-hidden="true" />
|
||||||
<div>
|
<div>{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}</div>
|
||||||
{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else
|
||||||
v-tooltip="$dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')"
|
v-tooltip="$dayjs(project.published).format('MMMM D, YYYY [at] h:mm A')"
|
||||||
class="details-list__item"
|
class="details-list__item"
|
||||||
>
|
>
|
||||||
<CalendarIcon aria-hidden="true" />
|
<CalendarIcon aria-hidden="true" />
|
||||||
<div>
|
<div>{{ formatMessage(detailsMessages.created, { date: createdDate }) }}</div>
|
||||||
{{ formatMessage(detailsMessages.created, { date: createdDate }) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="project.status === 'processing' && project.queued"
|
v-if="project.status === 'processing' && project.queued"
|
||||||
v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')"
|
v-tooltip="$dayjs(project.queued).format('MMMM D, YYYY [at] h:mm A')"
|
||||||
class="details-list__item"
|
class="details-list__item"
|
||||||
>
|
>
|
||||||
<ScaleIcon aria-hidden="true" />
|
<ScaleIcon aria-hidden="true" />
|
||||||
<div>
|
<div>{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}</div>
|
||||||
{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="versions.length > 0 && project.updated"
|
v-if="versions.length > 0 && project.updated"
|
||||||
v-tooltip="$dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')"
|
v-tooltip="$dayjs(project.updated).format('MMMM D, YYYY [at] h:mm A')"
|
||||||
class="details-list__item"
|
class="details-list__item"
|
||||||
>
|
>
|
||||||
<VersionIcon aria-hidden="true" />
|
<VersionIcon aria-hidden="true" />
|
||||||
<div>
|
<div>{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}</div>
|
||||||
{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="normal-page__content">
|
<div class="normal-page__content">
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto"><NavTabs :links="navLinks" class="mb-4" /></div>
|
||||||
<NavTabs :links="navLinks" class="mb-4" />
|
|
||||||
</div>
|
|
||||||
<NuxtPage
|
<NuxtPage
|
||||||
v-model:project="project"
|
v-model:project="project"
|
||||||
v-model:versions="versions"
|
v-model:versions="versions"
|
||||||
@@ -877,8 +870,10 @@
|
|||||||
@delete-version="deleteVersion"
|
@delete-version="deleteVersion"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="normal-page__ultimate-sidebar">
|
<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"
|
v-if="auth.user && tags.staffRoles.includes(auth.user.role) && showModerationChecklist"
|
||||||
:project="project"
|
:project="project"
|
||||||
:future-projects="futureProjects"
|
:future-projects="futureProjects"
|
||||||
@@ -886,11 +881,25 @@
|
|||||||
:collapsed="collapsedModerationChecklist"
|
:collapsed="collapsedModerationChecklist"
|
||||||
@exit="showModerationChecklist = false"
|
@exit="showModerationChecklist = false"
|
||||||
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
@toggle-collapsed="collapsedModerationChecklist = !collapsedModerationChecklist"
|
||||||
/>
|
/> -->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import {
|
import {
|
||||||
BookmarkIcon,
|
BookmarkIcon,
|
||||||
@@ -950,16 +959,16 @@ import {
|
|||||||
isUnderReview,
|
isUnderReview,
|
||||||
renderString,
|
renderString,
|
||||||
} from "@modrinth/utils";
|
} from "@modrinth/utils";
|
||||||
import { navigateTo } from "#app";
|
|
||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { Tooltip } from "floating-vue";
|
import { Tooltip } from "floating-vue";
|
||||||
|
import { useLocalStorage } from "@vueuse/core";
|
||||||
|
import { navigateTo } from "#app";
|
||||||
import Accordion from "~/components/ui/Accordion.vue";
|
import Accordion from "~/components/ui/Accordion.vue";
|
||||||
// import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
// import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
|
||||||
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
|
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
|
||||||
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
|
||||||
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
|
||||||
import MessageBanner from "~/components/ui/MessageBanner.vue";
|
import MessageBanner from "~/components/ui/MessageBanner.vue";
|
||||||
import ModerationChecklist from "~/components/ui/ModerationChecklist.vue";
|
|
||||||
import NavStack from "~/components/ui/NavStack.vue";
|
import NavStack from "~/components/ui/NavStack.vue";
|
||||||
import NavStackItem from "~/components/ui/NavStackItem.vue";
|
import NavStackItem from "~/components/ui/NavStackItem.vue";
|
||||||
import NavTabs from "~/components/ui/NavTabs.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 { userCollectProject } from "~/composables/user.js";
|
||||||
import { reportProject } from "~/utils/report-helpers.ts";
|
import { reportProject } from "~/utils/report-helpers.ts";
|
||||||
import { saveFeatureFlags } from "~/composables/featureFlags.ts";
|
import { saveFeatureFlags } from "~/composables/featureFlags.ts";
|
||||||
|
import NewModerationChecklist from "~/components/ui/moderation/NewModerationChecklist.vue";
|
||||||
|
|
||||||
const data = useNuxtApp();
|
const data = useNuxtApp();
|
||||||
const route = useNativeRoute();
|
const route = useNativeRoute();
|
||||||
@@ -980,6 +990,7 @@ const flags = useFeatureFlags();
|
|||||||
const cosmetics = useCosmetics();
|
const cosmetics = useCosmetics();
|
||||||
|
|
||||||
const { formatMessage } = useVIntl();
|
const { formatMessage } = useVIntl();
|
||||||
|
const { setVisible } = useNotificationRightwards();
|
||||||
|
|
||||||
const settingsModal = ref();
|
const settingsModal = ref();
|
||||||
const downloadModal = ref();
|
const downloadModal = ref();
|
||||||
@@ -1551,12 +1562,28 @@ async function copyPermalink() {
|
|||||||
|
|
||||||
const collapsedChecklist = ref(false);
|
const collapsedChecklist = ref(false);
|
||||||
|
|
||||||
const showModerationChecklist = ref(false);
|
const showModerationChecklist = useLocalStorage(
|
||||||
const collapsedModerationChecklist = ref(false);
|
`show-moderation-checklist-${project.value.id}`,
|
||||||
const futureProjects = ref([]);
|
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) {
|
if (import.meta.client && history && history.state && history.state.showChecklist) {
|
||||||
showModerationChecklist.value = true;
|
showModerationChecklist.value = true;
|
||||||
futureProjects.value = history.state.projects;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeDownloadModal(event) {
|
function closeDownloadModal(event) {
|
||||||
@@ -1626,6 +1653,7 @@ const navLinks = computed(() => {
|
|||||||
];
|
];
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.settings-header {
|
.settings-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1781,4 +1809,16 @@ const navLinks = computed(() => {
|
|||||||
left: 18px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -705,9 +705,9 @@ export default defineNuxtComponent({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.gallery-body {
|
.gallery-body {
|
||||||
flex-grow: 1;
|
|
||||||
width: calc(100% - 2 * var(--spacing-card-md));
|
width: calc(100% - 2 * var(--spacing-card-md));
|
||||||
padding: var(--spacing-card-sm) var(--spacing-card-md);
|
padding: var(--spacing-card-sm) var(--spacing-card-md);
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
|
||||||
.gallery-info {
|
.gallery-info {
|
||||||
h2 {
|
h2 {
|
||||||
|
|||||||
@@ -150,9 +150,26 @@
|
|||||||
</template>
|
</template>
|
||||||
</span>
|
</span>
|
||||||
<span class="text-sm text-secondary">
|
<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") }}
|
{{ dayjs(charge.due).format("MMMM D, YYYY [at] h:mma") }}
|
||||||
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
|
<span class="text-secondary">({{ formatRelativeTime(charge.due) }}) </span>
|
||||||
</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">
|
<div class="flex w-full items-center gap-1 text-xs text-secondary">
|
||||||
{{ charge.status }}
|
{{ charge.status }}
|
||||||
⋅
|
⋅
|
||||||
|
|||||||
@@ -1421,7 +1421,8 @@ useSeoMeta({
|
|||||||
width: 25rem;
|
width: 25rem;
|
||||||
height: 25rem;
|
height: 25rem;
|
||||||
opacity: 0.75;
|
opacity: 0.75;
|
||||||
background: radial-gradient(
|
background:
|
||||||
|
radial-gradient(
|
||||||
50% 50% at 50% 50%,
|
50% 50% at 50% 50%,
|
||||||
rgba(5, 206, 69, 0.19) 0%,
|
rgba(5, 206, 69, 0.19) 0%,
|
||||||
rgba(15, 19, 49, 0.25) 100%
|
rgba(15, 19, 49, 0.25) 100%
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div v-if="subtleLauncherRedirectUri">
|
||||||
<template v-if="flow">
|
<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">
|
<label for="two-factor-code">
|
||||||
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
|
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
|
||||||
<span class="label__description">
|
<span class="label__description">
|
||||||
@@ -189,6 +195,7 @@ const auth = await useAuth();
|
|||||||
const route = useNativeRoute();
|
const route = useNativeRoute();
|
||||||
|
|
||||||
const redirectTarget = route.query.redirect || "";
|
const redirectTarget = route.query.redirect || "";
|
||||||
|
const subtleLauncherRedirectUri = ref();
|
||||||
|
|
||||||
if (route.query.code && !route.fullPath.includes("new_account=true")) {
|
if (route.query.code && !route.fullPath.includes("new_account=true")) {
|
||||||
await finishSignIn();
|
await finishSignIn();
|
||||||
@@ -262,7 +269,32 @@ async function begin2FASignIn() {
|
|||||||
|
|
||||||
async function finishSignIn(token) {
|
async function finishSignIn(token) {
|
||||||
if (route.query.launcher) {
|
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;
|
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 useAuth(res.session);
|
||||||
await useUser();
|
await useUser();
|
||||||
|
|
||||||
|
if (route.query.launcher) {
|
||||||
|
await navigateTo({ path: "/auth/sign-in", query: route.query });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (route.query.redirect) {
|
if (route.query.redirect) {
|
||||||
await navigateTo(route.query.redirect);
|
await navigateTo(route.query.redirect);
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -266,12 +266,12 @@ const getRangeOfMethod = (method) => {
|
|||||||
|
|
||||||
const maxWithdrawAmount = computed(() => {
|
const maxWithdrawAmount = computed(() => {
|
||||||
const interval = selectedMethod.value.interval;
|
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 minWithdrawAmount = computed(() => {
|
||||||
const interval = selectedMethod.value.interval;
|
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(() => {
|
const withdrawAccount = computed(() => {
|
||||||
|
|||||||
@@ -212,6 +212,10 @@ if (projects.value) {
|
|||||||
|
|
||||||
async function goToProjects() {
|
async function goToProjects() {
|
||||||
const project = projectsFiltered.value[0];
|
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({
|
await router.push({
|
||||||
name: "type-id",
|
name: "type-id",
|
||||||
params: {
|
params: {
|
||||||
@@ -220,7 +224,6 @@ async function goToProjects() {
|
|||||||
},
|
},
|
||||||
state: {
|
state: {
|
||||||
showChecklist: true,
|
showChecklist: true,
|
||||||
projects: projectsFiltered.value.slice(1).map((x) => (x.slug ? x.slug : x.id)),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,7 +149,8 @@ onMounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.main-hero {
|
.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);
|
var(--color-accent-contrast);
|
||||||
margin-top: -5rem;
|
margin-top: -5rem;
|
||||||
padding: 11.25rem 1rem 8rem;
|
padding: 11.25rem 1rem 8rem;
|
||||||
|
|||||||
@@ -45,8 +45,9 @@
|
|||||||
<h2
|
<h2
|
||||||
class="relative m-0 max-w-2xl text-base font-normal leading-[155%] text-secondary md:text-[1.2rem]"
|
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
|
Modrinth Servers is the easiest way to host your own Minecraft: Java Edition server.
|
||||||
and play your favorite mods and modpacks, all within the Modrinth platform.
|
Seamlessly install and play your favorite mods and modpacks, all within the Modrinth
|
||||||
|
platform.
|
||||||
</h2>
|
</h2>
|
||||||
<div class="relative flex w-full flex-wrap items-center gap-8 align-middle sm:w-fit">
|
<div class="relative flex w-full flex-wrap items-center gap-8 align-middle sm:w-fit">
|
||||||
<div
|
<div
|
||||||
@@ -427,11 +428,8 @@
|
|||||||
Do Modrinth Servers have DDoS protection?
|
Do Modrinth Servers have DDoS protection?
|
||||||
</summary>
|
</summary>
|
||||||
<p class="m-0 ml-6 leading-[160%]">
|
<p class="m-0 ml-6 leading-[160%]">
|
||||||
Yes. All Modrinth Servers come with DDoS protection powered by
|
Yes. All Modrinth Servers come with DDoS protection, with up to 17Tbps capacity in
|
||||||
<a href="https://us.ovhcloud.com/security/anti-ddos/" target="_blank"
|
some locations.
|
||||||
>OVHcloud® Anti-DDoS infrastructure</a
|
|
||||||
>
|
|
||||||
which has over 17Tbps capacity. Your server is safe on Modrinth.
|
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -443,8 +441,9 @@
|
|||||||
Where are Modrinth Servers located? Can I choose a region?
|
Where are Modrinth Servers located? Can I choose a region?
|
||||||
</summary>
|
</summary>
|
||||||
<p class="m-0 ml-6 leading-[160%]">
|
<p class="m-0 ml-6 leading-[160%]">
|
||||||
We have servers in both North America in Vint Hill, Virginia, and Europe in Limburg,
|
We have servers available in North America and Europe at the moment that you can
|
||||||
Germany. More regions to come in the future!
|
choose upon purchase. More regions to come in the future! If you'd like to switch
|
||||||
|
your region, please contact support.
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
@@ -461,7 +460,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</details>
|
</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">
|
<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">
|
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||||
<RightArrowIcon />
|
<RightArrowIcon />
|
||||||
@@ -482,7 +481,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</details>
|
</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">
|
<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">
|
<span class="mr-2 transition-transform duration-200 group-open:rotate-90">
|
||||||
<RightArrowIcon />
|
<RightArrowIcon />
|
||||||
@@ -493,6 +492,24 @@
|
|||||||
All prices are listed in United States Dollars (USD).
|
All prices are listed in United States Dollars (USD).
|
||||||
</p>
|
</p>
|
||||||
</details>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1020,7 +1020,7 @@ const nodeUnavailableDetails = computed(() => [
|
|||||||
{
|
{
|
||||||
label: "Error message",
|
label: "Error message",
|
||||||
value: nodeAccessible.value
|
value: nodeAccessible.value
|
||||||
? server.moduleErrors?.general?.error.message ?? "Unknown"
|
? (server.moduleErrors?.general?.error.message ?? "Unknown")
|
||||||
: "Unable to reach node. Ping test failed.",
|
: "Unable to reach node. Ping test failed.",
|
||||||
type: "block" as const,
|
type: "block" as const,
|
||||||
},
|
},
|
||||||
@@ -1277,7 +1277,8 @@ useHead({
|
|||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
filter: blur(1rem);
|
filter: blur(1rem);
|
||||||
content: "";
|
content: "";
|
||||||
background-image: linear-gradient(
|
background-image:
|
||||||
|
linear-gradient(
|
||||||
to bottom,
|
to bottom,
|
||||||
rgba(from var(--color-raised-bg) r g b / 0.2),
|
rgba(from var(--color-raised-bg) r g b / 0.2),
|
||||||
rgb(from var(--color-raised-bg) r g b / 0.8)
|
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 }">
|
<span :class="{ invisible: 'current_file' in op && !op.current_file }">
|
||||||
{{
|
{{
|
||||||
"current_file" in op
|
"current_file" in op
|
||||||
? op.current_file?.split("/")?.pop() ?? "unknown"
|
? (op.current_file?.split("/")?.pop() ?? "unknown")
|
||||||
: "unknown"
|
: "unknown"
|
||||||
}}
|
}}
|
||||||
</span>
|
</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",
|
"date": "2023-02-01T20:00:00.000Z",
|
||||||
"link": "https://modrinth.com/news/article/accelerating-development"
|
"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",
|
"title": "Modrinth's Anniversary Update",
|
||||||
"summary": "Marking two years of Modrinth and discussing our New Year's Resolutions for 2023.",
|
"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",
|
"date": "2023-01-07T00:00:00.000Z",
|
||||||
"link": "https://modrinth.com/news/article/two-years-of-modrinth"
|
"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!",
|
"title": "Creators can now make money on Modrinth!",
|
||||||
"summary": "Introducing the Creator Monetization Program allowing creators to earn revenue from their projects.",
|
"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",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -102,5 +102,5 @@
|
|||||||
true
|
true
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "99cca53fd3f35325e2da3b671532bf98b8c7ad8e7cb9158e4eb9c5bac66d20b2"
|
"hash": "cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055"
|
||||||
}
|
}
|
||||||
@@ -36,7 +36,7 @@ paste.workspace = true
|
|||||||
meilisearch-sdk = { workspace = true, features = ["reqwest"] }
|
meilisearch-sdk = { workspace = true, features = ["reqwest"] }
|
||||||
rust-s3.workspace = true
|
rust-s3.workspace = true
|
||||||
reqwest = { workspace = true, features = ["http2", "rustls-tls-webpki-roots", "json", "multipart"] }
|
reqwest = { workspace = true, features = ["http2", "rustls-tls-webpki-roots", "json", "multipart"] }
|
||||||
hyper-tls.workspace = true
|
hyper-rustls.workspace = true
|
||||||
hyper-util.workspace = true
|
hyper-util.workspace = true
|
||||||
|
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
|||||||
@@ -1,8 +1,21 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
FROM rust:1.88.0 AS build
|
FROM rust:1.88.0 AS build
|
||||||
|
|
||||||
WORKDIR /usr/src/labrinth
|
WORKDIR /usr/src/labrinth
|
||||||
COPY . .
|
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
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
@@ -11,13 +24,11 @@ LABEL org.opencontainers.image.description="Modrinth API"
|
|||||||
LABEL org.opencontainers.image.licenses=AGPL-3.0
|
LABEL org.opencontainers.image.licenses=AGPL-3.0
|
||||||
|
|
||||||
RUN apt-get update \
|
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/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY --from=build /usr/src/labrinth/target/release/labrinth /labrinth/labrinth
|
COPY --from=artifacts /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
|
|
||||||
|
|
||||||
|
WORKDIR /labrinth
|
||||||
ENTRYPOINT ["dumb-init", "--"]
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
CMD ["/labrinth/labrinth"]
|
CMD ["/labrinth/labrinth"]
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ pub enum AuthenticationError {
|
|||||||
InvalidAuthMethod,
|
InvalidAuthMethod,
|
||||||
#[error("GitHub Token from incorrect Client ID")]
|
#[error("GitHub Token from incorrect Client ID")]
|
||||||
InvalidClientId,
|
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,
|
DuplicateUser,
|
||||||
#[error("Invalid state sent, you probably need to get a new websocket")]
|
#[error("Invalid state sent, you probably need to get a new websocket")]
|
||||||
SocketError,
|
SocketError,
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
use hyper_tls::{HttpsConnector, native_tls};
|
use hyper_rustls::HttpsConnectorBuilder;
|
||||||
use hyper_util::client::legacy::connect::HttpConnector;
|
|
||||||
use hyper_util::rt::TokioExecutor;
|
use hyper_util::rt::TokioExecutor;
|
||||||
|
|
||||||
mod fetch;
|
mod fetch;
|
||||||
@@ -15,13 +14,11 @@ pub async fn init_client_with_database(
|
|||||||
database: &str,
|
database: &str,
|
||||||
) -> clickhouse::error::Result<clickhouse::Client> {
|
) -> clickhouse::error::Result<clickhouse::Client> {
|
||||||
let client = {
|
let client = {
|
||||||
let mut http_connector = HttpConnector::new();
|
let https_connector = HttpsConnectorBuilder::new()
|
||||||
http_connector.enforce_http(false); // allow https URLs
|
.with_native_roots()?
|
||||||
|
.https_or_http()
|
||||||
let tls_connector =
|
.enable_all_versions()
|
||||||
native_tls::TlsConnector::builder().build().unwrap().into();
|
.build();
|
||||||
let https_connector =
|
|
||||||
HttpsConnector::from((http_connector, tls_connector));
|
|
||||||
let hyper_client =
|
let hyper_client =
|
||||||
hyper_util::client::legacy::Client::builder(TokioExecutor::new())
|
hyper_util::client::legacy::Client::builder(TokioExecutor::new())
|
||||||
.build(https_connector);
|
.build(https_connector);
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ impl DBCharge {
|
|||||||
) -> Result<Option<DBCharge>, DatabaseError> {
|
) -> Result<Option<DBCharge>, DatabaseError> {
|
||||||
let user_subscription_id = user_subscription_id.0;
|
let user_subscription_id = user_subscription_id.0;
|
||||||
let res = select_charges_with_predicate!(
|
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
|
user_subscription_id
|
||||||
)
|
)
|
||||||
.fetch_optional(exec)
|
.fetch_optional(exec)
|
||||||
|
|||||||
@@ -223,8 +223,8 @@ impl TempUser {
|
|||||||
stripe_customer_id: None,
|
stripe_customer_id: None,
|
||||||
totp_secret: None,
|
totp_secret: None,
|
||||||
username,
|
username,
|
||||||
email: self.email,
|
email: self.email.clone(),
|
||||||
email_verified: true,
|
email_verified: self.email.is_some(),
|
||||||
avatar_url,
|
avatar_url,
|
||||||
raw_avatar_url,
|
raw_avatar_url,
|
||||||
bio: self.bio,
|
bio: self.bio,
|
||||||
@@ -1419,15 +1419,15 @@ pub async fn create_account_with_password(
|
|||||||
.hash_password(new_account.password.as_bytes(), &salt)?
|
.hash_password(new_account.password.as_bytes(), &salt)?
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
if crate::database::models::DBUser::get_by_email(
|
if !crate::database::models::DBUser::get_by_case_insensitive_email(
|
||||||
&new_account.email,
|
&new_account.email,
|
||||||
&**pool,
|
&**pool,
|
||||||
)
|
)
|
||||||
.await?
|
.await?
|
||||||
.is_some()
|
.is_empty()
|
||||||
{
|
{
|
||||||
return Err(ApiError::InvalidInput(
|
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?
|
.await?
|
||||||
.1;
|
.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?;
|
let mut transaction = pool.begin().await?;
|
||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
"app:build": "turbo run build --filter=@modrinth/app",
|
"app:build": "turbo run build --filter=@modrinth/app",
|
||||||
"app:fix": "turbo run fix --filter=@modrinth/app",
|
"app:fix": "turbo run fix --filter=@modrinth/app",
|
||||||
"app:intl:extract": "pnpm run --filter=@modrinth/app-frontend intl:extract",
|
"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",
|
"pages:build": "NITRO_PRESET=cloudflare-pages pnpm --filter frontend run build",
|
||||||
|
"moderation:fix": "turbo run fix --filter=@modrinth/moderation",
|
||||||
"build": "turbo run build --continue",
|
"build": "turbo run build --continue",
|
||||||
"lint": "turbo run lint --continue",
|
"lint": "turbo run lint --continue",
|
||||||
"test": "turbo run test --continue",
|
"test": "turbo run test --continue",
|
||||||
|
|||||||
@@ -1,2 +1,10 @@
|
|||||||
# SQLite database file location
|
MODRINTH_URL=http://localhost:3000/
|
||||||
DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db
|
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",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -32,6 +32,11 @@
|
|||||||
"name": "expires",
|
"name": "expires",
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "account_type",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -43,8 +48,9 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
|
false,
|
||||||
false
|
false
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"hash": "bf7d47350092d87c478009adaab131168e87bb37aa65c2156ad2cb6198426d8c"
|
"hash": "57214178fb3a0ccd8f67457e9732a706cbc4a4f5190c9320d1ad6111b9711d63"
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"db_name": "SQLite",
|
"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": {
|
"describe": {
|
||||||
"columns": [
|
"columns": [
|
||||||
{
|
{
|
||||||
@@ -32,6 +32,11 @@
|
|||||||
"name": "expires",
|
"name": "expires",
|
||||||
"ordinal": 5,
|
"ordinal": 5,
|
||||||
"type_info": "Integer"
|
"type_info": "Integer"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "account_type",
|
||||||
|
"ordinal": 6,
|
||||||
|
"type_info": "Text"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"parameters": {
|
"parameters": {
|
||||||
@@ -43,8 +48,9 @@
|
|||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
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
|
winreg.workspace = true
|
||||||
|
|
||||||
[build-dependencies]
|
[build-dependencies]
|
||||||
|
dotenvy.workspace = true
|
||||||
dunce.workspace = true
|
dunce.workspace = true
|
||||||
|
|
||||||
[features]
|
[features]
|
||||||
|
|||||||
@@ -4,12 +4,31 @@ use std::process::{Command, exit};
|
|||||||
use std::{env, fs};
|
use std::{env, fs};
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
|
println!("cargo::rerun-if-changed=.env");
|
||||||
println!("cargo::rerun-if-changed=java/gradle");
|
println!("cargo::rerun-if-changed=java/gradle");
|
||||||
println!("cargo::rerun-if-changed=java/src");
|
println!("cargo::rerun-if-changed=java/src");
|
||||||
println!("cargo::rerun-if-changed=java/build.gradle.kts");
|
println!("cargo::rerun-if-changed=java/build.gradle.kts");
|
||||||
println!("cargo::rerun-if-changed=java/settings.gradle.kts");
|
println!("cargo::rerun-if-changed=java/settings.gradle.kts");
|
||||||
println!("cargo::rerun-if-changed=java/gradle.properties");
|
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 =
|
let out_dir =
|
||||||
dunce::canonicalize(PathBuf::from(env::var_os("OUT_DIR").unwrap()))
|
dunce::canonicalize(PathBuf::from(env::var_os("OUT_DIR").unwrap()))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -37,6 +56,7 @@ fn main() {
|
|||||||
.current_dir(dunce::canonicalize("java").unwrap())
|
.current_dir(dunce::canonicalize("java").unwrap())
|
||||||
.status()
|
.status()
|
||||||
.expect("Failed to wait on Gradle build");
|
.expect("Failed to wait on Gradle build");
|
||||||
|
|
||||||
if !exit_status.success() {
|
if !exit_status.success() {
|
||||||
println!("cargo::error=Gradle build failed with {exit_status}");
|
println!("cargo::error=Gradle build failed with {exit_status}");
|
||||||
exit(exit_status.code().unwrap_or(1));
|
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';
|
||||||
@@ -28,6 +28,16 @@ pub async fn offline_auth(
|
|||||||
crate::state::offline_auth(name, &state.pool).await
|
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]
|
#[tracing::instrument]
|
||||||
pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> {
|
pub async fn get_default_user() -> crate::Result<Option<uuid::Uuid>> {
|
||||||
let state = State::get().await?;
|
let state = State::get().await?;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use crate::state::ModrinthCredentials;
|
use crate::state::ModrinthCredentials;
|
||||||
|
|
||||||
#[tracing::instrument]
|
#[tracing::instrument]
|
||||||
pub fn authenticate_begin_flow() -> String {
|
pub fn authenticate_begin_flow() -> &'static str {
|
||||||
crate::state::get_login_url()
|
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(),
|
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") {
|
if component.uid.starts_with("org.quiltmc.quilt-loader") {
|
||||||
return Some((
|
return Some((
|
||||||
PackDependency::QuiltLoader,
|
PackDependency::QuiltLoader,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ use crate::{
|
|||||||
|
|
||||||
pub mod atlauncher;
|
pub mod atlauncher;
|
||||||
pub mod curseforge;
|
pub mod curseforge;
|
||||||
|
pub mod curseforge_profile;
|
||||||
pub mod gdlauncher;
|
pub mod gdlauncher;
|
||||||
pub mod mmc;
|
pub mod mmc;
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
//! Configuration structs
|
|
||||||
|
|
||||||
// pub const MODRINTH_URL: &str = "https://staging.modrinth.com/";
|
|
||||||
// pub const MODRINTH_API_URL: &str = "https://staging-api.modrinth.com/v2/";
|
|
||||||
// pub const MODRINTH_API_URL_V3: &str = "https://staging-api.modrinth.com/v3/";
|
|
||||||
|
|
||||||
pub const MODRINTH_URL: &str = "https://modrinth.com/";
|
|
||||||
pub const MODRINTH_API_URL: &str = "https://api.modrinth.com/v2/";
|
|
||||||
pub const MODRINTH_API_URL_V3: &str = "https://api.modrinth.com/v3/";
|
|
||||||
|
|
||||||
pub const MODRINTH_SOCKET_URL: &str = "wss://api.modrinth.com/";
|
|
||||||
|
|
||||||
pub const META_URL: &str = "https://launcher-meta.modrinth.com/";
|
|
||||||
@@ -176,6 +176,9 @@ pub enum LoadingBarType {
|
|||||||
import_location: PathBuf,
|
import_location: PathBuf,
|
||||||
profile_name: String,
|
profile_name: String,
|
||||||
},
|
},
|
||||||
|
CurseForgeProfileDownload {
|
||||||
|
profile_name: String,
|
||||||
|
},
|
||||||
CheckingForUpdates,
|
CheckingForUpdates,
|
||||||
LauncherUpdate {
|
LauncherUpdate {
|
||||||
version: String,
|
version: String,
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ use crate::launcher::download::download_log_config;
|
|||||||
use crate::launcher::io::IOError;
|
use crate::launcher::io::IOError;
|
||||||
use crate::profile::QuickPlayType;
|
use crate::profile::QuickPlayType;
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
|
AccountType, Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
|
||||||
};
|
};
|
||||||
use crate::util::io;
|
use crate::util::{io, utils};
|
||||||
use crate::{State, get_resource_file, process, state as st};
|
use crate::{State, get_resource_file, process, state as st};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use daedalus as d;
|
use daedalus as d;
|
||||||
@@ -633,18 +633,32 @@ pub async fn launch_minecraft(
|
|||||||
command.arg("--add-opens=jdk.internal/jdk.internal.misc=ALL-UNNAMED");
|
command.arg("--add-opens=jdk.internal/jdk.internal.misc=ALL-UNNAMED");
|
||||||
}
|
}
|
||||||
|
|
||||||
// FIXME: Fix ElyBy integration with this patch.
|
|
||||||
// [AR] Patch
|
// [AR] Patch
|
||||||
if credentials.access_token == "null" && credentials.refresh_token == "null" {
|
if credentials.account_type == AccountType::Pirate.as_lowercase_str() {
|
||||||
if version_jar == "1.16.4" || version_jar == "1.16.5" {
|
if version_jar == "1.16.4" || version_jar == "1.16.5" {
|
||||||
let invalid_url = "https://invalid.invalid";
|
let invalid_url = "https://invalid.invalid";
|
||||||
tracing::info!("✅ JVM args is patched by AstralRinth for MC {}", version_jar);
|
tracing::info!(
|
||||||
|
"[AR] • The launcher detected the launch of {} on the offline account. Applying offline multiplayer fixes.",
|
||||||
|
version_jar
|
||||||
|
);
|
||||||
command.arg("-Dminecraft.api.env=custom");
|
command.arg("-Dminecraft.api.env=custom");
|
||||||
command.arg(format!("-Dminecraft.api.auth.host={}", invalid_url));
|
command.arg(format!("-Dminecraft.api.auth.host={}", invalid_url));
|
||||||
command.arg(format!("-Dminecraft.api.account.host={}", invalid_url));
|
command
|
||||||
command.arg(format!("-Dminecraft.api.session.host={}", invalid_url));
|
.arg(format!("-Dminecraft.api.account.host={}", invalid_url));
|
||||||
command.arg(format!("-Dminecraft.api.services.host={}", invalid_url));
|
command
|
||||||
|
.arg(format!("-Dminecraft.api.session.host={}", invalid_url));
|
||||||
|
command
|
||||||
|
.arg(format!("-Dminecraft.api.services.host={}", invalid_url));
|
||||||
}
|
}
|
||||||
|
} else if credentials.account_type == AccountType::ElyBy.as_lowercase_str()
|
||||||
|
{
|
||||||
|
tracing::info!(
|
||||||
|
"[AR] • The launcher detected the launch of {} on the Ely.by account. Applying Ely.by Java Injector.",
|
||||||
|
version_jar
|
||||||
|
);
|
||||||
|
let path_buf = utils::get_or_download_elyby_injector().await?;
|
||||||
|
let path = path_buf.to_str().unwrap();
|
||||||
|
command.arg(format!("-javaagent:{}=ely.by", path));
|
||||||
}
|
}
|
||||||
|
|
||||||
command
|
command
|
||||||
@@ -746,10 +760,10 @@ pub async fn launch_minecraft(
|
|||||||
|
|
||||||
// [AR] Feature
|
// [AR] Feature
|
||||||
let selected_phrase = ACTIVE_STATE.choose(&mut rand::thread_rng()).unwrap();
|
let selected_phrase = ACTIVE_STATE.choose(&mut rand::thread_rng()).unwrap();
|
||||||
let _ = state
|
let _ = state
|
||||||
.discord_rpc
|
.discord_rpc
|
||||||
.set_activity(&format!("{} {}", selected_phrase, profile.name), true)
|
.set_activity(&format!("{} {}", selected_phrase, profile.name), true)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let _ = state
|
let _ = state
|
||||||
.friends_socket
|
.friends_socket
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ and launching Modrinth mod packs
|
|||||||
pub mod util; // [AR] Refactor
|
pub mod util; // [AR] Refactor
|
||||||
|
|
||||||
mod api;
|
mod api;
|
||||||
mod config;
|
|
||||||
mod error;
|
mod error;
|
||||||
mod event;
|
mod event;
|
||||||
mod launcher;
|
mod launcher;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::config::{META_URL, MODRINTH_API_URL, MODRINTH_API_URL_V3};
|
|
||||||
use crate::state::ProjectType;
|
use crate::state::ProjectType;
|
||||||
use crate::util::fetch::{FetchSemaphore, fetch_json, sha1_async};
|
use crate::util::fetch::{FetchSemaphore, fetch_json, sha1_async};
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
@@ -8,6 +7,7 @@ use serde::de::DeserializeOwned;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use sqlx::SqlitePool;
|
use sqlx::SqlitePool;
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::env;
|
||||||
use std::fmt::Display;
|
use std::fmt::Display;
|
||||||
use std::hash::Hash;
|
use std::hash::Hash;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
@@ -945,7 +945,7 @@ impl CachedEntry {
|
|||||||
CacheValueType::Project => {
|
CacheValueType::Project => {
|
||||||
fetch_original_values!(
|
fetch_original_values!(
|
||||||
Project,
|
Project,
|
||||||
MODRINTH_API_URL,
|
env!("MODRINTH_API_URL"),
|
||||||
"projects",
|
"projects",
|
||||||
CacheValue::Project
|
CacheValue::Project
|
||||||
)
|
)
|
||||||
@@ -953,7 +953,7 @@ impl CachedEntry {
|
|||||||
CacheValueType::Version => {
|
CacheValueType::Version => {
|
||||||
fetch_original_values!(
|
fetch_original_values!(
|
||||||
Version,
|
Version,
|
||||||
MODRINTH_API_URL,
|
env!("MODRINTH_API_URL"),
|
||||||
"versions",
|
"versions",
|
||||||
CacheValue::Version
|
CacheValue::Version
|
||||||
)
|
)
|
||||||
@@ -961,7 +961,7 @@ impl CachedEntry {
|
|||||||
CacheValueType::User => {
|
CacheValueType::User => {
|
||||||
fetch_original_values!(
|
fetch_original_values!(
|
||||||
User,
|
User,
|
||||||
MODRINTH_API_URL,
|
env!("MODRINTH_API_URL"),
|
||||||
"users",
|
"users",
|
||||||
CacheValue::User
|
CacheValue::User
|
||||||
)
|
)
|
||||||
@@ -969,7 +969,7 @@ impl CachedEntry {
|
|||||||
CacheValueType::Team => {
|
CacheValueType::Team => {
|
||||||
let mut teams = fetch_many_batched::<Vec<TeamMember>>(
|
let mut teams = fetch_many_batched::<Vec<TeamMember>>(
|
||||||
Method::GET,
|
Method::GET,
|
||||||
MODRINTH_API_URL_V3,
|
env!("MODRINTH_API_URL_V3"),
|
||||||
"teams?ids=",
|
"teams?ids=",
|
||||||
&keys,
|
&keys,
|
||||||
fetch_semaphore,
|
fetch_semaphore,
|
||||||
@@ -1008,7 +1008,7 @@ impl CachedEntry {
|
|||||||
CacheValueType::Organization => {
|
CacheValueType::Organization => {
|
||||||
let mut orgs = fetch_many_batched::<Organization>(
|
let mut orgs = fetch_many_batched::<Organization>(
|
||||||
Method::GET,
|
Method::GET,
|
||||||
MODRINTH_API_URL_V3,
|
env!("MODRINTH_API_URL_V3"),
|
||||||
"organizations?ids=",
|
"organizations?ids=",
|
||||||
&keys,
|
&keys,
|
||||||
fetch_semaphore,
|
fetch_semaphore,
|
||||||
@@ -1063,7 +1063,7 @@ impl CachedEntry {
|
|||||||
CacheValueType::File => {
|
CacheValueType::File => {
|
||||||
let mut versions = fetch_json::<HashMap<String, Version>>(
|
let mut versions = fetch_json::<HashMap<String, Version>>(
|
||||||
Method::POST,
|
Method::POST,
|
||||||
&format!("{MODRINTH_API_URL}version_files"),
|
concat!(env!("MODRINTH_API_URL"), "version_files"),
|
||||||
None,
|
None,
|
||||||
Some(serde_json::json!({
|
Some(serde_json::json!({
|
||||||
"algorithm": "sha1",
|
"algorithm": "sha1",
|
||||||
@@ -1119,7 +1119,11 @@ impl CachedEntry {
|
|||||||
.map(|x| {
|
.map(|x| {
|
||||||
(
|
(
|
||||||
x.key().to_string(),
|
x.key().to_string(),
|
||||||
format!("{META_URL}{}/v0/manifest.json", x.key()),
|
format!(
|
||||||
|
"{}{}/v0/manifest.json",
|
||||||
|
env!("MODRINTH_LAUNCHER_META_URL"),
|
||||||
|
x.key()
|
||||||
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
@@ -1154,7 +1158,7 @@ impl CachedEntry {
|
|||||||
CacheValueType::MinecraftManifest => {
|
CacheValueType::MinecraftManifest => {
|
||||||
fetch_original_value!(
|
fetch_original_value!(
|
||||||
MinecraftManifest,
|
MinecraftManifest,
|
||||||
META_URL,
|
env!("MODRINTH_LAUNCHER_META_URL"),
|
||||||
format!(
|
format!(
|
||||||
"minecraft/v{}/manifest.json",
|
"minecraft/v{}/manifest.json",
|
||||||
daedalus::minecraft::CURRENT_FORMAT_VERSION
|
daedalus::minecraft::CURRENT_FORMAT_VERSION
|
||||||
@@ -1165,7 +1169,7 @@ impl CachedEntry {
|
|||||||
CacheValueType::Categories => {
|
CacheValueType::Categories => {
|
||||||
fetch_original_value!(
|
fetch_original_value!(
|
||||||
Categories,
|
Categories,
|
||||||
MODRINTH_API_URL,
|
env!("MODRINTH_API_URL"),
|
||||||
"tag/category",
|
"tag/category",
|
||||||
CacheValue::Categories
|
CacheValue::Categories
|
||||||
)
|
)
|
||||||
@@ -1173,7 +1177,7 @@ impl CachedEntry {
|
|||||||
CacheValueType::ReportTypes => {
|
CacheValueType::ReportTypes => {
|
||||||
fetch_original_value!(
|
fetch_original_value!(
|
||||||
ReportTypes,
|
ReportTypes,
|
||||||
MODRINTH_API_URL,
|
env!("MODRINTH_API_URL"),
|
||||||
"tag/report_type",
|
"tag/report_type",
|
||||||
CacheValue::ReportTypes
|
CacheValue::ReportTypes
|
||||||
)
|
)
|
||||||
@@ -1181,7 +1185,7 @@ impl CachedEntry {
|
|||||||
CacheValueType::Loaders => {
|
CacheValueType::Loaders => {
|
||||||
fetch_original_value!(
|
fetch_original_value!(
|
||||||
Loaders,
|
Loaders,
|
||||||
MODRINTH_API_URL,
|
env!("MODRINTH_API_URL"),
|
||||||
"tag/loader",
|
"tag/loader",
|
||||||
CacheValue::Loaders
|
CacheValue::Loaders
|
||||||
)
|
)
|
||||||
@@ -1189,7 +1193,7 @@ impl CachedEntry {
|
|||||||
CacheValueType::GameVersions => {
|
CacheValueType::GameVersions => {
|
||||||
fetch_original_value!(
|
fetch_original_value!(
|
||||||
GameVersions,
|
GameVersions,
|
||||||
MODRINTH_API_URL,
|
env!("MODRINTH_API_URL"),
|
||||||
"tag/game_version",
|
"tag/game_version",
|
||||||
CacheValue::GameVersions
|
CacheValue::GameVersions
|
||||||
)
|
)
|
||||||
@@ -1197,7 +1201,7 @@ impl CachedEntry {
|
|||||||
CacheValueType::DonationPlatforms => {
|
CacheValueType::DonationPlatforms => {
|
||||||
fetch_original_value!(
|
fetch_original_value!(
|
||||||
DonationPlatforms,
|
DonationPlatforms,
|
||||||
MODRINTH_API_URL,
|
env!("MODRINTH_API_URL"),
|
||||||
"tag/donation_platform",
|
"tag/donation_platform",
|
||||||
CacheValue::DonationPlatforms
|
CacheValue::DonationPlatforms
|
||||||
)
|
)
|
||||||
@@ -1297,14 +1301,12 @@ impl CachedEntry {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let version_update_url =
|
|
||||||
format!("{MODRINTH_API_URL}version_files/update");
|
|
||||||
let variations =
|
let variations =
|
||||||
futures::future::try_join_all(filtered_keys.iter().map(
|
futures::future::try_join_all(filtered_keys.iter().map(
|
||||||
|((loaders_key, game_version), hashes)| {
|
|((loaders_key, game_version), hashes)| {
|
||||||
fetch_json::<HashMap<String, Version>>(
|
fetch_json::<HashMap<String, Version>>(
|
||||||
Method::POST,
|
Method::POST,
|
||||||
&version_update_url,
|
concat!(env!("MODRINTH_API_URL"), "version_files/update"),
|
||||||
None,
|
None,
|
||||||
Some(serde_json::json!({
|
Some(serde_json::json!({
|
||||||
"algorithm": "sha1",
|
"algorithm": "sha1",
|
||||||
@@ -1368,7 +1370,11 @@ impl CachedEntry {
|
|||||||
.map(|x| {
|
.map(|x| {
|
||||||
(
|
(
|
||||||
x.key().to_string(),
|
x.key().to_string(),
|
||||||
format!("{MODRINTH_API_URL}search{}", x.key()),
|
format!(
|
||||||
|
"{}search{}",
|
||||||
|
env!("MODRINTH_API_URL"),
|
||||||
|
x.key()
|
||||||
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::config::{MODRINTH_API_URL_V3, MODRINTH_SOCKET_URL};
|
|
||||||
use crate::data::ModrinthCredentials;
|
use crate::data::ModrinthCredentials;
|
||||||
use crate::event::FriendPayload;
|
use crate::event::FriendPayload;
|
||||||
use crate::event::emit::emit_friend;
|
use crate::event::emit::emit_friend;
|
||||||
@@ -77,7 +76,8 @@ impl FriendsSocket {
|
|||||||
|
|
||||||
if let Some(credentials) = credentials {
|
if let Some(credentials) = credentials {
|
||||||
let mut request = format!(
|
let mut request = format!(
|
||||||
"{MODRINTH_SOCKET_URL}_internal/launcher_socket?code={}",
|
"{}_internal/launcher_socket?code={}",
|
||||||
|
env!("MODRINTH_SOCKET_URL"),
|
||||||
credentials.session
|
credentials.session
|
||||||
)
|
)
|
||||||
.into_client_request()?;
|
.into_client_request()?;
|
||||||
@@ -303,7 +303,7 @@ impl FriendsSocket {
|
|||||||
) -> crate::Result<Vec<UserFriend>> {
|
) -> crate::Result<Vec<UserFriend>> {
|
||||||
fetch_json(
|
fetch_json(
|
||||||
Method::GET,
|
Method::GET,
|
||||||
&format!("{MODRINTH_API_URL_V3}friends"),
|
concat!(env!("MODRINTH_API_URL_V3"), "friends"),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
semaphore,
|
semaphore,
|
||||||
@@ -328,7 +328,7 @@ impl FriendsSocket {
|
|||||||
) -> crate::Result<()> {
|
) -> crate::Result<()> {
|
||||||
fetch_advanced(
|
fetch_advanced(
|
||||||
Method::POST,
|
Method::POST,
|
||||||
&format!("{MODRINTH_API_URL_V3}friend/{user_id}"),
|
&format!("{}friend/{user_id}", env!("MODRINTH_API_URL_V3")),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
@@ -349,7 +349,7 @@ impl FriendsSocket {
|
|||||||
) -> crate::Result<()> {
|
) -> crate::Result<()> {
|
||||||
fetch_advanced(
|
fetch_advanced(
|
||||||
Method::DELETE,
|
Method::DELETE,
|
||||||
&format!("{MODRINTH_API_URL_V3}friend/{user_id}"),
|
&format!("{}friend/{user_id}", env!("MODRINTH_API_URL_V3")),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
|||||||
@@ -131,6 +131,7 @@ where
|
|||||||
expires: legacy_credentials.expires,
|
expires: legacy_credentials.expires,
|
||||||
active: minecraft_auth.default_user == Some(uuid)
|
active: minecraft_auth.default_user == Some(uuid)
|
||||||
|| minecraft_users_len == 1,
|
|| minecraft_users_len == 1,
|
||||||
|
account_type: legacy_credentials.account_type,
|
||||||
}
|
}
|
||||||
.upsert(exec)
|
.upsert(exec)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -518,6 +519,7 @@ struct LegacyCredentials {
|
|||||||
pub access_token: String,
|
pub access_token: String,
|
||||||
pub refresh_token: String,
|
pub refresh_token: String,
|
||||||
pub expires: DateTime<Utc>,
|
pub expires: DateTime<Utc>,
|
||||||
|
pub account_type: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
|
|||||||
@@ -85,21 +85,18 @@ pub struct MinecraftLoginFlow {
|
|||||||
pub verifier: String,
|
pub verifier: String,
|
||||||
pub challenge: String,
|
pub challenge: String,
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
pub redirect_uri: String,
|
pub auth_request_uri: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tracing::instrument]
|
#[tracing::instrument]
|
||||||
pub async fn login_begin(
|
pub async fn login_begin(
|
||||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||||
) -> crate::Result<MinecraftLoginFlow> {
|
) -> crate::Result<MinecraftLoginFlow> {
|
||||||
let (pair, current_date, valid_date) =
|
let (pair, current_date) =
|
||||||
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), false, exec)
|
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), exec).await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
let verifier = generate_oauth_challenge();
|
let verifier = generate_oauth_challenge();
|
||||||
let mut hasher = sha2::Sha256::new();
|
let result = sha2::Sha256::digest(&verifier);
|
||||||
hasher.update(&verifier);
|
|
||||||
let result = hasher.finalize();
|
|
||||||
let challenge = BASE64_URL_SAFE_NO_PAD.encode(result);
|
let challenge = BASE64_URL_SAFE_NO_PAD.encode(result);
|
||||||
|
|
||||||
match sisu_authenticate(
|
match sisu_authenticate(
|
||||||
@@ -110,46 +107,15 @@ pub async fn login_begin(
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok((session_id, redirect_uri)) => Ok(MinecraftLoginFlow {
|
Ok((session_id, redirect_uri)) => {
|
||||||
verifier,
|
return Ok(MinecraftLoginFlow {
|
||||||
challenge,
|
verifier,
|
||||||
session_id,
|
challenge,
|
||||||
redirect_uri: redirect_uri.value.msa_oauth_redirect,
|
session_id,
|
||||||
}),
|
auth_request_uri: redirect_uri.value.msa_oauth_redirect,
|
||||||
Err(err) => {
|
});
|
||||||
if !valid_date {
|
|
||||||
let (pair, current_date, _) =
|
|
||||||
DeviceTokenPair::refresh_and_get_device_token(
|
|
||||||
Utc::now(),
|
|
||||||
false,
|
|
||||||
exec,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let verifier = generate_oauth_challenge();
|
|
||||||
let mut hasher = sha2::Sha256::new();
|
|
||||||
hasher.update(&verifier);
|
|
||||||
let result = hasher.finalize();
|
|
||||||
let challenge = BASE64_URL_SAFE_NO_PAD.encode(result);
|
|
||||||
|
|
||||||
let (session_id, redirect_uri) = sisu_authenticate(
|
|
||||||
&pair.token.token,
|
|
||||||
&challenge,
|
|
||||||
&pair.key,
|
|
||||||
current_date,
|
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
Ok(MinecraftLoginFlow {
|
|
||||||
verifier,
|
|
||||||
challenge,
|
|
||||||
session_id,
|
|
||||||
redirect_uri: redirect_uri.value.msa_oauth_redirect,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
Err(crate::ErrorKind::from(err).into())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
Err(err) => return Err(crate::ErrorKind::from(err).into()),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,9 +125,8 @@ pub async fn login_finish(
|
|||||||
flow: MinecraftLoginFlow,
|
flow: MinecraftLoginFlow,
|
||||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||||
) -> crate::Result<Credentials> {
|
) -> crate::Result<Credentials> {
|
||||||
let (pair, _, _) =
|
let (pair, _) =
|
||||||
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), false, exec)
|
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), exec).await?;
|
||||||
.await?;
|
|
||||||
|
|
||||||
let oauth_token = oauth_token(code, &flow.verifier).await?;
|
let oauth_token = oauth_token(code, &flow.verifier).await?;
|
||||||
let sisu_authorize = sisu_authorize(
|
let sisu_authorize = sisu_authorize(
|
||||||
@@ -191,6 +156,7 @@ pub async fn login_finish(
|
|||||||
expires: oauth_token.date
|
expires: oauth_token.date
|
||||||
+ Duration::seconds(oauth_token.value.expires_in as i64),
|
+ Duration::seconds(oauth_token.value.expires_in as i64),
|
||||||
active: true,
|
active: true,
|
||||||
|
account_type: AccountType::Microsoft.as_lowercase_str(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// During login, we need to fetch the online profile at least once to get the
|
// During login, we need to fetch the online profile at least once to get the
|
||||||
@@ -229,6 +195,7 @@ pub async fn offline_auth(
|
|||||||
refresh_token: refresh_token,
|
refresh_token: refresh_token,
|
||||||
expires: Utc::now() + Duration::days(365 * 99),
|
expires: Utc::now() + Duration::days(365 * 99),
|
||||||
active: true,
|
active: true,
|
||||||
|
account_type: AccountType::Pirate.as_lowercase_str(),
|
||||||
};
|
};
|
||||||
|
|
||||||
credentials.offline_profile = MinecraftProfile {
|
credentials.offline_profile = MinecraftProfile {
|
||||||
@@ -242,6 +209,58 @@ pub async fn offline_auth(
|
|||||||
Ok(credentials)
|
Ok(credentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [AR] Feature
|
||||||
|
#[tracing::instrument]
|
||||||
|
pub async fn elyby_auth(
|
||||||
|
uuid: Uuid,
|
||||||
|
username: &str,
|
||||||
|
access_token: &str,
|
||||||
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||||
|
) -> crate::Result<Credentials> {
|
||||||
|
let mut credentials = Credentials {
|
||||||
|
offline_profile: MinecraftProfile::default(),
|
||||||
|
access_token: access_token.to_string(),
|
||||||
|
refresh_token: "null".to_string(),
|
||||||
|
expires: Utc::now() + Duration::days(365 * 99),
|
||||||
|
active: true,
|
||||||
|
account_type: AccountType::ElyBy.as_lowercase_str(),
|
||||||
|
};
|
||||||
|
|
||||||
|
credentials.offline_profile = MinecraftProfile {
|
||||||
|
id: uuid,
|
||||||
|
name: username.to_string(),
|
||||||
|
..credentials.offline_profile
|
||||||
|
};
|
||||||
|
|
||||||
|
credentials.upsert(exec).await?;
|
||||||
|
|
||||||
|
Ok(credentials)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [AR] • Feature
|
||||||
|
#[derive(Deserialize, Debug)]
|
||||||
|
pub enum AccountType {
|
||||||
|
Unknown,
|
||||||
|
Microsoft,
|
||||||
|
Pirate,
|
||||||
|
ElyBy,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AccountType {
|
||||||
|
fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
AccountType::Unknown => "Unknown",
|
||||||
|
AccountType::Microsoft => "Microsoft",
|
||||||
|
AccountType::Pirate => "Pirate",
|
||||||
|
AccountType::ElyBy => "ElyBy",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn as_lowercase_str(&self) -> String {
|
||||||
|
self.as_str().to_lowercase()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize, Debug)]
|
#[derive(Deserialize, Debug)]
|
||||||
pub struct Credentials {
|
pub struct Credentials {
|
||||||
/// The offline profile of the user these credentials are for.
|
/// The offline profile of the user these credentials are for.
|
||||||
@@ -255,6 +274,7 @@ pub struct Credentials {
|
|||||||
pub refresh_token: String,
|
pub refresh_token: String,
|
||||||
pub expires: DateTime<Utc>,
|
pub expires: DateTime<Utc>,
|
||||||
pub active: bool,
|
pub active: bool,
|
||||||
|
pub account_type: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An entry in the player profile cache, keyed by player UUID.
|
/// An entry in the player profile cache, keyed by player UUID.
|
||||||
@@ -296,10 +316,9 @@ impl Credentials {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let oauth_token = oauth_refresh(&self.refresh_token).await?;
|
let oauth_token = oauth_refresh(&self.refresh_token).await?;
|
||||||
let (pair, current_date, _) =
|
let (pair, current_date) =
|
||||||
DeviceTokenPair::refresh_and_get_device_token(
|
DeviceTokenPair::refresh_and_get_device_token(
|
||||||
oauth_token.date,
|
oauth_token.date,
|
||||||
false,
|
|
||||||
exec,
|
exec,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -480,7 +499,7 @@ impl Credentials {
|
|||||||
let res = sqlx::query!(
|
let res = sqlx::query!(
|
||||||
"
|
"
|
||||||
SELECT
|
SELECT
|
||||||
uuid, active, username, access_token, refresh_token, expires
|
uuid, active, username, access_token, refresh_token, expires, account_type
|
||||||
FROM minecraft_users
|
FROM minecraft_users
|
||||||
WHERE active = TRUE
|
WHERE active = TRUE
|
||||||
"
|
"
|
||||||
@@ -503,6 +522,7 @@ impl Credentials {
|
|||||||
.single()
|
.single()
|
||||||
.unwrap_or_else(Utc::now),
|
.unwrap_or_else(Utc::now),
|
||||||
active: x.active == 1,
|
active: x.active == 1,
|
||||||
|
account_type: x.account_type,
|
||||||
};
|
};
|
||||||
credentials.refresh(exec).await.ok();
|
credentials.refresh(exec).await.ok();
|
||||||
Some(credentials)
|
Some(credentials)
|
||||||
@@ -517,7 +537,7 @@ impl Credentials {
|
|||||||
let res = sqlx::query!(
|
let res = sqlx::query!(
|
||||||
"
|
"
|
||||||
SELECT
|
SELECT
|
||||||
uuid, active, username, access_token, refresh_token, expires
|
uuid, active, username, access_token, refresh_token, expires, account_type
|
||||||
FROM minecraft_users
|
FROM minecraft_users
|
||||||
"
|
"
|
||||||
)
|
)
|
||||||
@@ -537,6 +557,7 @@ impl Credentials {
|
|||||||
.single()
|
.single()
|
||||||
.unwrap_or_else(Utc::now),
|
.unwrap_or_else(Utc::now),
|
||||||
active: x.active == 1,
|
active: x.active == 1,
|
||||||
|
account_type: x.account_type,
|
||||||
};
|
};
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
@@ -572,14 +593,15 @@ impl Credentials {
|
|||||||
|
|
||||||
sqlx::query!(
|
sqlx::query!(
|
||||||
"
|
"
|
||||||
INSERT INTO minecraft_users (uuid, active, username, access_token, refresh_token, expires)
|
INSERT INTO minecraft_users (uuid, active, username, access_token, refresh_token, expires, account_type)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6)
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
ON CONFLICT (uuid) DO UPDATE SET
|
ON CONFLICT (uuid) DO UPDATE SET
|
||||||
active = $2,
|
active = $2,
|
||||||
username = $3,
|
username = $3,
|
||||||
access_token = $4,
|
access_token = $4,
|
||||||
refresh_token = $5,
|
refresh_token = $5,
|
||||||
expires = $6
|
expires = $6,
|
||||||
|
account_type = $7
|
||||||
",
|
",
|
||||||
uuid,
|
uuid,
|
||||||
self.active,
|
self.active,
|
||||||
@@ -587,6 +609,7 @@ impl Credentials {
|
|||||||
self.access_token,
|
self.access_token,
|
||||||
self.refresh_token,
|
self.refresh_token,
|
||||||
expires,
|
expires,
|
||||||
|
self.account_type,
|
||||||
)
|
)
|
||||||
.execute(exec)
|
.execute(exec)
|
||||||
.await?;
|
.await?;
|
||||||
@@ -649,6 +672,7 @@ impl Serialize for Credentials {
|
|||||||
ser.serialize_field("refresh_token", &self.refresh_token)?;
|
ser.serialize_field("refresh_token", &self.refresh_token)?;
|
||||||
ser.serialize_field("expires", &self.expires)?;
|
ser.serialize_field("expires", &self.expires)?;
|
||||||
ser.serialize_field("active", &self.active)?;
|
ser.serialize_field("active", &self.active)?;
|
||||||
|
ser.serialize_field("account_type", &self.account_type)?;
|
||||||
ser.end()
|
ser.end()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -662,21 +686,20 @@ impl DeviceTokenPair {
|
|||||||
#[tracing::instrument(skip(exec))]
|
#[tracing::instrument(skip(exec))]
|
||||||
async fn refresh_and_get_device_token(
|
async fn refresh_and_get_device_token(
|
||||||
current_date: DateTime<Utc>,
|
current_date: DateTime<Utc>,
|
||||||
force_generate: bool,
|
|
||||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||||
) -> crate::Result<(Self, DateTime<Utc>, bool)> {
|
) -> crate::Result<(Self, DateTime<Utc>)> {
|
||||||
let pair = Self::get(exec).await?;
|
let pair = Self::get(exec).await?;
|
||||||
|
|
||||||
if let Some(mut pair) = pair {
|
if let Some(mut pair) = pair {
|
||||||
if pair.token.not_after > Utc::now() && !force_generate {
|
if pair.token.not_after > current_date {
|
||||||
Ok((pair, current_date, false))
|
Ok((pair, current_date))
|
||||||
} else {
|
} else {
|
||||||
let res = device_token(&pair.key, current_date).await?;
|
let res = device_token(&pair.key, current_date).await?;
|
||||||
|
|
||||||
pair.token = res.value;
|
pair.token = res.value;
|
||||||
pair.upsert(exec).await?;
|
pair.upsert(exec).await?;
|
||||||
|
|
||||||
Ok((pair, res.date, true))
|
Ok((pair, res.date))
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let key = generate_key()?;
|
let key = generate_key()?;
|
||||||
@@ -689,7 +712,7 @@ impl DeviceTokenPair {
|
|||||||
|
|
||||||
pair.upsert(exec).await?;
|
pair.upsert(exec).await?;
|
||||||
|
|
||||||
Ok((pair, res.date, true))
|
Ok((pair, res.date))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -787,8 +810,8 @@ impl DeviceTokenPair {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const MICROSOFT_CLIENT_ID: &str = "00000000402b5328";
|
const MICROSOFT_CLIENT_ID: &str = "00000000402b5328";
|
||||||
const REDIRECT_URL: &str = "https://login.live.com/oauth20_desktop.srf";
|
const AUTH_REPLY_URL: &str = "https://login.live.com/oauth20_desktop.srf";
|
||||||
const REQUESTED_SCOPES: &str = "service::user.auth.xboxlive.com::MBI_SSL";
|
const REQUESTED_SCOPE: &str = "service::user.auth.xboxlive.com::MBI_SSL";
|
||||||
|
|
||||||
/* [AR] Fix
|
/* [AR] Fix
|
||||||
* Weird visibility issue that didn't reproduce before
|
* Weird visibility issue that didn't reproduce before
|
||||||
@@ -871,7 +894,7 @@ async fn sisu_authenticate(
|
|||||||
"AppId": MICROSOFT_CLIENT_ID,
|
"AppId": MICROSOFT_CLIENT_ID,
|
||||||
"DeviceToken": token,
|
"DeviceToken": token,
|
||||||
"Offers": [
|
"Offers": [
|
||||||
REQUESTED_SCOPES
|
REQUESTED_SCOPE
|
||||||
],
|
],
|
||||||
"Query": {
|
"Query": {
|
||||||
"code_challenge": challenge,
|
"code_challenge": challenge,
|
||||||
@@ -879,7 +902,7 @@ async fn sisu_authenticate(
|
|||||||
"state": generate_oauth_challenge(),
|
"state": generate_oauth_challenge(),
|
||||||
"prompt": "select_account"
|
"prompt": "select_account"
|
||||||
},
|
},
|
||||||
"RedirectUri": REDIRECT_URL,
|
"RedirectUri": AUTH_REPLY_URL,
|
||||||
"Sandbox": "RETAIL",
|
"Sandbox": "RETAIL",
|
||||||
"TokenType": "code",
|
"TokenType": "code",
|
||||||
"TitleId": "1794566092",
|
"TitleId": "1794566092",
|
||||||
@@ -923,12 +946,12 @@ async fn oauth_token(
|
|||||||
verifier: &str,
|
verifier: &str,
|
||||||
) -> Result<RequestWithDate<OAuthToken>, MinecraftAuthenticationError> {
|
) -> Result<RequestWithDate<OAuthToken>, MinecraftAuthenticationError> {
|
||||||
let mut query = HashMap::new();
|
let mut query = HashMap::new();
|
||||||
query.insert("client_id", "00000000402b5328");
|
query.insert("client_id", MICROSOFT_CLIENT_ID);
|
||||||
query.insert("code", code);
|
query.insert("code", code);
|
||||||
query.insert("code_verifier", verifier);
|
query.insert("code_verifier", verifier);
|
||||||
query.insert("grant_type", "authorization_code");
|
query.insert("grant_type", "authorization_code");
|
||||||
query.insert("redirect_uri", "https://login.live.com/oauth20_desktop.srf");
|
query.insert("redirect_uri", AUTH_REPLY_URL);
|
||||||
query.insert("scope", "service::user.auth.xboxlive.com::MBI_SSL");
|
query.insert("scope", REQUESTED_SCOPE);
|
||||||
|
|
||||||
let res = auth_retry(|| {
|
let res = auth_retry(|| {
|
||||||
REQWEST_CLIENT
|
REQWEST_CLIENT
|
||||||
@@ -972,11 +995,11 @@ async fn oauth_refresh(
|
|||||||
refresh_token: &str,
|
refresh_token: &str,
|
||||||
) -> Result<RequestWithDate<OAuthToken>, MinecraftAuthenticationError> {
|
) -> Result<RequestWithDate<OAuthToken>, MinecraftAuthenticationError> {
|
||||||
let mut query = HashMap::new();
|
let mut query = HashMap::new();
|
||||||
query.insert("client_id", "00000000402b5328");
|
query.insert("client_id", MICROSOFT_CLIENT_ID);
|
||||||
query.insert("refresh_token", refresh_token);
|
query.insert("refresh_token", refresh_token);
|
||||||
query.insert("grant_type", "refresh_token");
|
query.insert("grant_type", "refresh_token");
|
||||||
query.insert("redirect_uri", "https://login.live.com/oauth20_desktop.srf");
|
query.insert("redirect_uri", AUTH_REPLY_URL);
|
||||||
query.insert("scope", "service::user.auth.xboxlive.com::MBI_SSL");
|
query.insert("scope", REQUESTED_SCOPE);
|
||||||
|
|
||||||
let res = auth_retry(|| {
|
let res = auth_retry(|| {
|
||||||
REQWEST_CLIENT
|
REQWEST_CLIENT
|
||||||
@@ -1040,7 +1063,7 @@ async fn sisu_authorize(
|
|||||||
"/authorize",
|
"/authorize",
|
||||||
json!({
|
json!({
|
||||||
"AccessToken": format!("t={access_token}"),
|
"AccessToken": format!("t={access_token}"),
|
||||||
"AppId": "00000000402b5328",
|
"AppId": MICROSOFT_CLIENT_ID,
|
||||||
"DeviceToken": device_token,
|
"DeviceToken": device_token,
|
||||||
"ProofKey": {
|
"ProofKey": {
|
||||||
"kty": "EC",
|
"kty": "EC",
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
use crate::config::{MODRINTH_API_URL, MODRINTH_URL};
|
|
||||||
use crate::state::{CacheBehaviour, CachedEntry};
|
use crate::state::{CacheBehaviour, CachedEntry};
|
||||||
use crate::util::fetch::{FetchSemaphore, fetch_advanced};
|
use crate::util::fetch::{FetchSemaphore, fetch_advanced};
|
||||||
use chrono::{DateTime, Duration, TimeZone, Utc};
|
use chrono::{DateTime, Duration, TimeZone, Utc};
|
||||||
@@ -31,7 +30,7 @@ impl ModrinthCredentials {
|
|||||||
|
|
||||||
let resp = fetch_advanced(
|
let resp = fetch_advanced(
|
||||||
Method::POST,
|
Method::POST,
|
||||||
&format!("{MODRINTH_API_URL}session/refresh"),
|
concat!(env!("MODRINTH_API_URL"), "session/refresh"),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(("Authorization", &*creds.session)),
|
Some(("Authorization", &*creds.session)),
|
||||||
@@ -190,8 +189,8 @@ impl ModrinthCredentials {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_login_url() -> String {
|
pub const fn get_login_url() -> &'static str {
|
||||||
format!("{MODRINTH_URL}auth/sign-in?launcher=true")
|
concat!(env!("MODRINTH_URL"), "auth/sign-in")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn finish_login_flow(
|
pub async fn finish_login_flow(
|
||||||
@@ -199,6 +198,12 @@ pub async fn finish_login_flow(
|
|||||||
semaphore: &FetchSemaphore,
|
semaphore: &FetchSemaphore,
|
||||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||||
) -> crate::Result<ModrinthCredentials> {
|
) -> crate::Result<ModrinthCredentials> {
|
||||||
|
// The authorization code actually is the access token, since Labrinth doesn't
|
||||||
|
// issue separate authorization codes. Therefore, this is equivalent to an
|
||||||
|
// implicit OAuth grant flow, and no additional exchanging or finalization is
|
||||||
|
// needed. TODO not do this for the reasons outlined at
|
||||||
|
// https://oauth.net/2/grant-types/implicit/
|
||||||
|
|
||||||
let info = fetch_info(code, semaphore, exec).await?;
|
let info = fetch_info(code, semaphore, exec).await?;
|
||||||
|
|
||||||
Ok(ModrinthCredentials {
|
Ok(ModrinthCredentials {
|
||||||
@@ -216,7 +221,7 @@ async fn fetch_info(
|
|||||||
) -> crate::Result<crate::state::cache::User> {
|
) -> crate::Result<crate::state::cache::User> {
|
||||||
let result = fetch_advanced(
|
let result = fetch_advanced(
|
||||||
Method::GET,
|
Method::GET,
|
||||||
&format!("{MODRINTH_API_URL}user"),
|
concat!(env!("MODRINTH_API_URL"), "user"),
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
Some(("Authorization", token)),
|
Some(("Authorization", token)),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
//! Functions for fetching information from the Internet
|
//! Functions for fetching information from the Internet
|
||||||
use super::io::{self, IOError};
|
use super::io::{self, IOError};
|
||||||
use crate::config::{MODRINTH_API_URL, MODRINTH_API_URL_V3};
|
|
||||||
use crate::event::LoadingBarId;
|
use crate::event::LoadingBarId;
|
||||||
use crate::event::emit::emit_loading;
|
use crate::event::emit::emit_loading;
|
||||||
use bytes::Bytes;
|
use bytes::Bytes;
|
||||||
@@ -84,8 +83,8 @@ pub async fn fetch_advanced(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.is_none_or(|x| &*x.0.to_lowercase() != "authorization")
|
.is_none_or(|x| &*x.0.to_lowercase() != "authorization")
|
||||||
&& (url.starts_with("https://cdn.modrinth.com")
|
&& (url.starts_with("https://cdn.modrinth.com")
|
||||||
|| url.starts_with(MODRINTH_API_URL)
|
|| url.starts_with(env!("MODRINTH_API_URL"))
|
||||||
|| url.starts_with(MODRINTH_API_URL_V3))
|
|| url.starts_with(env!("MODRINTH_API_URL_V3")))
|
||||||
{
|
{
|
||||||
crate::state::ModrinthCredentials::get_active(exec).await?
|
crate::state::ModrinthCredentials::get_active(exec).await?
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user