Compare commits

..

13 Commits

Author SHA1 Message Date
Prospector
1b08d2741b Merge remote-tracking branch 'origin/main' into security-txt 2025-07-09 15:50:02 -07:00
Erb3
e35981e4aa Merge branch 'main' into security-txt 2025-03-12 20:50:25 +01:00
Erb3
4f3b4a55e2 docs(frontend) careers link in security.txt
Signed-off-by: Erb3 <49862976+Erb3@users.noreply.github.com>
2025-03-12 20:50:15 +01:00
Erb3
51c3797456 Merge branch 'main' into security-txt 2025-01-17 16:33:07 +01:00
Erb3
5f3200ce43 fix(frontend): extend security.txt expiry
It takes so long to merge the PR :(

Signed-off-by: Erb3 <49862976+Erb3@users.noreply.github.com>
2025-01-17 16:32:55 +01:00
Erb3
c4b0f7dcd1 Merge branch 'main' into security-txt 2024-12-07 10:37:07 +01:00
Erb3
c6dee57c40 Merge branch 'main' into security-txt 2024-10-24 17:56:21 +02:00
Erb3
8f29da5739 Merge branch 'main' into security-txt 2024-10-22 21:23:52 +02:00
Erb3
0bc5e954e4 Merge branch 'main' into security-txt 2024-10-20 18:04:58 +02:00
Erb3
7a3ca5fb45 Merge branch 'main' into security-txt 2024-08-26 20:46:33 +02:00
Erb3
28adcdd401 Merge branch 'main' into security-txt 2024-08-25 09:55:12 +02:00
Erb3
414bcaf348 fix(docs): reduce security.txt expiry
This addresses a concern where the security.txt has a long expiration date. Someone could treat this as "use this until then", which we don't want since it's a long time. The specification recommends no longer than one year, as it is to mark as stale.

From the RFC:

> The "Expires" field indicates the date and time after which the data contained in the "security.txt" file is considered stale and should not be used (as per Section 5.3). The value of this field is formatted according to the Internet profiles of [ISO.8601-1] and [ISO.8601-2] as defined in [RFC3339]. It is RECOMMENDED that the value of this field be less than a year into the future to avoid staleness.

Signed-off-by: Erb3 <49862976+Erb3@users.noreply.github.com>
2024-08-22 19:10:51 +02:00
Erb3
727b00855e feat: add security.txt
Security.txt is a well-known (pun intended) file among security researchers, so they don't have to go scavenging for your security information. More information is available on [securitytxt.org](https://securitytxt.org/).

I've set the following values:

- The email to contact with issues, `jai@modrinth.com`. This is the email stated in the security policy. If you wish to not include it here due to spam, you should also not have it as a `mailto` link in the security policy.
- Expiry is set to 2030. By this time Modrinth has become the biggest Minecraft mod distributor, and having expanded into other games. By this time they should also have updated this file.
- English is the preferred language
- The file is located at modrinth.com/.well-known/security.txt
- The security policy is at https://modrinth.com/legal/security

The following values have been left unset:

- PGP key, not sure where this would be located, if there is one
- Acknowledgments. Modrinth does currently not have a site for thanks
- Hiring, as it wants security-related positions
- CSAF, a Common Security Advisory Framework ?
2024-08-22 17:30:32 +02:00
218 changed files with 2121 additions and 11743 deletions

View File

@@ -2,8 +2,5 @@
[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"]

View File

@@ -75,7 +75,7 @@ jobs:
rename-to: ${{ startsWith(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }} rename-to: ${{ startsWith(matrix.platform, 'windows') && 'dasel.exe' || 'dasel' }}
chmod: 0755 chmod: 0755
- name: ⚙️ Set application version and environment - name: ⚙️ Set application version
shell: bash shell: bash
run: | run: |
APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')" APP_VERSION="$(git describe --tags --always | sed -E 's/-([0-9]+)-(g[0-9a-fA-F]+)$/-canary+\1.\2/')"
@@ -84,8 +84,6 @@ jobs:
dasel put -f packages/app-lib/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version' dasel put -f packages/app-lib/Cargo.toml -t string -v "${APP_VERSION#v}" 'package.version'
dasel put -f apps/app-frontend/package.json -t string -v "${APP_VERSION#v}" 'version' dasel put -f apps/app-frontend/package.json -t string -v "${APP_VERSION#v}" 'version'
cp packages/app-lib/.env.prod packages/app-lib/.env
- name: 💨 Setup Turbo cache - name: 💨 Setup Turbo cache
uses: rharkor/caching-for-turbo@v1.8 uses: rharkor/caching-for-turbo@v1.8

View File

@@ -74,14 +74,5 @@ jobs:
cp .env.local .env cp .env.local .env
sqlx database setup sqlx database setup
- name: ⚙️ Set app environment
working-directory: packages/app-lib
run: cp .env.staging .env
- name: 🔍 Lint and test - name: 🔍 Lint and test
run: pnpm run ci run: pnpm run ci
- name: 🔍 Verify intl:extract has been run
run: |
pnpm intl:extract
git diff --exit-code --color */*/src/locales/en-US/index.json

114
Cargo.lock generated
View File

@@ -1706,7 +1706,7 @@ dependencies = [
"bitflags 2.9.1", "bitflags 2.9.1",
"core-foundation 0.10.0", "core-foundation 0.10.0",
"core-graphics-types", "core-graphics-types",
"foreign-types", "foreign-types 0.5.0",
"libc", "libc",
] ]
@@ -2699,6 +2699,15 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]] [[package]]
name = "foreign-types" name = "foreign-types"
version = "0.5.0" version = "0.5.0"
@@ -2706,7 +2715,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [ dependencies = [
"foreign-types-macros", "foreign-types-macros",
"foreign-types-shared", "foreign-types-shared 0.3.1",
] ]
[[package]] [[package]]
@@ -2720,6 +2729,12 @@ dependencies = [
"syn 2.0.101", "syn 2.0.101",
] ]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]] [[package]]
name = "foreign-types-shared" name = "foreign-types-shared"
version = "0.3.1" version = "0.3.1"
@@ -3663,10 +3678,11 @@ dependencies = [
[[package]] [[package]]
name = "hyper-rustls" name = "hyper-rustls"
version = "0.27.7" version = "0.27.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" checksum = "2d191583f3da1305256f22463b9bb0471acad48a4e534a5218b9963e9c1f59b2"
dependencies = [ dependencies = [
"futures-util",
"http 1.3.1", "http 1.3.1",
"hyper 1.6.0", "hyper 1.6.0",
"hyper-util", "hyper-util",
@@ -3676,7 +3692,7 @@ dependencies = [
"tokio", "tokio",
"tokio-rustls 0.26.2", "tokio-rustls 0.26.2",
"tower-service", "tower-service",
"webpki-roots 1.0.0", "webpki-roots 0.26.11",
] ]
[[package]] [[package]]
@@ -3692,6 +3708,22 @@ dependencies = [
"tower-service", "tower-service",
] ]
[[package]]
name = "hyper-tls"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
dependencies = [
"bytes",
"http-body-util",
"hyper 1.6.0",
"hyper-util",
"native-tls",
"tokio",
"tokio-native-tls",
"tower-service",
]
[[package]] [[package]]
name = "hyper-util" name = "hyper-util"
version = "0.1.14" version = "0.1.14"
@@ -4357,7 +4389,7 @@ dependencies = [
"futures-util", "futures-util",
"hex", "hex",
"hmac", "hmac",
"hyper-rustls 0.27.7", "hyper-tls",
"hyper-util", "hyper-util",
"iana-time-zone", "iana-time-zone",
"image", "image",
@@ -4954,6 +4986,23 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework 2.11.1",
"security-framework-sys",
"tempfile",
]
[[package]] [[package]]
name = "ndk" name = "ndk"
version = "0.9.0" version = "0.9.0"
@@ -5528,12 +5577,50 @@ dependencies = [
"pathdiff", "pathdiff",
] ]
[[package]]
name = "openssl"
version = "0.10.72"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fedfea7d58a1f73118430a55da6a286e7b044961736ce96a16a17068ea25e5da"
dependencies = [
"bitflags 2.9.1",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.101",
]
[[package]] [[package]]
name = "openssl-probe" name = "openssl-probe"
version = "0.1.6" version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e" checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.108"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e145e1651e858e820e4860f7b9c5e169bc1d8ce1c86043be79fa7b7634821847"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@@ -6763,7 +6850,7 @@ dependencies = [
"http-body 1.0.1", "http-body 1.0.1",
"http-body-util", "http-body-util",
"hyper 1.6.0", "hyper 1.6.0",
"hyper-rustls 0.27.7", "hyper-rustls 0.27.5",
"hyper-util", "hyper-util",
"js-sys", "js-sys",
"log", "log",
@@ -7893,7 +7980,7 @@ dependencies = [
"bytemuck", "bytemuck",
"cfg_aliases", "cfg_aliases",
"core-graphics", "core-graphics",
"foreign-types", "foreign-types 0.5.0",
"js-sys", "js-sys",
"log", "log",
"objc2 0.5.2", "objc2 0.5.2",
@@ -8930,7 +9017,6 @@ dependencies = [
"data-url", "data-url",
"dirs", "dirs",
"discord-rich-presence", "discord-rich-presence",
"dotenvy",
"dunce", "dunce",
"either", "either",
"encoding_rs", "encoding_rs",
@@ -9206,6 +9292,16 @@ dependencies = [
"syn 2.0.101", "syn 2.0.101",
] ]
[[package]]
name = "tokio-native-tls"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
dependencies = [
"native-tls",
"tokio",
]
[[package]] [[package]]
name = "tokio-rustls" name = "tokio-rustls"
version = "0.24.1" version = "0.24.1"

View File

@@ -67,12 +67,7 @@ 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-rustls = { version = "0.27.7", default-features = false, features = [ hyper-tls = "0.6.0"
"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"] }

View File

@@ -9,7 +9,7 @@
"tsc:check": "vue-tsc --noEmit", "tsc:check": "vue-tsc --noEmit",
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .", "fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace", "intl:extract": "formatjs extract \"{,src/components,src/composables,src/helpers,src/pages,src/store}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"test": "vue-tsc --noEmit" "test": "vue-tsc --noEmit"
}, },
"dependencies": { "dependencies": {

View File

@@ -1,14 +1,5 @@
<script setup> <script setup>
import { import { computed, onMounted, onUnmounted, ref, watch, provide } from 'vue'
computed,
onMounted,
onUnmounted,
ref,
watch,
useTemplateRef,
provide,
nextTick,
} from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router' import { RouterView, useRoute, useRouter } from 'vue-router'
import { import {
ArrowBigUpDashIcon, ArrowBigUpDashIcon,
@@ -42,7 +33,7 @@ import { useLoading, useTheming } from '@/store/state'
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component' import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
import AccountsCard from '@/components/ui/AccountsCard.vue' import AccountsCard from '@/components/ui/AccountsCard.vue'
import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue' import InstanceCreationModal from '@/components/ui/InstanceCreationModal.vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts' import { get } from '@/helpers/settings.ts'
import Breadcrumbs from '@/components/ui/Breadcrumbs.vue' import Breadcrumbs from '@/components/ui/Breadcrumbs.vue'
import RunningAppBar from '@/components/ui/RunningAppBar.vue' import RunningAppBar from '@/components/ui/RunningAppBar.vue'
import SplashScreen from '@/components/ui/SplashScreen.vue' import SplashScreen from '@/components/ui/SplashScreen.vue'
@@ -51,7 +42,7 @@ import ModrinthLoadingIndicator from '@/components/LoadingIndicatorBar.vue'
import { handleError, useNotifications } from '@/store/notifications.js' import { handleError, useNotifications } from '@/store/notifications.js'
import { command_listener, warning_listener } from '@/helpers/events.js' import { command_listener, warning_listener } from '@/helpers/events.js'
import { type } from '@tauri-apps/plugin-os' import { type } from '@tauri-apps/plugin-os'
import { areUpdatesEnabled, getOS, isDev } from '@/helpers/utils.js' import { getOS, isDev, restartApp } from '@/helpers/utils.js'
import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics' import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
import { getCurrentWindow } from '@tauri-apps/api/window' import { getCurrentWindow } from '@tauri-apps/api/window'
import { getVersion } from '@tauri-apps/api/app' import { getVersion } from '@tauri-apps/api/app'
@@ -68,6 +59,7 @@ import { get_opening_command, initialize_state } from '@/helpers/state'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state' import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { renderString } from '@modrinth/utils' 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 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 { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
import { get_user } from '@/helpers/cache.js' import { get_user } from '@/helpers/cache.js'
@@ -77,11 +69,8 @@ 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'
import { openUrl } from '@tauri-apps/plugin-opener' import { openUrl } from '@tauri-apps/plugin-opener'
import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue' import QuickInstanceSwitcher from '@/components/ui/QuickInstanceSwitcher.vue'
import UpdateModal from '@/components/ui/UpdateModal.vue'
import { get_available_capes, get_available_skins } from './helpers/skins' import { get_available_capes, get_available_skins } from './helpers/skins'
import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer' import { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { createTooltip, destroyTooltip } from 'floating-vue'
const themeStore = useTheming() const themeStore = useTheming()
@@ -120,18 +109,6 @@ onUnmounted(() => {
document.querySelector('body').removeEventListener('auxclick', handleAuxClick) document.querySelector('body').removeEventListener('auxclick', handleAuxClick)
}) })
const { formatMessage } = useVIntl()
const messages = defineMessages({
updateInstalledToastTitle: {
id: 'app.update.complete-toast.title',
defaultMessage: 'Version {version} was successfully installed!',
},
updateInstalledToastText: {
id: 'app.update.complete-toast.text',
defaultMessage: 'Click here to view the changelog.',
},
})
async function setupApp() { async function setupApp() {
stateInitialized.value = true stateInitialized.value = true
const { const {
@@ -145,8 +122,7 @@ async function setupApp() {
toggle_sidebar, toggle_sidebar,
developer_mode, developer_mode,
feature_flags, feature_flags,
pending_update_toast_for_version, } = await get()
} = await getSettings()
if (default_page === 'Library') { if (default_page === 'Library') {
await router.push('/library') await router.push('/library')
@@ -233,6 +209,7 @@ async function setupApp() {
}) })
get_opening_command().then(handleCommand) get_opening_command().then(handleCommand)
checkUpdates()
fetchCredentials() fetchCredentials()
try { try {
@@ -242,22 +219,6 @@ async function setupApp() {
} catch (error) { } catch (error) {
console.warn('Failed to generate skin previews in app setup.', error) console.warn('Failed to generate skin previews in app setup.', error)
} }
if (pending_update_toast_for_version !== null) {
const settings = await getSettings()
settings.pending_update_toast_for_version = null
await setSettings(settings)
const version = await getVersion()
if (pending_update_toast_for_version === version) {
notifications.addNotification({
type: 'success',
title: formatMessage(messages.updateInstalledToastTitle, { version }),
text: formatMessage(messages.updateInstalledToastText),
clickAction: () => openUrl('https://modrinth.com/news/changelog?filter=app'),
})
}
}
} }
const stateFailed = ref(false) const stateFailed = ref(false)
@@ -385,95 +346,19 @@ async function handleCommand(e) {
} }
} }
const availableUpdate = ref(null) const updateAvailable = ref(false)
const updateSkipped = ref(false)
const enqueuedUpdate = ref(null)
const updateModal = useTemplateRef('updateModal')
async function checkUpdates() { async function checkUpdates() {
if (!(await areUpdatesEnabled())) { const update = await check()
console.log('Skipping update check as updates are disabled in this build') updateAvailable.value = !!update
return
}
async function performCheck() {
if (updateModal.value.isOpen) {
console.log('Skipping update check because the update modal is already open')
return
}
const update = await invoke('plugin:updater|check')
if (!update) {
return
}
console.log(`Update ${update.version} is available.`)
if (update.version === availableUpdate.value?.version) {
console.log(
'Skipping update modal because the new version is the same as the dismissed update',
)
return
}
availableUpdate.value = update
const settings = await getSettings()
if (settings.skipped_update === update.version) {
updateSkipped.value = true
console.log('Skipping update modal because the user chose to skip this update')
return
}
updateSkipped.value = false
updateModal.value.show(update)
}
await performCheck()
setTimeout( setTimeout(
() => { () => {
checkUpdates() checkUpdates()
}, },
5 * 60 * 1000, 5 * 1000 * 60,
) )
} }
async function skipUpdate(version) {
enqueuedUpdate.value = null
updateSkipped.value = true
const settings = await getSettings()
settings.skipped_update = version
await setSettings(settings)
}
async function updateEnqueuedForLater(version) {
enqueuedUpdate.value = version
}
async function forceOpenUpdateModal() {
if (updateSkipped.value) {
updateSkipped.value = false
const settings = await getSettings()
settings.skipped_update = null
await setSettings(settings)
}
updateModal.value.show(availableUpdate.value)
}
const updateButton = useTemplateRef('updateButton')
async function showUpdateButtonTooltip() {
await nextTick()
const tooltip = createTooltip(updateButton.value.$el, {
placement: 'right',
content: 'Click here to view the update again.',
})
tooltip.show()
setTimeout(() => {
tooltip.hide()
destroyTooltip(updateButton.value.$el)
}, 3500)
}
function handleClick(e) { function handleClick(e) {
let target = e.target let target = e.target
while (target != null) { while (target != null) {
@@ -514,14 +399,6 @@ function handleAuxClick(e) {
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region /> <SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
<div id="teleports"></div> <div id="teleports"></div>
<div v-if="stateInitialized" class="app-grid-layout experimental-styles-within relative"> <div v-if="stateInitialized" class="app-grid-layout experimental-styles-within relative">
<Suspense @resolve="checkUpdates">
<UpdateModal
ref="updateModal"
@update-skipped="skipUpdate"
@update-enqueued-for-later="updateEnqueuedForLater"
@modal-hidden="showUpdateButtonTooltip"
/>
</Suspense>
<Suspense> <Suspense>
<AppSettingsModal ref="settingsModal" /> <AppSettingsModal ref="settingsModal" />
</Suspense> </Suspense>
@@ -572,18 +449,8 @@ function handleAuxClick(e) {
<PlusIcon /> <PlusIcon />
</NavButton> </NavButton>
<div class="flex flex-grow"></div> <div class="flex flex-grow"></div>
<NavButton <NavButton v-if="updateAvailable" v-tooltip.right="'Install update'" :to="() => restartApp()">
v-if="!!availableUpdate" <DownloadIcon />
ref="updateButton"
v-tooltip.right="
enqueuedUpdate === availableUpdate?.version
? 'Update installation queued for next restart'
: 'Update available'
"
:to="forceOpenUpdateModal"
>
<DownloadIcon v-if="updateSkipped || enqueuedUpdate === availableUpdate?.version" />
<DownloadIcon v-else class="text-brand-green" />
</NavButton> </NavButton>
<NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()"> <NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
<SettingsIcon /> <SettingsIcon />
@@ -618,13 +485,13 @@ function handleAuxClick(e) {
<ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" /> <ModrinthAppLogo class="h-full w-auto text-contrast pointer-events-none" />
<div class="flex items-center gap-1 ml-3"> <div class="flex items-center gap-1 ml-3">
<button <button
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all" class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
@click="router.back()" @click="router.back()"
> >
<LeftArrowIcon /> <LeftArrowIcon />
</button> </button>
<button <button
class="cursor-pointer p-0 m-0 text-contrast border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all" class="cursor-pointer p-0 m-0 border-none outline-none bg-button-bg rounded-full flex items-center justify-center w-6 h-6 hover:brightness-75 transition-all"
@click="router.forward()" @click="router.forward()"
> >
<RightArrowIcon /> <RightArrowIcon />

View File

@@ -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, undefined, { numeric: true }) return a.game_version.localeCompare(b.game_version)
}) })
} }
@@ -213,17 +213,6 @@ 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
}) })

View File

@@ -1,12 +1,6 @@
<template> <template>
<div class="progress-bar"> <div class="progress-bar">
<div <div class="progress-bar__fill" :style="{ width: `${progress}%` }"></div>
class="progress-bar__fill"
:style="{
width: `${progress}%`,
'background-color': error ? 'var(--color-red)' : 'var(--color-brand)',
}"
></div>
</div> </div>
</template> </template>
@@ -19,10 +13,6 @@ defineProps({
return value >= 0 && value <= 100 return value >= 0 && value <= 100
}, },
}, },
error: {
type: Boolean,
default: false,
},
}) })
</script> </script>
@@ -37,6 +27,7 @@ defineProps({
.progress-bar__fill { .progress-bar__fill {
height: 100%; height: 100%;
background-color: var(--color-brand);
transition: width 0.3s; transition: width 0.3s;
} }
</style> </style>

View File

@@ -1,303 +0,0 @@
<template>
<ModalWrapper ref="modal" hide-header :closable="false" :on-hide="onHide">
<div class="flex flex-col gap-4">
<div class="w-[500px]">
<div class="font-extrabold text-contrast text-xl">
{{ formatMessage(messages.header) }} Modrinth App v{{ update!.version }}
</div>
<template v-if="!downloadInProgress && !downloadError">
<div class="mb-1 leading-tight">{{ formatMessage(messages.bodyVersion) }}</div>
<div class="text-sm text-secondary mb-2">
{{ formatMessage(messages.downloadSize, { size: formatBytes(updateSize) }) }}
</div>
</template>
<AppearingProgressBar
v-if="!downloadError"
:max-value="shouldShowProgress ? updateSize || 0 : 0"
:current-value="shouldShowProgress ? downloadedBytes : 0"
color="green"
class="w-full mb-4 mt-2"
/>
<div v-if="downloadError" class="leading-tight">
<div class="text-red font-medium mb-4">
{{ formatMessage(messages.downloadError) }}
</div>
<div class="flex flex-wrap gap-2">
<ButtonStyled color="brand">
<button @click="installUpdateNow">
<DownloadIcon />
{{ formatMessage(messages.tryAgain) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="copyError">
<ClipboardCopyIcon />
{{
copiedError
? formatMessage(messages.copiedError)
: formatMessage(messages.copyError)
}}
</button>
</ButtonStyled>
<ButtonStyled>
<a href="https://support.modrinth.com"><ChatIcon /> Get support</a>
</ButtonStyled>
</div>
</div>
<div v-if="!downloadError" class="flex flex-wrap gap-2 w-full">
<JoinedButtons
:actions="installActions"
:disabled="updatingImmediately || downloadInProgress"
color="brand"
/>
<div>
<ButtonStyled>
<button @click="() => openUrl('https://modrinth.com/news/changelog?filter=app')">
<ExternalIcon /> {{ formatMessage(messages.changelog) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</ModalWrapper>
</template>
<script setup lang="ts">
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { useTemplateRef, ref, computed } from 'vue'
import { AppearingProgressBar, ButtonStyled, JoinedButtons } from '@modrinth/ui'
import type { JoinedButtonAction } from '@modrinth/ui'
import { ExternalIcon, DownloadIcon, RedoIcon, ClipboardCopyIcon, XIcon } from '@modrinth/assets'
import { enqueueUpdateForInstallation, getUpdateSize } from '@/helpers/utils'
import { formatBytes } from '@modrinth/utils'
import { handleError } from '@/store/notifications'
import { loading_listener } from '@/helpers/events'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ChatIcon } from '@/assets/icons'
const emit = defineEmits<{
(e: 'updateEnqueuedForLater', version: string | null): Promise<void>
(e: 'modalHidden'): void
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'app.update.modal-header',
defaultMessage: 'Update available - ',
},
copiedError: {
id: 'app.update.copied-error',
defaultMessage: 'Copied to clipboard!',
},
bodyVersion: {
id: 'app.update.modal-body-version',
defaultMessage:
'We recommend updating as soon as possible so you can enjoy the latest features, fixes, and improvements.',
},
downloadSize: {
id: 'app.update.download-size',
defaultMessage: 'The update is {size}.',
},
changelog: {
id: 'app.update.changelog',
defaultMessage: 'View changelog',
},
restartNow: {
id: 'app.update.restart',
defaultMessage: 'Update now',
},
later: {
id: 'app.update.later',
defaultMessage: 'Update on exit',
},
downloadError: {
id: 'app.update.download-error',
defaultMessage:
'An error occurred while downloading the update. Please try again later. Contact support if the issue persists.',
},
copyError: {
id: 'app.update.copy-error',
defaultMessage: 'Copy error',
},
tryAgain: {
id: 'app.update.try-again',
defaultMessage: 'Try again',
},
hide: {
id: 'app.update.hide',
defaultMessage: 'Hide update reminder',
},
})
type UpdateData = {
rid: number
currentVersion: string
version: string
date?: string
body?: string
rawJson: Record<string, unknown>
}
const update = ref<UpdateData>()
const updateSize = ref<number>()
const updatingImmediately = ref(false)
const downloadInProgress = ref(false)
const downloadProgress = ref(0)
const copiedError = ref(false)
const downloadError = ref<Error | null>(null)
const enqueuedUpdate = ref<string | null>(null)
const installActions = computed<JoinedButtonAction[]>(() => [
{
id: 'install-now',
label: formatMessage(messages.restartNow),
icon: DownloadIcon,
action: installUpdateNow,
color: 'green',
},
{
id: 'install-later',
label: formatMessage(messages.later),
icon: RedoIcon,
action: updateAtNextExit,
},
{
id: 'hide',
label: formatMessage(messages.hide),
action: () => {
hide()
emit('modalHidden')
},
icon: XIcon,
},
])
const downloadedBytes = computed(() => {
return updateSize.value ? Math.round((downloadProgress.value / 100) * updateSize.value) : 0
})
const shouldShowProgress = computed(() => {
return downloadInProgress.value || updatingImmediately.value
})
const modal = useTemplateRef('modal')
const isOpen = ref(false)
async function show(newUpdate: UpdateData) {
const oldVersion = update.value?.version
update.value = newUpdate
updateSize.value = await getUpdateSize(newUpdate.rid).catch(handleError)
if (oldVersion !== update.value?.version) {
downloadProgress.value = 0
}
modal.value!.show(new MouseEvent('click'))
isOpen.value = true
}
function onHide() {
isOpen.value = false
}
function hide() {
modal.value!.hide()
}
defineExpose({ show, hide, isOpen })
async function copyError() {
if (downloadError.value) {
copiedError.value = true
const errorData = {
message: downloadError.value.message,
stack: downloadError.value.stack,
name: downloadError.value.name,
timestamp: new Date().toISOString(),
updateVersion: update.value?.version,
}
setTimeout(() => {
copiedError.value = false
}, 3000)
try {
await navigator.clipboard.writeText(JSON.stringify(errorData, null, 2))
} catch (e) {
console.error('Failed to copy error to clipboard:', e)
}
}
}
// TODO: Migrate to common events.ts helper when events/listeners are refactored
interface LoadingListenerEvent {
event: {
type: 'launcher_update'
version: string
}
fraction?: number
}
loading_listener((event: LoadingListenerEvent) => {
if (event.event.type === 'launcher_update') {
if (event.event.version === update.value!.version) {
downloadProgress.value = (event.fraction ?? 1.0) * 100
}
}
})
function installUpdateNow() {
updatingImmediately.value = true
if (enqueuedUpdate.value !== update.value!.version) {
downloadUpdate()
} else if (!downloadInProgress.value) {
// Update already downloaded. Simply close the app
getCurrentWindow().close()
}
}
function updateAtNextExit() {
enqueuedUpdate.value = update.value!.version
emit('updateEnqueuedForLater', update.value!.version)
downloadUpdate()
hide()
}
async function downloadUpdate() {
downloadError.value = null
downloadProgress.value = 0
const versionToDownload = update.value!.version
downloadInProgress.value = true
try {
await enqueueUpdateForInstallation(update.value!.rid)
} catch (e) {
downloadInProgress.value = false
downloadError.value = e instanceof Error ? e : new Error(String(e))
handleError(e)
enqueuedUpdate.value = null
updatingImmediately.value = false
await emit('updateEnqueuedForLater', null)
return
}
downloadInProgress.value = false
if (updatingImmediately.value && update.value!.version === versionToDownload) {
await getCurrentWindow().close()
}
}
</script>
<style scoped lang="scss"></style>

View File

@@ -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, snapPoints } = await useMemorySlider() const maxMemory = Math.floor((await get_max_memory().catch(handleError)) / 1024)
const editProfileObject = computed(() => { const editProfileObject = computed(() => {
const editProfile: { const editProfile: {
@@ -156,8 +156,6 @@ 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">

View File

@@ -11,10 +11,6 @@ const props = defineProps({
type: String, type: String,
default: null, default: null,
}, },
hideHeader: {
type: Boolean,
default: false,
},
closable: { closable: {
type: Boolean, type: Boolean,
default: true, default: true,
@@ -52,14 +48,7 @@ function onModalHide() {
</script> </script>
<template> <template>
<Modal <Modal ref="modal" :header="header" :noblur="!themeStore.advancedRendering" @hide="onModalHide">
ref="modal"
:header="header"
:noblur="!themeStore.advancedRendering"
:closable="closable"
:hide-header="hideHeader"
@hide="onModalHide"
>
<template #title> <template #title>
<slot name="title" /> <slot name="title" />
</template> </template>

View File

@@ -1,8 +1,9 @@
<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(' ')
@@ -10,7 +11,7 @@ fetchSettings.envVars = fetchSettings.custom_env_vars.map((x) => x.join('=')).jo
const settings = ref(fetchSettings) const settings = ref(fetchSettings)
const { maxMemory, snapPoints } = await useMemorySlider() const maxMemory = ref(Math.floor((await get_max_memory().catch(handleError)) / 1024))
watch( watch(
settings, settings,
@@ -106,8 +107,6 @@ watch(
:min="512" :min="512"
:max="maxMemory" :max="maxMemory"
:step="64" :step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB" unit="MB"
/> />

View File

@@ -118,7 +118,6 @@ 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 {
@@ -254,7 +253,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 = await determineModelType(skinTextureUrl) variant.value = 'CLASSIC'
selectedCape.value = undefined selectedCape.value = undefined
visibleCapeList.value = [] visibleCapeList.value = []
initVisibleCapeList() initVisibleCapeList()

View File

@@ -128,14 +128,6 @@ 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',
@@ -310,33 +302,39 @@ 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">
<ButtonStyled <template v-if="world.type === 'singleplayer' || serverStatus">
v-if="(playingWorld || (locked && playingInstance)) && !startingInstance" <ButtonStyled
color="red" v-if="(playingWorld || (locked && playingInstance)) && !startingInstance"
> color="red"
<button @click="emit('stop')"> >
<StopCircleIcon aria-hidden="true" /> <button @click="emit('stop')">
{{ formatMessage(commonMessages.stopButton) }} <StopCircleIcon aria-hidden="true" />
</button> {{ formatMessage(commonMessages.stopButton) }}
</ButtonStyled> </button>
<ButtonStyled v-else> </ButtonStyled>
<button <ButtonStyled v-else>
v-tooltip=" <button
!serverStatus v-tooltip="
? formatMessage(messages.noContact) serverIncompatible
: serverIncompatible ? 'Server is incompatible'
? 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>

View File

@@ -1,21 +0,0 @@
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 }
}

View File

@@ -2,46 +2,25 @@ 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 { import { setupSkinModel, disposeCaches } from '@modrinth/utils'
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 { CapeModel, ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
import { ClassicPlayerModel, SlimPlayerModel } from '@modrinth/assets'
export interface RenderResult { export interface RenderResult {
forwards: string forwards: string
backwards: string backwards: string
} }
export interface RawRenderResult {
forwards: Blob
backwards: Blob
}
class BatchSkinRenderer { class BatchSkinRenderer {
private renderer: THREE.WebGLRenderer | null = null private renderer: THREE.WebGLRenderer
private scene: THREE.Scene | null = null private readonly scene: THREE.Scene
private camera: THREE.PerspectiveCamera | null = null private readonly camera: THREE.PerspectiveCamera
private currentModel: THREE.Group | null = null private currentModel: THREE.Group | null = null
private readonly width: number
private readonly height: number
constructor(width: number = 360, height: number = 504) { constructor(width: number = 360, height: number = 504) {
this.width = width
this.height = height
}
private initializeRenderer(): void {
if (this.renderer) return
const canvas = document.createElement('canvas') const canvas = document.createElement('canvas')
canvas.width = this.width canvas.width = width
canvas.height = this.height canvas.height = height
this.renderer = new THREE.WebGLRenderer({ this.renderer = new THREE.WebGLRenderer({
canvas: canvas, canvas: canvas,
@@ -54,10 +33,10 @@ class BatchSkinRenderer {
this.renderer.toneMapping = THREE.NoToneMapping this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.toneMappingExposure = 10.0 this.renderer.toneMappingExposure = 10.0
this.renderer.setClearColor(0x000000, 0) this.renderer.setClearColor(0x000000, 0)
this.renderer.setSize(this.width, this.height) this.renderer.setSize(width, height)
this.scene = new THREE.Scene() this.scene = new THREE.Scene()
this.camera = new THREE.PerspectiveCamera(20, this.width / this.height, 0.4, 1000) this.camera = new THREE.PerspectiveCamera(20, width / height, 0.4, 1000)
const ambientLight = new THREE.AmbientLight(0xffffff, 2) const ambientLight = new THREE.AmbientLight(0xffffff, 2)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2) const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
@@ -71,12 +50,9 @@ class BatchSkinRenderer {
textureUrl: string, textureUrl: string,
modelUrl: string, modelUrl: string,
capeUrl?: string, capeUrl?: string,
): Promise<RawRenderResult> { capeModelUrl?: string,
this.initializeRenderer() ): Promise<RenderResult> {
await this.setupModel(modelUrl, textureUrl, capeModelUrl, capeUrl)
this.clearScene()
await this.setupModel(modelUrl, textureUrl, capeUrl)
const headPart = this.currentModel!.getObjectByName('Head') const headPart = this.currentModel!.getObjectByName('Head')
let lookAtTarget: [number, number, number] let lookAtTarget: [number, number, number]
@@ -101,35 +77,35 @@ class BatchSkinRenderer {
private async renderView( private async renderView(
cameraPosition: [number, number, number], cameraPosition: [number, number, number],
lookAtPosition: [number, number, number], lookAtPosition: [number, number, number],
): Promise<Blob> { ): Promise<string> {
if (!this.camera || !this.renderer || !this.scene) {
throw new Error('Renderer not initialized')
}
this.camera.position.set(...cameraPosition) this.camera.position.set(...cameraPosition)
this.camera.lookAt(...lookAtPosition) this.camera.lookAt(...lookAtPosition)
this.renderer.render(this.scene, this.camera) this.renderer.render(this.scene, this.camera)
const dataUrl = this.renderer.domElement.toDataURL('image/webp', 0.9) return new Promise<string>((resolve, reject) => {
const response = await fetch(dataUrl) this.renderer.domElement.toBlob((blob) => {
return await response.blob() if (blob) {
const url = URL.createObjectURL(blob)
resolve(url)
} else {
reject(new Error('Failed to create blob from canvas'))
}
}, 'image/png')
})
} }
private async setupModel(modelUrl: string, textureUrl: string, capeUrl?: string): Promise<void> { private async setupModel(
if (!this.scene) { modelUrl: string,
throw new Error('Renderer not initialized') textureUrl: string,
capeModelUrl?: string,
capeUrl?: string,
): Promise<void> {
if (this.currentModel) {
this.scene.remove(this.currentModel)
} }
const { model } = await setupSkinModel(modelUrl, textureUrl) const { model } = await setupSkinModel(modelUrl, textureUrl, capeModelUrl, capeUrl)
if (capeUrl) {
const capeTexture = await loadTexture(capeUrl)
applyCapeTexture(model, capeTexture)
} else {
const transparentTexture = createTransparentTexture()
applyCapeTexture(model, null, transparentTexture)
}
const group = new THREE.Group() const group = new THREE.Group()
group.add(model) group.add(model)
@@ -140,39 +116,8 @@ class BatchSkinRenderer {
this.currentModel = group this.currentModel = group
} }
private clearScene(): void {
if (!this.scene) return
while (this.scene.children.length > 0) {
const child = this.scene.children[0]
this.scene.remove(child)
if (child instanceof THREE.Mesh) {
if (child.geometry) child.geometry.dispose()
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach((material) => material.dispose())
} else {
child.material.dispose()
}
}
}
}
const ambientLight = new THREE.AmbientLight(0xffffff, 2)
const directionalLight = new THREE.DirectionalLight(0xffffff, 1.2)
directionalLight.castShadow = true
directionalLight.position.set(2, 4, 3)
this.scene.add(ambientLight)
this.scene.add(directionalLight)
this.currentModel = null
}
public dispose(): void { public dispose(): void {
if (this.renderer) { this.renderer.dispose()
this.renderer.dispose()
}
disposeCaches() disposeCaches()
} }
} }
@@ -188,25 +133,10 @@ function getModelUrlForVariant(variant: string): string {
} }
} }
export const skinBlobUrlMap = reactive(new Map<string, RenderResult>()) export const map = reactive(new Map<string, RenderResult>())
export const headBlobUrlMap = reactive(new Map<string, string>()) export const headMap = reactive(new Map<string, string>())
const DEBUG_MODE = false const DEBUG_MODE = false
let sharedRenderer: BatchSkinRenderer | null = null
function getSharedRenderer(): BatchSkinRenderer {
if (!sharedRenderer) {
sharedRenderer = new BatchSkinRenderer()
}
return sharedRenderer
}
export function disposeSharedRenderer(): void {
if (sharedRenderer) {
sharedRenderer.dispose()
sharedRenderer = null
}
}
export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> { export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
const validKeys = new Set<string>() const validKeys = new Set<string>()
const validHeadKeys = new Set<string>() const validHeadKeys = new Set<string>()
@@ -220,7 +150,7 @@ export async function cleanupUnusedPreviews(skins: Skin[]): Promise<void> {
try { try {
await skinPreviewStorage.cleanupInvalidKeys(validKeys) await skinPreviewStorage.cleanupInvalidKeys(validKeys)
await headStorage.cleanupInvalidKeys(validHeadKeys) await skinPreviewStorage.cleanupInvalidKeys(validHeadKeys)
} catch (error) { } catch (error) {
console.warn('Failed to cleanup unused skin previews:', error) console.warn('Failed to cleanup unused skin previews:', error)
} }
@@ -299,17 +229,13 @@ export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64)
outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size) outputCtx.drawImage(hatCanvas, 0, 0, 8, 8, 0, 0, size, size)
} }
outputCanvas.toBlob( outputCanvas.toBlob((blob) => {
(blob) => { if (blob) {
if (blob) { resolve(blob)
resolve(blob) } else {
} else { reject(new Error('Failed to create blob from canvas'))
reject(new Error('Failed to create blob from canvas')) }
} }, 'image/png')
},
'image/webp',
0.9,
)
} catch (error) { } catch (error) {
reject(error) reject(error)
} }
@@ -326,24 +252,35 @@ export async function generatePlayerHeadBlob(skinUrl: string, size: number = 64)
async function generateHeadRender(skin: Skin): Promise<string> { async function generateHeadRender(skin: Skin): Promise<string> {
const headKey = `${skin.texture_key}-head` const headKey = `${skin.texture_key}-head`
if (headBlobUrlMap.has(headKey)) { if (headMap.has(headKey)) {
if (DEBUG_MODE) { if (DEBUG_MODE) {
const url = headBlobUrlMap.get(headKey)! const url = headMap.get(headKey)!
URL.revokeObjectURL(url) URL.revokeObjectURL(url)
headBlobUrlMap.delete(headKey) headMap.delete(headKey)
} else { } else {
return headBlobUrlMap.get(headKey)! return headMap.get(headKey)!
} }
} }
try {
const cached = await skinPreviewStorage.retrieve(headKey)
if (cached && typeof cached === 'string') {
headMap.set(headKey, cached)
return cached
}
} catch (error) {
console.warn('Failed to retrieve cached head render:', error)
}
const skinUrl = await get_normalized_skin_texture(skin) const skinUrl = await get_normalized_skin_texture(skin)
const headBlob = await generatePlayerHeadBlob(skinUrl, 64) const headBlob = await generatePlayerHeadBlob(skinUrl, 64)
const headUrl = URL.createObjectURL(headBlob) const headUrl = URL.createObjectURL(headBlob)
headBlobUrlMap.set(headKey, headUrl) headMap.set(headKey, headUrl)
try { try {
await headStorage.store(headKey, headBlob) // @ts-expect-error - skinPreviewStorage.store expects a RenderResult, but we are storing a string url.
await skinPreviewStorage.store(headKey, headUrl)
} catch (error) { } catch (error) {
console.warn('Failed to store head render in persistent storage:', error) console.warn('Failed to store head render in persistent storage:', error)
} }
@@ -356,49 +293,30 @@ export async function getPlayerHeadUrl(skin: Skin): Promise<string> {
} }
export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> { export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promise<void> {
const renderer = new BatchSkinRenderer()
try { try {
const skinKeys = skins.map(
(skin) => `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`,
)
const headKeys = skins.map((skin) => `${skin.texture_key}-head`)
const [cachedSkinPreviews, cachedHeadPreviews] = await Promise.all([
skinPreviewStorage.batchRetrieve(skinKeys),
headStorage.batchRetrieve(headKeys),
])
for (let i = 0; i < skins.length; i++) {
const skinKey = skinKeys[i]
const headKey = headKeys[i]
const rawCached = cachedSkinPreviews[skinKey]
if (rawCached) {
const cached: RenderResult = {
forwards: URL.createObjectURL(rawCached.forwards),
backwards: URL.createObjectURL(rawCached.backwards),
}
skinBlobUrlMap.set(skinKey, cached)
}
const cachedHead = cachedHeadPreviews[headKey]
if (cachedHead) {
headBlobUrlMap.set(headKey, URL.createObjectURL(cachedHead))
}
}
for (const skin of skins) { for (const skin of skins) {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}` const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
if (skinBlobUrlMap.has(key)) { if (map.has(key)) {
if (DEBUG_MODE) { if (DEBUG_MODE) {
const result = skinBlobUrlMap.get(key)! const result = map.get(key)!
URL.revokeObjectURL(result.forwards) URL.revokeObjectURL(result.forwards)
URL.revokeObjectURL(result.backwards) URL.revokeObjectURL(result.backwards)
skinBlobUrlMap.delete(key) map.delete(key)
} else continue } else continue
} }
const renderer = getSharedRenderer() try {
const cached = await skinPreviewStorage.retrieve(key)
if (cached) {
map.set(key, cached)
continue
}
} catch (error) {
console.warn('Failed to retrieve cached skin preview:', error)
}
let variant = skin.variant let variant = skin.variant
if (variant === 'UNKNOWN') { if (variant === 'UNKNOWN') {
@@ -412,35 +330,25 @@ export async function generateSkinPreviews(skins: Skin[], capes: Cape[]): Promis
const modelUrl = getModelUrlForVariant(variant) const modelUrl = getModelUrlForVariant(variant)
const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id) const cape: Cape | undefined = capes.find((_cape) => _cape.id === skin.cape_id)
const rawRenderResult = await renderer.renderSkin( const renderResult = await renderer.renderSkin(
await get_normalized_skin_texture(skin), await get_normalized_skin_texture(skin),
modelUrl, modelUrl,
cape?.texture, cape?.texture,
CapeModel,
) )
const renderResult: RenderResult = { map.set(key, renderResult)
forwards: URL.createObjectURL(rawRenderResult.forwards),
backwards: URL.createObjectURL(rawRenderResult.backwards),
}
skinBlobUrlMap.set(key, renderResult)
try { try {
await skinPreviewStorage.store(key, rawRenderResult) await skinPreviewStorage.store(key, renderResult)
} catch (error) { } catch (error) {
console.warn('Failed to store skin preview in persistent storage:', error) console.warn('Failed to store skin preview in persistent storage:', error)
} }
const headKey = `${skin.texture_key}-head` await generateHeadRender(skin)
if (!headBlobUrlMap.has(headKey)) {
await generateHeadRender(skin)
}
} }
} finally { } finally {
disposeSharedRenderer() renderer.dispose()
await cleanupUnusedPreviews(skins) await cleanupUnusedPreviews(skins)
await skinPreviewStorage.debugCalculateStorage()
await headStorage.debugCalculateStorage()
} }
} }

View File

@@ -62,9 +62,6 @@ export type AppSettings = {
developer_mode: boolean developer_mode: boolean
feature_flags: Record<FeatureFlag, boolean> feature_flags: Record<FeatureFlag, boolean>
skipped_update: string | null
pending_update_toast_for_version: string | null
} }
// Get full settings object // Get full settings object

View File

@@ -62,12 +62,15 @@ export async function determineModelType(texture: string): Promise<'SLIM' | 'CLA
context.drawImage(image, 0, 0) context.drawImage(image, 0, 0)
const armX = 54 const armX = 44
const armY = 20 const armY = 16
const armWidth = 2 const armWidth = 4
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

View File

@@ -1,229 +0,0 @@
interface StoredHead {
blob: Blob
timestamp: number
}
export class HeadStorage {
private dbName = 'head-storage'
private version = 1
private db: IDBDatabase | null = null
async init(): Promise<void> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.version)
request.onerror = () => reject(request.error)
request.onsuccess = () => {
this.db = request.result
resolve()
}
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains('heads')) {
db.createObjectStore('heads')
}
}
})
}
async store(key: string, blob: Blob): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readwrite')
const store = transaction.objectStore('heads')
const storedHead: StoredHead = {
blob,
timestamp: Date.now(),
}
return new Promise((resolve, reject) => {
const request = store.put(storedHead, key)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}
async retrieve(key: string): Promise<string | null> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readonly')
const store = transaction.objectStore('heads')
return new Promise((resolve, reject) => {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredHead | undefined
if (!result) {
resolve(null)
return
}
const url = URL.createObjectURL(result.blob)
resolve(url)
}
request.onerror = () => reject(request.error)
})
}
async batchRetrieve(keys: string[]): Promise<Record<string, Blob | null>> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readonly')
const store = transaction.objectStore('heads')
const results: Record<string, Blob | null> = {}
return new Promise((resolve, _reject) => {
let completedRequests = 0
if (keys.length === 0) {
resolve(results)
return
}
for (const key of keys) {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredHead | undefined
if (result) {
results[key] = result.blob
} else {
results[key] = null
}
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
request.onerror = () => {
results[key] = null
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
}
})
}
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readwrite')
const store = transaction.objectStore('heads')
let deletedCount = 0
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
if (!validKeys.has(key)) {
const deleteRequest = cursor.delete()
deleteRequest.onsuccess = () => {
deletedCount++
}
deleteRequest.onerror = () => {
console.warn('Failed to delete invalid head entry:', key)
}
}
cursor.continue()
} else {
resolve(deletedCount)
}
}
request.onerror = () => reject(request.error)
})
}
async debugCalculateStorage(): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readonly')
const store = transaction.objectStore('heads')
let totalSize = 0
let count = 0
const entries: Array<{ key: string; size: number }> = []
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
const value = cursor.value as StoredHead
const entrySize = value.blob.size
totalSize += entrySize
count++
entries.push({
key,
size: entrySize,
})
cursor.continue()
} else {
console.group('🗄️ Head Storage Debug Info')
console.log(`Total entries: ${count}`)
console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
console.log(
`Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`,
)
if (entries.length > 0) {
const sortedEntries = entries.sort((a, b) => b.size - a.size)
console.log(
'Largest entry:',
sortedEntries[0].key,
'(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)',
)
console.log(
'Smallest entry:',
sortedEntries[sortedEntries.length - 1].key,
'(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)',
)
}
console.groupEnd()
resolve()
}
}
request.onerror = () => reject(request.error)
})
}
async clearAll(): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['heads'], 'readwrite')
const store = transaction.objectStore('heads')
return new Promise((resolve, reject) => {
const request = store.clear()
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
}
}
export const headStorage = new HeadStorage()

View File

@@ -1,4 +1,4 @@
import type { RawRenderResult } from '../rendering/batch-skin-renderer' import type { RenderResult } from '../rendering/batch-skin-renderer'
interface StoredPreview { interface StoredPreview {
forwards: Blob forwards: Blob
@@ -30,15 +30,18 @@ export class SkinPreviewStorage {
}) })
} }
async store(key: string, result: RawRenderResult): Promise<void> { async store(key: string, result: RenderResult): Promise<void> {
if (!this.db) await this.init() if (!this.db) await this.init()
const forwardsBlob = await fetch(result.forwards).then((r) => r.blob())
const backwardsBlob = await fetch(result.backwards).then((r) => r.blob())
const transaction = this.db!.transaction(['previews'], 'readwrite') const transaction = this.db!.transaction(['previews'], 'readwrite')
const store = transaction.objectStore('previews') const store = transaction.objectStore('previews')
const storedPreview: StoredPreview = { const storedPreview: StoredPreview = {
forwards: result.forwards, forwards: forwardsBlob,
backwards: result.backwards, backwards: backwardsBlob,
timestamp: Date.now(), timestamp: Date.now(),
} }
@@ -50,7 +53,7 @@ export class SkinPreviewStorage {
}) })
} }
async retrieve(key: string): Promise<RawRenderResult | null> { async retrieve(key: string): Promise<RenderResult | null> {
if (!this.db) await this.init() if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly') const transaction = this.db!.transaction(['previews'], 'readonly')
@@ -67,56 +70,14 @@ export class SkinPreviewStorage {
return return
} }
resolve({ forwards: result.forwards, backwards: result.backwards }) const forwards = URL.createObjectURL(result.forwards)
const backwards = URL.createObjectURL(result.backwards)
resolve({ forwards, backwards })
} }
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error)
}) })
} }
async batchRetrieve(keys: string[]): Promise<Record<string, RawRenderResult | null>> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly')
const store = transaction.objectStore('previews')
const results: Record<string, RawRenderResult | null> = {}
return new Promise((resolve, _reject) => {
let completedRequests = 0
if (keys.length === 0) {
resolve(results)
return
}
for (const key of keys) {
const request = store.get(key)
request.onsuccess = () => {
const result = request.result as StoredPreview | undefined
if (result) {
results[key] = { forwards: result.forwards, backwards: result.backwards }
} else {
results[key] = null
}
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
request.onerror = () => {
results[key] = null
completedRequests++
if (completedRequests === keys.length) {
resolve(results)
}
}
}
})
}
async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> { async cleanupInvalidKeys(validKeys: Set<string>): Promise<number> {
if (!this.db) await this.init() if (!this.db) await this.init()
@@ -152,67 +113,6 @@ export class SkinPreviewStorage {
request.onerror = () => reject(request.error) request.onerror = () => reject(request.error)
}) })
} }
async debugCalculateStorage(): Promise<void> {
if (!this.db) await this.init()
const transaction = this.db!.transaction(['previews'], 'readonly')
const store = transaction.objectStore('previews')
let totalSize = 0
let count = 0
const entries: Array<{ key: string; size: number }> = []
return new Promise((resolve, reject) => {
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest<IDBCursorWithValue>).result
if (cursor) {
const key = cursor.primaryKey as string
const value = cursor.value as StoredPreview
const entrySize = value.forwards.size + value.backwards.size
totalSize += entrySize
count++
entries.push({
key,
size: entrySize,
})
cursor.continue()
} else {
console.group('🗄️ Skin Preview Storage Debug Info')
console.log(`Total entries: ${count}`)
console.log(`Total size: ${(totalSize / 1024 / 1024).toFixed(2)} MB`)
console.log(
`Average size per entry: ${count > 0 ? (totalSize / count / 1024).toFixed(2) : 0} KB`,
)
if (entries.length > 0) {
const sortedEntries = entries.sort((a, b) => b.size - a.size)
console.log(
'Largest entry:',
sortedEntries[0].key,
'(' + (sortedEntries[0].size / 1024).toFixed(2) + ' KB)',
)
console.log(
'Smallest entry:',
sortedEntries[sortedEntries.length - 1].key,
'(' + (sortedEntries[sortedEntries.length - 1].size / 1024).toFixed(2) + ' KB)',
)
}
console.groupEnd()
resolve()
}
}
request.onerror = () => reject(request.error)
})
}
} }
export const skinPreviewStorage = new SkinPreviewStorage() export const skinPreviewStorage = new SkinPreviewStorage()

View File

@@ -5,22 +5,6 @@ export async function isDev() {
return await invoke('is_dev') return await invoke('is_dev')
} }
export async function areUpdatesEnabled() {
return await invoke('are_updates_enabled')
}
export async function getUpdateSize(updateRid) {
return await invoke('get_update_size', { rid: updateRid })
}
export async function enqueueUpdateForInstallation(updateRid) {
return await invoke('enqueue_update_for_installation', { rid: updateRid })
}
export async function removeEnqueuedUpdate() {
return await invoke('remove_enqueued_update')
}
// One of 'Windows', 'Linux', 'MacOS' // One of 'Windows', 'Linux', 'MacOS'
export async function getOS() { export async function getOS() {
return await invoke('plugin:utils|get_os') return await invoke('plugin:utils|get_os')
@@ -53,6 +37,13 @@ export async function restartApp() {
return await invoke('restart_app') return await invoke('restart_app')
} }
/**
* @deprecated This method is no longer needed, and just returns its parameter
*/
export function sanitizePotentialFileUrl(url) {
return url
}
export const releaseColor = (releaseType) => { export const releaseColor = (releaseType) => {
switch (releaseType) { switch (releaseType) {
case 'release': case 'release':

View File

@@ -20,45 +20,6 @@
"app.settings.tabs.resource-management": { "app.settings.tabs.resource-management": {
"message": "Resource management" "message": "Resource management"
}, },
"app.update.changelog": {
"message": "View changelog"
},
"app.update.complete-toast.text": {
"message": "Click here to view the changelog."
},
"app.update.complete-toast.title": {
"message": "Version {version} was successfully installed!"
},
"app.update.copied-error": {
"message": "Copied to clipboard!"
},
"app.update.copy-error": {
"message": "Copy error"
},
"app.update.download-error": {
"message": "An error occurred while downloading the update. Please try again later. Contact support if the issue persists."
},
"app.update.download-size": {
"message": "The update is {size}."
},
"app.update.hide": {
"message": "Hide update reminder"
},
"app.update.later": {
"message": "Update on exit"
},
"app.update.modal-body-version": {
"message": "We recommend updating as soon as possible so you can enjoy the latest features, fixes, and improvements."
},
"app.update.modal-header": {
"message": "Update available - "
},
"app.update.restart": {
"message": "Update now"
},
"app.update.try-again": {
"message": "Try again"
},
"instance.add-server.add-and-play": { "instance.add-server.add-and-play": {
"message": "Add and play" "message": "Add and play"
}, },
@@ -416,12 +377,6 @@
"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+"
}, },

View File

@@ -220,7 +220,7 @@ async function refreshSearch() {
} }
} }
results.value = rawResults.result results.value = rawResults.result
currentPage.value = 1 currentPage.value = Math.max(1, Math.min(pageCount.value, currentPage.value))
const persistentParams: LocationQuery = {} const persistentParams: LocationQuery = {}
@@ -266,7 +266,6 @@ async function onSearchChangeToTop() {
function clearSearch() { function clearSearch() {
query.value = '' query.value = ''
currentPage.value = 1
} }
watch( watch(

View File

@@ -38,7 +38,7 @@ import {
import { get as getSettings } from '@/helpers/settings.ts' import { get as getSettings } from '@/helpers/settings.ts'
import { get_default_user, login as login_flow, users } from '@/helpers/auth' import { get_default_user, login as login_flow, users } from '@/helpers/auth'
import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts' import type { RenderResult } from '@/helpers/rendering/batch-skin-renderer.ts'
import { generateSkinPreviews, skinBlobUrlMap } from '@/helpers/rendering/batch-skin-renderer.ts' import { generateSkinPreviews, map } from '@/helpers/rendering/batch-skin-renderer.ts'
import { handleSevereError } from '@/store/error' import { handleSevereError } from '@/store/error'
import { trackEvent } from '@/helpers/analytics' import { trackEvent } from '@/helpers/analytics'
import type AccountsCard from '@/components/ui/AccountsCard.vue' import type AccountsCard from '@/components/ui/AccountsCard.vue'
@@ -215,7 +215,7 @@ async function loadCurrentUser() {
function getBakedSkinTextures(skin: Skin): RenderResult | undefined { function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}` const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
return skinBlobUrlMap.get(key) return map.get(key)
} }
async function login() { async function login() {

View File

@@ -483,7 +483,7 @@ onUnmounted(() => {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 1rem; gap: 1rem;
height: 100vh; height: calc(100vh - 11rem);
} }
.button-row { .button-row {

View File

@@ -5,8 +5,8 @@
"build": "tauri build", "build": "tauri build",
"dev": "tauri dev", "dev": "tauri dev",
"test": "cargo nextest run --all-targets --no-fail-fast", "test": "cargo nextest run --all-targets --no-fail-fast",
"lint": "cargo fmt --check && cargo clippy --all-targets && cargo clippy --all-targets --features updater", "lint": "cargo fmt --check && cargo clippy --all-targets",
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo clippy --all-targets --features updater --fix --allow-dirty && cargo fmt" "fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "2.5.0" "@tauri-apps/cli": "2.5.0"

View File

@@ -45,12 +45,8 @@ pub enum TheseusSerializableError {
Tauri(#[from] tauri::Error), Tauri(#[from] tauri::Error),
#[cfg(feature = "updater")] #[cfg(feature = "updater")]
#[error("Updater error: {0}")] #[error("Tauri updater error: {0}")]
Updater(#[from] tauri_plugin_updater::Error), TauriUpdater(#[from] tauri_plugin_updater::Error),
#[cfg(feature = "updater")]
#[error("HTTP error: {0}")]
Http(#[from] tauri_plugin_http::reqwest::Error),
} }
// Generic implementation of From<T> for ErrorTypeA // Generic implementation of From<T> for ErrorTypeA
@@ -108,6 +104,5 @@ impl_serialize! {
impl_serialize! { impl_serialize! {
IO, IO,
Tauri, Tauri,
Updater, TauriUpdater,
Http,
} }

View File

@@ -14,11 +14,6 @@ mod error;
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
mod macos; mod macos;
#[cfg(feature = "updater")]
mod updater_impl;
#[cfg(not(feature = "updater"))]
mod updater_impl_noop;
// Should be called in launcher initialization // Should be called in launcher initialization
#[tracing::instrument(skip_all)] #[tracing::instrument(skip_all)]
#[tauri::command] #[tauri::command]
@@ -26,9 +21,75 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
tracing::info!("Initializing app event state..."); tracing::info!("Initializing app event state...");
theseus::EventState::init(app.clone()).await?; theseus::EventState::init(app.clone()).await?;
tracing::info!("Initializing app state..."); #[cfg(feature = "updater")]
State::init().await?; 'updater: {
if env::var("MODRINTH_EXTERNAL_UPDATE_PROVIDER").is_ok() {
State::init().await?;
break 'updater;
}
use tauri_plugin_updater::UpdaterExt;
let updater = app.updater_builder().build()?;
let update_fut = updater.check();
tracing::info!("Initializing app state...");
State::init().await?;
let check_bar = theseus::init_loading(
theseus::LoadingBarType::CheckingForUpdates,
1.0,
"Checking for updates...",
)
.await?;
tracing::info!("Checking for updates...");
let update = update_fut.await;
drop(check_bar);
if let Some(update) = update.ok().flatten() {
tracing::info!("Update found: {:?}", update.download_url);
let loader_bar_id = theseus::init_loading(
theseus::LoadingBarType::LauncherUpdate {
version: update.version.clone(),
current_version: update.current_version.clone(),
},
1.0,
"Updating Modrinth App...",
)
.await?;
// 100 MiB
const DEFAULT_CONTENT_LENGTH: u64 = 1024 * 1024 * 100;
update
.download_and_install(
|chunk_length, content_length| {
let _ = theseus::emit_loading(
&loader_bar_id,
(chunk_length as f64)
/ (content_length
.unwrap_or(DEFAULT_CONTENT_LENGTH)
as f64),
None,
);
},
|| {},
)
.await?;
app.restart();
}
}
#[cfg(not(feature = "updater"))]
{
State::init().await?;
}
tracing::info!("Finished checking for updates!");
let state = State::get().await?; let state = State::get().await?;
app.asset_protocol_scope() app.asset_protocol_scope()
.allow_directory(state.directories.caches_dir(), true)?; .allow_directory(state.directories.caches_dir(), true)?;
@@ -64,17 +125,6 @@ fn is_dev() -> bool {
cfg!(debug_assertions) cfg!(debug_assertions)
} }
#[tauri::command]
fn are_updates_enabled() -> bool {
cfg!(feature = "updater")
}
#[cfg(feature = "updater")]
pub use updater_impl::*;
#[cfg(not(feature = "updater"))]
pub use updater_impl_noop::*;
// Toggles decorations // Toggles decorations
#[tauri::command] #[tauri::command]
async fn toggle_decorations(b: bool, window: tauri::Window) -> api::Result<()> { async fn toggle_decorations(b: bool, window: tauri::Window) -> api::Result<()> {
@@ -116,17 +166,7 @@ fn main() {
#[cfg(feature = "updater")] #[cfg(feature = "updater")]
{ {
use tauri_plugin_http::reqwest::header::{HeaderValue, USER_AGENT}; builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
use theseus::LAUNCHER_USER_AGENT;
builder = builder.plugin(
tauri_plugin_updater::Builder::new()
.header(
USER_AGENT,
HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap(),
)
.unwrap()
.build(),
);
} }
builder = builder builder = builder
@@ -221,14 +261,9 @@ fn main() {
.plugin(api::ads::init()) .plugin(api::ads::init())
.plugin(api::friends::init()) .plugin(api::friends::init())
.plugin(api::worlds::init()) .plugin(api::worlds::init())
.manage(PendingUpdateData::default())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
initialize_state, initialize_state,
is_dev, is_dev,
are_updates_enabled,
get_update_size,
enqueue_update_for_installation,
remove_enqueued_update,
toggle_decorations, toggle_decorations,
show_window, show_window,
restart_app, restart_app,
@@ -240,41 +275,8 @@ fn main() {
match app { match app {
Ok(app) => { Ok(app) => {
app.run(|app, event| { app.run(|app, event| {
#[cfg(not(any(feature = "updater", target_os = "macos")))] #[cfg(not(target_os = "macos"))]
drop((app, event)); drop((app, event));
#[cfg(feature = "updater")]
if matches!(event, tauri::RunEvent::Exit) {
let update_data = app.state::<PendingUpdateData>().inner();
if let Some((update, data)) = &*update_data.0.lock().unwrap() {
fn set_changelog_toast(version: Option<String>) {
let toast_result: theseus::Result<()> = tauri::async_runtime::block_on(async move {
let mut settings = settings::get().await?;
settings.pending_update_toast_for_version = version;
settings::set(settings).await?;
Ok(())
});
if let Err(e) = toast_result {
tracing::warn!("Failed to set pending_update_toast: {e}")
}
}
set_changelog_toast(Some(update.version.clone()));
if let Err(e) = update.install(data) {
tracing::error!("Error while updating: {e}");
set_changelog_toast(None);
DialogBuilder::message()
.set_level(MessageLevel::Error)
.set_title("Update error")
.set_text(format!("Failed to install update due to an error:\n{e}"))
.alert()
.show()
.unwrap();
}
}
}
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
if let tauri::RunEvent::Opened { urls } = event { if let tauri::RunEvent::Opened { urls } = event {
tracing::info!("Handling webview open {urls:?}"); tracing::info!("Handling webview open {urls:?}");
@@ -302,8 +304,6 @@ fn main() {
}); });
} }
Err(e) => { Err(e) => {
tracing::error!("Error while running tauri application: {:?}", e);
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
{ {
// tauri doesn't expose runtime errors, so matching a string representation seems like the only solution // tauri doesn't expose runtime errors, so matching a string representation seems like the only solution
@@ -332,6 +332,7 @@ fn main() {
.show() .show()
.unwrap(); .unwrap();
tracing::error!("Error while running tauri application: {:?}", e);
panic!("{1}: {:?}", e, "error while running tauri application") panic!("{1}: {:?}", e, "error while running tauri application")
} }
} }

View File

@@ -1,121 +0,0 @@
use crate::api::Result;
use std::sync::{Arc, Mutex};
use tauri::http::HeaderValue;
use tauri::http::header::ACCEPT;
use tauri::{Manager, ResourceId, Runtime, Webview};
use tauri_plugin_http::reqwest;
use tauri_plugin_http::reqwest::ClientBuilder;
use tauri_plugin_updater::Error;
use tauri_plugin_updater::Update;
use theseus::{
LAUNCHER_USER_AGENT, LoadingBarType, emit_loading, init_loading,
};
use tokio::time::Instant;
#[derive(Default)]
pub struct PendingUpdateData(pub Mutex<Option<(Arc<Update>, Vec<u8>)>>);
// Reimplementation of Update::download mostly, minus the actual download part
#[tauri::command]
pub async fn get_update_size<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
) -> Result<Option<u64>> {
let update = webview.resources_table().get::<Update>(rid)?;
let mut headers = update.headers.clone();
if !headers.contains_key(ACCEPT) {
headers.insert(
ACCEPT,
HeaderValue::from_static("application/octet-stream"),
);
}
let mut request = ClientBuilder::new().user_agent(LAUNCHER_USER_AGENT);
if let Some(timeout) = update.timeout {
request = request.timeout(timeout);
}
if let Some(ref proxy) = update.proxy {
let proxy = reqwest::Proxy::all(proxy.as_str())?;
request = request.proxy(proxy);
}
let response = request
.build()?
.head(update.download_url.clone())
.headers(headers)
.send()
.await?;
if !response.status().is_success() {
return Err(Error::Network(format!(
"Download request failed with status: {}",
response.status()
))
.into());
}
let content_length = response
.headers()
.get("Content-Length")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.parse().ok());
Ok(content_length)
}
#[tauri::command]
pub async fn enqueue_update_for_installation<R: Runtime>(
webview: Webview<R>,
rid: ResourceId,
) -> Result<()> {
let pending_data = webview.state::<PendingUpdateData>().inner();
let update = webview.resources_table().get::<Update>(rid)?;
let progress = init_loading(
LoadingBarType::LauncherUpdate {
version: update.version.clone(),
current_version: update.current_version.clone(),
},
1.0,
"Downloading update...",
)
.await?;
let download_start = Instant::now();
let update_data = update
.download(
|chunk_size, total_size| {
let Some(total_size) = total_size else {
return;
};
if let Err(e) = emit_loading(
&progress,
chunk_size as f64 / total_size as f64,
None,
) {
tracing::error!(
"Failed to update download progress bar: {e}"
);
}
},
|| {},
)
.await?;
let download_duration = download_start.elapsed();
tracing::info!("Downloaded update in {download_duration:?}");
pending_data
.0
.lock()
.unwrap()
.replace((update, update_data));
Ok(())
}
#[tauri::command]
pub fn remove_enqueued_update<R: Runtime>(webview: Webview<R>) {
let pending_data = webview.state::<PendingUpdateData>().inner();
pending_data.0.lock().unwrap().take();
}

View File

@@ -1,26 +0,0 @@
use crate::api::Result;
use theseus::ErrorKind;
#[derive(Default)]
pub struct PendingUpdateData(());
#[tauri::command]
pub fn get_update_size() -> Result<()> {
updates_are_disabled()
}
#[tauri::command]
pub fn enqueue_update_for_installation() -> Result<()> {
updates_are_disabled()
}
fn updates_are_disabled() -> Result<()> {
let error: theseus::Error = ErrorKind::OtherError(
"Updates are disabled in this build.".to_string(),
)
.into();
Err(error.into())
}
#[tauri::command]
pub fn remove_enqueued_update() {}

View File

@@ -19,6 +19,8 @@ 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.

View File

@@ -10,7 +10,7 @@
"postinstall": "nuxi prepare", "postinstall": "nuxi prepare",
"lint": "eslint . && prettier --check .", "lint": "eslint . && prettier --check .",
"fix": "eslint . --fix && prettier --write .", "fix": "eslint . --fix && prettier --write .",
"intl:extract": "formatjs extract \"{,src/components,src/composables,src/layouts,src/middleware,src/modules,src/pages,src/plugins,src/utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace", "intl:extract": "formatjs extract \"{,src/components,src/composables,src/layouts,src/middleware,src/modules,src/pages,src/plugins,src/utils}/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" \"src/error.vue\" --ignore '**/*.d.ts' --ignore 'node_modules' --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace",
"test": "nuxi build" "test": "nuxi build"
}, },
"devDependencies": { "devDependencies": {
@@ -38,10 +38,9 @@
"@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",
@@ -59,7 +58,6 @@
"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",

View File

@@ -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);
} }

View File

@@ -115,12 +115,10 @@ 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: --shadow-raised: 0.3px 0.5px 0.6px hsl(var(--shadow-color) / 0.15),
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: --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) 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;
@@ -152,8 +150,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: --landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16),
2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(255, 255, 255, 0.45); 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);
@@ -253,15 +251,13 @@ 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: --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) 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: --landing-maze-gradient-bg: linear-gradient(0deg, #31375f 0%, rgba(8, 14, 55, 0) 100%),
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%);
@@ -288,8 +284,7 @@ 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: --landing-blob-shadow: 2px 2px 12px rgba(0, 0, 0, 0.16), inset 2px 2px 64px rgba(57, 61, 94, 0.45);
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);
@@ -365,9 +360,8 @@ body {
// Defaults // Defaults
background-color: var(--color-bg); background-color: var(--color-bg);
color: var(--color-text); color: var(--color-text);
--font-standard: --font-standard: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto,
Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif;
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;

View File

@@ -1,10 +1,7 @@
<template> <template>
<div <div
class="vue-notification-group experimental-styles-within" class="vue-notification-group experimental-styles-within"
:class="{ :class="{ 'intercom-present': isIntercomPresent }"
'intercom-present': isIntercomPresent,
rightwards: moveNotificationsRight,
}"
> >
<transition-group name="notifs"> <transition-group name="notifs">
<div <div
@@ -85,7 +82,6 @@ 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);
@@ -164,15 +160,6 @@ 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;

View File

@@ -1,116 +0,0 @@
<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>

View File

@@ -1,422 +0,0 @@
<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 obtained. You may skip this step!</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 } 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 = ref<ModerationModpackItem[] | null>(null);
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 sortedData: ModerationModpackItem[] = [
...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 = [];
persistAll();
}
}
function goToPrevious(): void {
if (currentIndex.value > 0) {
currentIndex.value--;
persistAll();
}
}
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(
() => props.projectId,
() => {
clearPersistedData();
loadPersistedData();
if (!modPackData.value) {
fetchModPackData();
}
},
);
</script>
<style scoped>
.input-group {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.modpack-buttons {
margin-top: 1rem;
}
</style>

View File

@@ -172,7 +172,6 @@ const flags = useFeatureFlags();
.markdown-body { .markdown-body {
grid-area: body; grid-area: body;
max-width: 100%;
} }
.reporter-info { .reporter-info {

View File

@@ -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

View File

@@ -1,7 +1,36 @@
<template> <template>
<NewModal ref="mrpackModal" header="Uploading mrpack" :closable="!isLoading" @show="onShow"> <NewModal ref="mrpackModal" header="Uploading mrpack" :closable="!isLoading" @show="onShow">
<div class="flex flex-col gap-4 md:w-[600px]"> <div class="flex flex-col gap-4 md:w-[600px]">
<AppearingProgressBar :max-value="totalBytes" :current-value="uploadedBytes" /> <Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-20"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-20"
leave-to-class="opacity-0 max-h-0"
>
<div v-if="isLoading" class="w-full">
<div class="mb-2 flex justify-between text-sm">
<Transition name="phrase-fade" mode="out-in">
<span :key="currentPhrase" class="text-lg font-medium text-contrast">{{
currentPhrase
}}</span>
</Transition>
<div class="flex flex-col items-end">
<span class="text-secondary">{{ Math.round(uploadProgress) }}%</span>
<span class="text-xs text-secondary"
>{{ formatBytes(uploadedBytes) }} / {{ formatBytes(totalBytes) }}</span
>
</div>
</div>
<div class="h-2 w-full rounded-full bg-divider">
<div
class="h-2 animate-pulse rounded-full bg-brand transition-all duration-300 ease-out"
:style="{ width: `${uploadProgress}%` }"
></div>
</div>
</div>
</Transition>
<Transition <Transition
enter-active-class="transition-all duration-300 ease-out" enter-active-class="transition-all duration-300 ease-out"
@@ -115,7 +144,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { BackupWarning, ButtonStyled, NewModal, AppearingProgressBar } from "@modrinth/ui"; import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
import { import {
UploadIcon, UploadIcon,
RightArrowIcon, RightArrowIcon,
@@ -158,9 +187,50 @@ const hardReset = ref(false);
const isLoading = ref(false); const isLoading = ref(false);
const loadingServerCheck = ref(false); const loadingServerCheck = ref(false);
const mrpackFile = ref<File | null>(null); const mrpackFile = ref<File | null>(null);
const uploadProgress = ref(0);
const uploadedBytes = ref(0); const uploadedBytes = ref(0);
const totalBytes = ref(0); const totalBytes = ref(0);
const uploadPhrases = [
"Removing Herobrine...",
"Feeding parrots...",
"Teaching villagers new trades...",
"Convincing creepers to be friendly...",
"Polishing diamonds...",
"Training wolves to fetch...",
"Building pixel art...",
"Explaining redstone to beginners...",
"Collecting all the cats...",
"Negotiating with endermen...",
"Planting suspicious stew ingredients...",
"Calibrating TNT blast radius...",
"Teaching chickens to fly...",
"Sorting inventory alphabetically...",
"Convincing iron golems to smile...",
];
const currentPhrase = ref("Uploading...");
let phraseInterval: NodeJS.Timeout | null = null;
const usedPhrases = ref(new Set<number>());
const getNextPhrase = () => {
if (usedPhrases.value.size >= uploadPhrases.length) {
const currentPhraseIndex = uploadPhrases.indexOf(currentPhrase.value);
usedPhrases.value.clear();
if (currentPhraseIndex !== -1) {
usedPhrases.value.add(currentPhraseIndex);
}
}
const availableIndices = uploadPhrases
.map((_, index) => index)
.filter((index) => !usedPhrases.value.has(index));
const randomIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)];
usedPhrases.value.add(randomIndex);
return uploadPhrases[randomIndex];
};
const isDangerous = computed(() => hardReset.value); const isDangerous = computed(() => hardReset.value);
const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value); const canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value);
@@ -189,17 +259,31 @@ const handleReinstall = async () => {
} }
isLoading.value = true; isLoading.value = true;
uploadProgress.value = 0;
uploadProgress.value = 0;
uploadedBytes.value = 0; uploadedBytes.value = 0;
totalBytes.value = mrpackFile.value.size; totalBytes.value = mrpackFile.value.size;
currentPhrase.value = getNextPhrase();
phraseInterval = setInterval(() => {
currentPhrase.value = getNextPhrase();
}, 4500);
const { onProgress, promise } = props.server.general.reinstallFromMrpack( const { onProgress, promise } = props.server.general.reinstallFromMrpack(
mrpackFile.value, mrpackFile.value,
hardReset.value, hardReset.value,
); );
onProgress(({ loaded, total }) => { onProgress(({ loaded, total, progress }) => {
uploadProgress.value = progress;
uploadedBytes.value = loaded; uploadedBytes.value = loaded;
totalBytes.value = total; totalBytes.value = total;
if (phraseInterval && progress >= 100) {
clearInterval(phraseInterval);
phraseInterval = null;
currentPhrase.value = "Installing modpack...";
}
}); });
try { try {
@@ -232,6 +316,10 @@ const handleReinstall = async () => {
} }
} finally { } finally {
isLoading.value = false; isLoading.value = false;
if (phraseInterval) {
clearInterval(phraseInterval);
phraseInterval = null;
}
} }
}; };
const onShow = () => { const onShow = () => {
@@ -240,8 +328,15 @@ const onShow = () => {
loadingServerCheck.value = false; loadingServerCheck.value = false;
isLoading.value = false; isLoading.value = false;
mrpackFile.value = null; mrpackFile.value = null;
uploadProgress.value = 0;
uploadedBytes.value = 0; uploadedBytes.value = 0;
totalBytes.value = 0; totalBytes.value = 0;
currentPhrase.value = "Uploading...";
usedPhrases.value.clear();
if (phraseInterval) {
clearInterval(phraseInterval);
phraseInterval = null;
}
}; };
const show = () => mrpackModal.value?.show(); const show = () => mrpackModal.value?.show();
@@ -254,4 +349,14 @@ defineExpose({ show, hide });
.stylized-toggle:checked::after { .stylized-toggle:checked::after {
background: var(--color-accent-contrast) !important; background: var(--color-accent-contrast) !important;
} }
.phrase-fade-enter-active,
.phrase-fade-leave-active {
transition: opacity 0.3s ease;
}
.phrase-fade-enter-from,
.phrase-fade-leave-to {
opacity: 0;
}
</style> </style>

View File

@@ -194,12 +194,13 @@ export class ModrinthServer {
} }
async testNodeReachability(): Promise<boolean> { async testNodeReachability(): Promise<boolean> {
if (!this.general?.node?.instance) { if (!this.general?.datacenter) {
console.warn("No node instance available for ping test"); console.warn("No datacenter info available for ping test");
return false; return false;
} }
const wsUrl = `wss://${this.general.node.instance}/pingtest`; const datacenter = this.general.datacenter;
const wsUrl = `wss://${datacenter}.nodes.modrinth.com/pingtest`;
try { try {
return await new Promise((resolve) => { return await new Promise((resolve) => {

View File

@@ -112,8 +112,7 @@ export async function useServersFetch<T>(
const response = await $fetch<T>(fullUrl, { const response = await $fetch<T>(fullUrl, {
method, method,
headers, headers,
body: body: body && contentType === "application/json" ? JSON.stringify(body) : body ?? undefined,
body && contentType === "application/json" ? JSON.stringify(body) : (body ?? undefined),
timeout: 10000, timeout: 10000,
}); });

View File

@@ -1,12 +0,0 @@
export const useNotificationRightwards = () => {
const isVisible = useState("moderation-checklist-notifications", () => false);
const setVisible = (visible: boolean) => {
isVisible.value = visible;
};
return {
isVisible: readonly(isVisible),
setVisible,
};
};

View File

@@ -700,6 +700,7 @@ import {
PackageOpenIcon, PackageOpenIcon,
DiscordIcon, DiscordIcon,
BlueskyIcon, BlueskyIcon,
TumblrIcon,
TwitterIcon, TwitterIcon,
MastodonIcon, MastodonIcon,
GithubIcon, GithubIcon,
@@ -1184,6 +1185,13 @@ 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",
@@ -1338,15 +1346,6 @@ const footerLinks = [
}), }),
), ),
}, },
{
href: "/legal/copyright",
label: formatMessage(
defineMessage({
id: "layout.footer.legal.copyright-policy",
defaultMessage: "Copyright Policy and DMCA",
}),
),
},
], ],
}, },
]; ];

View File

@@ -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,9 +404,6 @@
"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"
}, },
@@ -461,6 +458,9 @@
"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"
}, },

View File

@@ -29,11 +29,12 @@
class="settings-header__icon" class="settings-header__icon"
/> />
<div class="settings-header__text"> <div class="settings-header__text">
<h1 class="wrap-as-needed">{{ project.title }}</h1> <h1 class="wrap-as-needed">
{{ project.title }}
</h1>
<ProjectStatusBadge :status="project.status" /> <ProjectStatusBadge :status="project.status" />
</div> </div>
</div> </div>
<h2>Project settings</h2> <h2>Project settings</h2>
<NavStack> <NavStack>
<NavStackItem <NavStackItem
@@ -110,7 +111,6 @@
</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,7 +145,6 @@
/> />
</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>
@@ -175,11 +174,9 @@
<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"
> >
@@ -222,7 +219,8 @@
:href="`modrinth://mod/${project.slug}`" :href="`modrinth://mod/${project.slug}`"
@click="() => installWithApp()" @click="() => installWithApp()"
> >
<ModrinthIcon aria-hidden="true" /> Install with Modrinth App <ModrinthIcon aria-hidden="true" />
Install with Modrinth App
<ExternalIcon aria-hidden="true" /> <ExternalIcon aria-hidden="true" />
</a> </a>
</ButtonStyled> </ButtonStyled>
@@ -242,7 +240,6 @@
<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">
@@ -330,7 +327,8 @@
} }
" "
> >
{{ gameVersion }} <CheckIcon v-if="userSelectedGameVersion === gameVersion" /> {{ gameVersion }}
<CheckIcon v-if="userSelectedGameVersion === gameVersion" />
</button> </button>
</ButtonStyled> </ButtonStyled>
</ScrollablePanel> </ScrollablePanel>
@@ -421,6 +419,7 @@
</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"
@@ -471,14 +470,10 @@
class="new-page sidebar" class="new-page sidebar"
:class="{ :class="{
'alt-layout': cosmetics.leftContentLayout, 'alt-layout': cosmetics.leftContentLayout,
'checklist-open': 'ultimate-sidebar':
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">
@@ -490,11 +485,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" /> Download <DownloadIcon aria-hidden="true" />
Download
</button> </button>
</ButtonStyled> </ButtonStyled>
</div> </div>
<div class="contents sm:hidden"> <div class="contents sm:hidden">
<ButtonStyled <ButtonStyled
size="large" size="large"
@@ -559,11 +554,9 @@
</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>
@@ -628,7 +621,6 @@
{{ 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>
@@ -636,7 +628,8 @@
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" /> Create new collection <PlusIcon aria-hidden="true" />
Create new collection
</button> </button>
</template> </template>
</PopoutMenu> </PopoutMenu>
@@ -719,14 +712,25 @@
:dropdown-id="`${baseId}-more-options`" :dropdown-id="`${baseId}-more-options`"
> >
<MoreVerticalIcon aria-hidden="true" /> <MoreVerticalIcon aria-hidden="true" />
<template #analytics> <ChartIcon aria-hidden="true" /> Analytics </template> <template #analytics>
<template #moderation-checklist> <ChartIcon aria-hidden="true" />
<ScaleIcon aria-hidden="true" /> Review project Analytics
</template>
<template #moderation-checklist>
<ScaleIcon aria-hidden="true" />
Review project
</template>
<template #report>
<ReportIcon aria-hidden="true" />
Report
</template>
<template #copy-id>
<ClipboardCopyIcon aria-hidden="true" />
Copy ID
</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" /> Copy permanent link <ClipboardCopyIcon aria-hidden="true" />
Copy permanent link
</template> </template>
</OverflowMenu> </OverflowMenu>
</ButtonStyled> </ButtonStyled>
@@ -752,7 +756,6 @@
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"
@@ -782,7 +785,6 @@
/> />
<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" />
@@ -811,48 +813,53 @@
<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>{{ formatMessage(detailsMessages.published, { date: publishedDate }) }}</div> <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>{{ formatMessage(detailsMessages.created, { date: createdDate }) }}</div> <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>{{ formatMessage(detailsMessages.submitted, { date: submittedDate }) }}</div> <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>{{ formatMessage(detailsMessages.updated, { date: updatedDate }) }}</div> <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"><NavTabs :links="navLinks" class="mb-4" /></div> <div class="overflow-x-auto">
<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"
@@ -870,10 +877,8 @@
@delete-version="deleteVersion" @delete-version="deleteVersion"
/> />
</div> </div>
<div class="normal-page__ultimate-sidebar"> <div class="normal-page__ultimate-sidebar">
<!-- Uncomment this to enable the old moderation checklist. --> <ModerationChecklist
<!-- <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"
@@ -881,25 +886,11 @@
: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,
@@ -959,16 +950,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";
@@ -976,7 +967,6 @@ 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();
@@ -990,7 +980,6 @@ 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();
@@ -1562,28 +1551,12 @@ async function copyPermalink() {
const collapsedChecklist = ref(false); const collapsedChecklist = ref(false);
const showModerationChecklist = useLocalStorage( const showModerationChecklist = ref(false);
`show-moderation-checklist-${project.value.id}`, const collapsedModerationChecklist = ref(false);
false, const futureProjects = ref([]);
);
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) {
@@ -1653,7 +1626,6 @@ const navLinks = computed(() => {
]; ];
}); });
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.settings-header { .settings-header {
display: flex; display: flex;
@@ -1809,16 +1781,4 @@ 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>

View File

@@ -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 {

View File

@@ -150,26 +150,9 @@
</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 }}

View File

@@ -1421,8 +1421,7 @@ useSeoMeta({
width: 25rem; width: 25rem;
height: 25rem; height: 25rem;
opacity: 0.75; opacity: 0.75;
background: background: radial-gradient(
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%

View File

@@ -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(() => {

View File

@@ -212,10 +212,6 @@ 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: {
@@ -224,6 +220,7 @@ async function goToProjects() {
}, },
state: { state: {
showChecklist: true, showChecklist: true,
projects: projectsFiltered.value.slice(1).map((x) => (x.slug ? x.slug : x.id)),
}, },
}); });
} }

View File

@@ -149,8 +149,7 @@ onMounted(() => {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.main-hero { .main-hero {
background: background: linear-gradient(360deg, rgba(199, 138, 255, 0.2) 10.92%, var(--color-bg) 100%),
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;

View File

@@ -45,9 +45,8 @@
<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: Java Edition server. Modrinth Servers is the easiest way to host your own Minecraft server. Seamlessly install
Seamlessly install and play your favorite mods and modpacks, all within the Modrinth and play your favorite mods and modpacks, all within the Modrinth platform.
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
@@ -428,8 +427,11 @@
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, with up to 17Tbps capacity in Yes. All Modrinth Servers come with DDoS protection powered by
some locations. <a href="https://us.ovhcloud.com/security/anti-ddos/" target="_blank"
>OVHcloud® Anti-DDoS infrastructure</a
>
which has over 17Tbps capacity. Your server is safe on Modrinth.
</p> </p>
</details> </details>
@@ -441,9 +443,8 @@
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 available in North America and Europe at the moment that you can We have servers in both North America in Vint Hill, Virginia, and Europe in Limburg,
choose upon purchase. More regions to come in the future! If you'd like to switch Germany. More regions to come in the future!
your region, please contact support.
</p> </p>
</details> </details>
@@ -460,7 +461,7 @@
</p> </p>
</details> </details>
<details pyro-hash="performance" class="group" :open="$route.hash === '#performance'"> <details pyro-hash="players" class="group" :open="$route.hash === '#players'">
<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 />
@@ -481,7 +482,7 @@
</p> </p>
</details> </details>
<details pyro-hash="prices" class="group" :open="$route.hash === '#prices'"> <details pyro-hash="players" 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 />
@@ -492,24 +493,6 @@
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>

View File

@@ -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,8 +1277,7 @@ useHead({
background-repeat: no-repeat; background-repeat: no-repeat;
filter: blur(1rem); filter: blur(1rem);
content: ""; content: "";
background-image: background-image: linear-gradient(
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)

View File

@@ -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>

View File

@@ -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": "Modrinth's Anniversary Update",
"summary": "Marking two years of Modrinth and discussing our New Year's Resolutions for 2023.",
"thumbnail": "https://modrinth.com/news/article/two-years-of-modrinth/thumbnail.webp",
"date": "2023-01-07T00:00:00.000Z",
"link": "https://modrinth.com/news/article/two-years-of-modrinth"
},
{ {
"title": "Two years of Modrinth: a retrospective", "title": "Two years of Modrinth: a retrospective",
"summary": "The history of Modrinth as we know it from December 2020 to December 2022.", "summary": "The history of Modrinth as we know it from December 2020 to December 2022.",
@@ -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-history" "link": "https://modrinth.com/news/article/two-years-of-modrinth-history"
}, },
{
"title": "Modrinth's Anniversary Update",
"summary": "Marking two years of Modrinth and discussing our New Year's Resolutions for 2023.",
"thumbnail": "https://modrinth.com/news/article/two-years-of-modrinth/thumbnail.webp",
"date": "2023-01-07T00:00:00.000Z",
"link": "https://modrinth.com/news/article/two-years-of-modrinth"
},
{ {
"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

View File

@@ -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' OR status = 'failed')", "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')",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -102,5 +102,5 @@
true true
] ]
}, },
"hash": "cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055" "hash": "99cca53fd3f35325e2da3b671532bf98b8c7ad8e7cb9158e4eb9c5bac66d20b2"
} }

View File

@@ -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-rustls.workspace = true hyper-tls.workspace = true
hyper-util.workspace = true hyper-util.workspace = true
serde = { workspace = true, features = ["derive"] } serde = { workspace = true, features = ["derive"] }

View File

@@ -11,7 +11,7 @@ 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 dumb-init curl \ && apt-get install -y --no-install-recommends ca-certificates openssl 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=build /usr/src/labrinth/target/release/labrinth /labrinth/labrinth

View File

@@ -43,9 +43,7 @@ pub enum AuthenticationError {
InvalidAuthMethod, InvalidAuthMethod,
#[error("GitHub Token from incorrect Client ID")] #[error("GitHub Token from incorrect Client ID")]
InvalidClientId, InvalidClientId,
#[error( #[error("User email/account is already registered on Modrinth")]
"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,

View File

@@ -1,4 +1,5 @@
use hyper_rustls::HttpsConnectorBuilder; use hyper_tls::{HttpsConnector, native_tls};
use hyper_util::client::legacy::connect::HttpConnector;
use hyper_util::rt::TokioExecutor; use hyper_util::rt::TokioExecutor;
mod fetch; mod fetch;
@@ -14,11 +15,13 @@ 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 https_connector = HttpsConnectorBuilder::new() let mut http_connector = HttpConnector::new();
.with_native_roots()? http_connector.enforce_http(false); // allow https URLs
.https_or_http()
.enable_all_versions() let tls_connector =
.build(); native_tls::TlsConnector::builder().build().unwrap().into();
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);

View File

@@ -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' OR status = 'failed')", "WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled')",
user_subscription_id user_subscription_id
) )
.fetch_optional(exec) .fetch_optional(exec)

View File

@@ -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.clone(), email: self.email,
email_verified: self.email.is_some(), email_verified: true,
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_case_insensitive_email( if crate::database::models::DBUser::get_by_email(
&new_account.email, &new_account.email,
&**pool, &**pool,
) )
.await? .await?
.is_empty() .is_some()
{ {
return Err(ApiError::InvalidInput( return Err(ApiError::InvalidInput(
"Email is already registered on Modrinth! Try 'Forgot password' to access your account.".to_string(), "Email is already registered on Modrinth!".to_string(),
)); ));
} }
@@ -2220,18 +2220,6 @@ 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!(

View File

@@ -13,9 +13,7 @@
"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",

View File

@@ -1,10 +1,2 @@
MODRINTH_URL=http://localhost:3000/ # SQLite database file location
MODRINTH_API_URL=http://127.0.0.1:8000/v2/ DATABASE_URL=sqlite:///tmp/Modrinth/code/packages/app-lib/.sqlx/generated/state.db
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

View File

@@ -1,10 +0,0 @@
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

View File

@@ -1,10 +0,0 @@
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

View File

@@ -1,12 +1,12 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28,\n\n skipped_update = $29,\n pending_update_toast_for_version = $30\n ", "query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28\n ",
"describe": { "describe": {
"columns": [], "columns": [],
"parameters": { "parameters": {
"Right": 30 "Right": 28
}, },
"nullable": [] "nullable": []
}, },
"hash": "cd7c9f394e4f0ec8fd0043352f7382ac2570aaa096cd2ebb4de990f4d42cc5c9" "hash": "3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c"
} }

View File

@@ -1,6 +1,6 @@
{ {
"db_name": "SQLite", "db_name": "SQLite",
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version\n FROM settings\n ", "query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar\n FROM settings\n ",
"describe": { "describe": {
"columns": [ "columns": [
{ {
@@ -142,16 +142,6 @@
"name": "toggle_sidebar", "name": "toggle_sidebar",
"ordinal": 27, "ordinal": 27,
"type_info": "Integer" "type_info": "Integer"
},
{
"name": "skipped_update",
"ordinal": 28,
"type_info": "Text"
},
{
"name": "pending_update_toast_for_version",
"ordinal": 29,
"type_info": "Text"
} }
], ],
"parameters": { "parameters": {
@@ -185,10 +175,8 @@
true, true,
false, false,
null, null,
false, false
true,
true
] ]
}, },
"hash": "30fecc13c6ea4da1e99e35d307aa8e7c4e7f15ea99527e7974619ef8ed946abe" "hash": "5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca"
} }

View File

@@ -41,7 +41,7 @@
{ {
"name": "display_claims!: serde_json::Value", "name": "display_claims!: serde_json::Value",
"ordinal": 7, "ordinal": 7,
"type_info": "Text" "type_info": "Null"
} }
], ],
"parameters": { "parameters": {

View File

@@ -82,7 +82,6 @@ ariadne.workspace = true
winreg.workspace = true winreg.workspace = true
[build-dependencies] [build-dependencies]
dotenvy.workspace = true
dunce.workspace = true dunce.workspace = true
[features] [features]

View File

@@ -4,31 +4,12 @@ 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();
@@ -56,7 +37,6 @@ fn build_java_jars() {
.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));

View File

@@ -1,2 +0,0 @@
ALTER TABLE settings
ADD COLUMN skipped_update TEXT NULL;

View File

@@ -1,2 +0,0 @@
ALTER TABLE settings
ADD COLUMN pending_update_toast_for_version TEXT NULL;

View File

@@ -1,7 +1,7 @@
use crate::state::ModrinthCredentials; use crate::state::ModrinthCredentials;
#[tracing::instrument] #[tracing::instrument]
pub fn authenticate_begin_flow() -> &'static str { pub fn authenticate_begin_flow() -> String {
crate::state::get_login_url() crate::state::get_login_url()
} }

View File

@@ -284,12 +284,6 @@ 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,

View File

@@ -0,0 +1,13 @@
//! 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/";

View File

@@ -176,6 +176,7 @@ pub enum LoadingBarType {
import_location: PathBuf, import_location: PathBuf,
profile_name: String, profile_name: String,
}, },
CheckingForUpdates,
LauncherUpdate { LauncherUpdate {
version: String, version: String,
current_version: String, current_version: String,

View File

@@ -11,6 +11,7 @@ and launching Modrinth mod packs
mod util; mod util;
mod api; mod api;
mod config;
mod error; mod error;
mod event; mod event;
mod launcher; mod launcher;
@@ -25,9 +26,3 @@ pub use event::{
}; };
pub use logger::start_logger; pub use logger::start_logger;
pub use state::State; pub use state::State;
pub const LAUNCHER_USER_AGENT: &str = concat!(
"modrinth/theseus/",
env!("CARGO_PKG_VERSION"),
" (support@modrinth.com)"
);

View File

@@ -25,11 +25,12 @@ pub fn start_logger() -> Option<()> {
.unwrap_or_else(|_| { .unwrap_or_else(|_| {
tracing_subscriber::EnvFilter::new("theseus=info,theseus_gui=info") tracing_subscriber::EnvFilter::new("theseus=info,theseus_gui=info")
}); });
tracing_subscriber::registry() let subscriber = tracing_subscriber::registry()
.with(tracing_subscriber::fmt::layer()) .with(tracing_subscriber::fmt::layer())
.with(filter) .with(filter)
.with(tracing_error::ErrorLayer::default()) .with(tracing_error::ErrorLayer::default());
.init(); tracing::subscriber::set_global_default(subscriber)
.expect("setting default subscriber failed");
Some(()) Some(())
} }
@@ -75,7 +76,7 @@ pub fn start_logger() -> Option<()> {
let filter = tracing_subscriber::EnvFilter::try_from_default_env() let filter = tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("theseus=info")); .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("theseus=info"));
tracing_subscriber::registry() let subscriber = tracing_subscriber::registry()
.with( .with(
tracing_subscriber::fmt::layer() tracing_subscriber::fmt::layer()
.with_writer(file) .with_writer(file)
@@ -83,8 +84,10 @@ pub fn start_logger() -> Option<()> {
.with_timer(ChronoLocal::rfc_3339()), .with_timer(ChronoLocal::rfc_3339()),
) )
.with(filter) .with(filter)
.with(tracing_error::ErrorLayer::default()) .with(tracing_error::ErrorLayer::default());
.init();
tracing::subscriber::set_global_default(subscriber)
.expect("Setting default subscriber failed");
Some(()) Some(())
} }

View File

@@ -1,3 +1,4 @@
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};
@@ -7,7 +8,6 @@ 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,
env!("MODRINTH_API_URL"), 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,
env!("MODRINTH_API_URL"), 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,
env!("MODRINTH_API_URL"), 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,
env!("MODRINTH_API_URL_V3"), 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,
env!("MODRINTH_API_URL_V3"), 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,
concat!(env!("MODRINTH_API_URL"), "version_files"), &format!("{MODRINTH_API_URL}version_files"),
None, None,
Some(serde_json::json!({ Some(serde_json::json!({
"algorithm": "sha1", "algorithm": "sha1",
@@ -1119,11 +1119,7 @@ impl CachedEntry {
.map(|x| { .map(|x| {
( (
x.key().to_string(), x.key().to_string(),
format!( format!("{META_URL}{}/v0/manifest.json", x.key()),
"{}{}/v0/manifest.json",
env!("MODRINTH_LAUNCHER_META_URL"),
x.key()
),
) )
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
@@ -1158,7 +1154,7 @@ impl CachedEntry {
CacheValueType::MinecraftManifest => { CacheValueType::MinecraftManifest => {
fetch_original_value!( fetch_original_value!(
MinecraftManifest, MinecraftManifest,
env!("MODRINTH_LAUNCHER_META_URL"), META_URL,
format!( format!(
"minecraft/v{}/manifest.json", "minecraft/v{}/manifest.json",
daedalus::minecraft::CURRENT_FORMAT_VERSION daedalus::minecraft::CURRENT_FORMAT_VERSION
@@ -1169,7 +1165,7 @@ impl CachedEntry {
CacheValueType::Categories => { CacheValueType::Categories => {
fetch_original_value!( fetch_original_value!(
Categories, Categories,
env!("MODRINTH_API_URL"), MODRINTH_API_URL,
"tag/category", "tag/category",
CacheValue::Categories CacheValue::Categories
) )
@@ -1177,7 +1173,7 @@ impl CachedEntry {
CacheValueType::ReportTypes => { CacheValueType::ReportTypes => {
fetch_original_value!( fetch_original_value!(
ReportTypes, ReportTypes,
env!("MODRINTH_API_URL"), MODRINTH_API_URL,
"tag/report_type", "tag/report_type",
CacheValue::ReportTypes CacheValue::ReportTypes
) )
@@ -1185,7 +1181,7 @@ impl CachedEntry {
CacheValueType::Loaders => { CacheValueType::Loaders => {
fetch_original_value!( fetch_original_value!(
Loaders, Loaders,
env!("MODRINTH_API_URL"), MODRINTH_API_URL,
"tag/loader", "tag/loader",
CacheValue::Loaders CacheValue::Loaders
) )
@@ -1193,7 +1189,7 @@ impl CachedEntry {
CacheValueType::GameVersions => { CacheValueType::GameVersions => {
fetch_original_value!( fetch_original_value!(
GameVersions, GameVersions,
env!("MODRINTH_API_URL"), MODRINTH_API_URL,
"tag/game_version", "tag/game_version",
CacheValue::GameVersions CacheValue::GameVersions
) )
@@ -1201,7 +1197,7 @@ impl CachedEntry {
CacheValueType::DonationPlatforms => { CacheValueType::DonationPlatforms => {
fetch_original_value!( fetch_original_value!(
DonationPlatforms, DonationPlatforms,
env!("MODRINTH_API_URL"), MODRINTH_API_URL,
"tag/donation_platform", "tag/donation_platform",
CacheValue::DonationPlatforms CacheValue::DonationPlatforms
) )
@@ -1301,12 +1297,14 @@ 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,
concat!(env!("MODRINTH_API_URL"), "version_files/update"), &version_update_url,
None, None,
Some(serde_json::json!({ Some(serde_json::json!({
"algorithm": "sha1", "algorithm": "sha1",
@@ -1370,11 +1368,7 @@ impl CachedEntry {
.map(|x| { .map(|x| {
( (
x.key().to_string(), x.key().to_string(),
format!( format!("{MODRINTH_API_URL}search{}", x.key()),
"{}search{}",
env!("MODRINTH_API_URL"),
x.key()
),
) )
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();

View File

@@ -1,4 +1,4 @@
use crate::LAUNCHER_USER_AGENT; 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,15 +77,18 @@ impl FriendsSocket {
if let Some(credentials) = credentials { if let Some(credentials) = credentials {
let mut request = format!( let mut request = format!(
"{}_internal/launcher_socket?code={}", "{MODRINTH_SOCKET_URL}_internal/launcher_socket?code={}",
env!("MODRINTH_SOCKET_URL"),
credentials.session credentials.session
) )
.into_client_request()?; .into_client_request()?;
let user_agent = format!(
"modrinth/theseus/{} (support@modrinth.com)",
env!("CARGO_PKG_VERSION")
);
request.headers_mut().insert( request.headers_mut().insert(
"User-Agent", "User-Agent",
HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap(), HeaderValue::from_str(&user_agent).unwrap(),
); );
let res = connect_async(request).await; let res = connect_async(request).await;
@@ -300,7 +303,7 @@ impl FriendsSocket {
) -> crate::Result<Vec<UserFriend>> { ) -> crate::Result<Vec<UserFriend>> {
fetch_json( fetch_json(
Method::GET, Method::GET,
concat!(env!("MODRINTH_API_URL_V3"), "friends"), &format!("{MODRINTH_API_URL_V3}friends"),
None, None,
None, None,
semaphore, semaphore,
@@ -325,7 +328,7 @@ impl FriendsSocket {
) -> crate::Result<()> { ) -> crate::Result<()> {
fetch_advanced( fetch_advanced(
Method::POST, Method::POST,
&format!("{}friend/{user_id}", env!("MODRINTH_API_URL_V3")), &format!("{MODRINTH_API_URL_V3}friend/{user_id}"),
None, None,
None, None,
None, None,
@@ -346,7 +349,7 @@ impl FriendsSocket {
) -> crate::Result<()> { ) -> crate::Result<()> {
fetch_advanced( fetch_advanced(
Method::DELETE, Method::DELETE,
&format!("{}friend/{user_id}", env!("MODRINTH_API_URL_V3")), &format!("{MODRINTH_API_URL_V3}friend/{user_id}"),
None, None,
None, None,
None, None,

View File

@@ -1,3 +1,4 @@
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};
@@ -30,7 +31,7 @@ impl ModrinthCredentials {
let resp = fetch_advanced( let resp = fetch_advanced(
Method::POST, Method::POST,
concat!(env!("MODRINTH_API_URL"), "session/refresh"), &format!("{MODRINTH_API_URL}session/refresh"),
None, None,
None, None,
Some(("Authorization", &*creds.session)), Some(("Authorization", &*creds.session)),
@@ -189,8 +190,8 @@ impl ModrinthCredentials {
} }
} }
pub const fn get_login_url() -> &'static str { pub fn get_login_url() -> String {
concat!(env!("MODRINTH_URL"), "auth/sign-in?launcher=true") format!("{MODRINTH_URL}auth/sign-in?launcher=true")
} }
pub async fn finish_login_flow( pub async fn finish_login_flow(
@@ -215,7 +216,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,
concat!(env!("MODRINTH_API_URL"), "user"), &format!("{MODRINTH_API_URL}user"),
None, None,
None, None,
Some(("Authorization", token)), Some(("Authorization", token)),

View File

@@ -38,9 +38,6 @@ pub struct Settings {
pub developer_mode: bool, pub developer_mode: bool,
pub feature_flags: HashMap<FeatureFlag, bool>, pub feature_flags: HashMap<FeatureFlag, bool>,
pub skipped_update: Option<String>,
pub pending_update_toast_for_version: Option<String>,
} }
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, Hash, PartialEq)] #[derive(Serialize, Deserialize, Debug, Clone, Copy, Eq, Hash, PartialEq)]
@@ -66,8 +63,7 @@ impl Settings {
json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars, json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,
mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start, mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,
hook_pre_launch, hook_wrapper, hook_post_exit, hook_pre_launch, hook_wrapper, hook_post_exit,
custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar, custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar
skipped_update, pending_update_toast_for_version
FROM settings FROM settings
" "
) )
@@ -121,9 +117,6 @@ impl Settings {
.as_ref() .as_ref()
.and_then(|x| serde_json::from_str(x).ok()) .and_then(|x| serde_json::from_str(x).ok())
.unwrap_or_default(), .unwrap_or_default(),
skipped_update: res.skipped_update,
pending_update_toast_for_version: res
.pending_update_toast_for_version,
}) })
} }
@@ -177,10 +170,7 @@ impl Settings {
toggle_sidebar = $26, toggle_sidebar = $26,
feature_flags = $27, feature_flags = $27,
hide_nametag_skins_page = $28, hide_nametag_skins_page = $28
skipped_update = $29,
pending_update_toast_for_version = $30
", ",
max_concurrent_writes, max_concurrent_writes,
max_concurrent_downloads, max_concurrent_downloads,
@@ -209,9 +199,7 @@ impl Settings {
self.migrated, self.migrated,
self.toggle_sidebar, self.toggle_sidebar,
feature_flags, feature_flags,
self.hide_nametag_skins_page, self.hide_nametag_skins_page
self.skipped_update,
self.pending_update_toast_for_version,
) )
.execute(exec) .execute(exec)
.await?; .await?;

View File

@@ -1,6 +1,6 @@
//! 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::LAUNCHER_USER_AGENT; 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;
@@ -20,8 +20,11 @@ pub struct FetchSemaphore(pub Semaphore);
pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| { pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
let mut headers = reqwest::header::HeaderMap::new(); let mut headers = reqwest::header::HeaderMap::new();
let header = let header = reqwest::header::HeaderValue::from_str(&format!(
reqwest::header::HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap(); "modrinth/theseus/{} (support@modrinth.com)",
env!("CARGO_PKG_VERSION")
))
.unwrap();
headers.insert(reqwest::header::USER_AGENT, header); headers.insert(reqwest::header::USER_AGENT, header);
reqwest::Client::builder() reqwest::Client::builder()
.tcp_keepalive(Some(time::Duration::from_secs(10))) .tcp_keepalive(Some(time::Duration::from_secs(10)))
@@ -81,8 +84,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(env!("MODRINTH_API_URL")) || url.starts_with(MODRINTH_API_URL)
|| url.starts_with(env!("MODRINTH_API_URL_V3"))) || url.starts_with(MODRINTH_API_URL_V3))
{ {
crate::state::ModrinthCredentials::get_active(exec).await? crate::state::ModrinthCredentials::get_active(exec).await?
} else { } else {

View File

@@ -12,7 +12,6 @@ import _BellRingIcon from './icons/bell-ring.svg?component'
import _BellIcon from './icons/bell.svg?component' import _BellIcon from './icons/bell.svg?component'
import _BlocksIcon from './icons/blocks.svg?component' import _BlocksIcon from './icons/blocks.svg?component'
import _BoldIcon from './icons/bold.svg?component' import _BoldIcon from './icons/bold.svg?component'
import _BookOpenIcon from './icons/book-open.svg?component'
import _BookTextIcon from './icons/book-text.svg?component' import _BookTextIcon from './icons/book-text.svg?component'
import _BookIcon from './icons/book.svg?component' import _BookIcon from './icons/book.svg?component'
import _BookmarkIcon from './icons/bookmark.svg?component' import _BookmarkIcon from './icons/bookmark.svg?component'
@@ -20,7 +19,6 @@ import _BotIcon from './icons/bot.svg?component'
import _BoxImportIcon from './icons/box-import.svg?component' import _BoxImportIcon from './icons/box-import.svg?component'
import _BoxIcon from './icons/box.svg?component' import _BoxIcon from './icons/box.svg?component'
import _BracesIcon from './icons/braces.svg?component' import _BracesIcon from './icons/braces.svg?component'
import _BrushCleaningIcon from './icons/brush-cleaning.svg?component'
import _CalendarIcon from './icons/calendar.svg?component' import _CalendarIcon from './icons/calendar.svg?component'
import _CardIcon from './icons/card.svg?component' import _CardIcon from './icons/card.svg?component'
import _ChangeSkinIcon from './icons/change-skin.svg?component' import _ChangeSkinIcon from './icons/change-skin.svg?component'
@@ -88,7 +86,6 @@ import _InfoIcon from './icons/info.svg?component'
import _IssuesIcon from './icons/issues.svg?component' import _IssuesIcon from './icons/issues.svg?component'
import _ItalicIcon from './icons/italic.svg?component' import _ItalicIcon from './icons/italic.svg?component'
import _KeyIcon from './icons/key.svg?component' import _KeyIcon from './icons/key.svg?component'
import _KeyboardIcon from './icons/keyboard.svg?component'
import _LanguagesIcon from './icons/languages.svg?component' import _LanguagesIcon from './icons/languages.svg?component'
import _LeftArrowIcon from './icons/left-arrow.svg?component' import _LeftArrowIcon from './icons/left-arrow.svg?component'
import _LibraryIcon from './icons/library.svg?component' import _LibraryIcon from './icons/library.svg?component'
@@ -132,7 +129,6 @@ import _RadioButtonCheckedIcon from './icons/radio-button-checked.svg?component'
import _RadioButtonIcon from './icons/radio-button.svg?component' import _RadioButtonIcon from './icons/radio-button.svg?component'
import _ReceiptTextIcon from './icons/receipt-text.svg?component' import _ReceiptTextIcon from './icons/receipt-text.svg?component'
import _RedoIcon from './icons/redo.svg?component' import _RedoIcon from './icons/redo.svg?component'
import _RefreshCwIcon from './icons/refresh-cw.svg?component'
import _ReplyIcon from './icons/reply.svg?component' import _ReplyIcon from './icons/reply.svg?component'
import _ReportIcon from './icons/report.svg?component' import _ReportIcon from './icons/report.svg?component'
import _RestoreIcon from './icons/restore.svg?component' import _RestoreIcon from './icons/restore.svg?component'
@@ -170,7 +166,6 @@ import _TextQuoteIcon from './icons/text-quote.svg?component'
import _TimerIcon from './icons/timer.svg?component' import _TimerIcon from './icons/timer.svg?component'
import _TransferIcon from './icons/transfer.svg?component' import _TransferIcon from './icons/transfer.svg?component'
import _TrashIcon from './icons/trash.svg?component' import _TrashIcon from './icons/trash.svg?component'
import _TriangleAlertIcon from './icons/triangle-alert.svg?component'
import _UnderlineIcon from './icons/underline.svg?component' import _UnderlineIcon from './icons/underline.svg?component'
import _UndoIcon from './icons/undo.svg?component' import _UndoIcon from './icons/undo.svg?component'
import _UnknownDonationIcon from './icons/unknown-donation.svg?component' import _UnknownDonationIcon from './icons/unknown-donation.svg?component'
@@ -204,7 +199,6 @@ export const BellRingIcon = _BellRingIcon
export const BellIcon = _BellIcon export const BellIcon = _BellIcon
export const BlocksIcon = _BlocksIcon export const BlocksIcon = _BlocksIcon
export const BoldIcon = _BoldIcon export const BoldIcon = _BoldIcon
export const BookOpenIcon = _BookOpenIcon
export const BookTextIcon = _BookTextIcon export const BookTextIcon = _BookTextIcon
export const BookIcon = _BookIcon export const BookIcon = _BookIcon
export const BookmarkIcon = _BookmarkIcon export const BookmarkIcon = _BookmarkIcon
@@ -212,7 +206,6 @@ export const BotIcon = _BotIcon
export const BoxImportIcon = _BoxImportIcon export const BoxImportIcon = _BoxImportIcon
export const BoxIcon = _BoxIcon export const BoxIcon = _BoxIcon
export const BracesIcon = _BracesIcon export const BracesIcon = _BracesIcon
export const BrushCleaningIcon = _BrushCleaningIcon
export const CalendarIcon = _CalendarIcon export const CalendarIcon = _CalendarIcon
export const CardIcon = _CardIcon export const CardIcon = _CardIcon
export const ChangeSkinIcon = _ChangeSkinIcon export const ChangeSkinIcon = _ChangeSkinIcon
@@ -280,7 +273,6 @@ export const InfoIcon = _InfoIcon
export const IssuesIcon = _IssuesIcon export const IssuesIcon = _IssuesIcon
export const ItalicIcon = _ItalicIcon export const ItalicIcon = _ItalicIcon
export const KeyIcon = _KeyIcon export const KeyIcon = _KeyIcon
export const KeyboardIcon = _KeyboardIcon
export const LanguagesIcon = _LanguagesIcon export const LanguagesIcon = _LanguagesIcon
export const LeftArrowIcon = _LeftArrowIcon export const LeftArrowIcon = _LeftArrowIcon
export const LibraryIcon = _LibraryIcon export const LibraryIcon = _LibraryIcon
@@ -324,7 +316,6 @@ export const RadioButtonCheckedIcon = _RadioButtonCheckedIcon
export const RadioButtonIcon = _RadioButtonIcon export const RadioButtonIcon = _RadioButtonIcon
export const ReceiptTextIcon = _ReceiptTextIcon export const ReceiptTextIcon = _ReceiptTextIcon
export const RedoIcon = _RedoIcon export const RedoIcon = _RedoIcon
export const RefreshCwIcon = _RefreshCwIcon
export const ReplyIcon = _ReplyIcon export const ReplyIcon = _ReplyIcon
export const ReportIcon = _ReportIcon export const ReportIcon = _ReportIcon
export const RestoreIcon = _RestoreIcon export const RestoreIcon = _RestoreIcon
@@ -362,7 +353,6 @@ export const TextQuoteIcon = _TextQuoteIcon
export const TimerIcon = _TimerIcon export const TimerIcon = _TimerIcon
export const TransferIcon = _TransferIcon export const TransferIcon = _TransferIcon
export const TrashIcon = _TrashIcon export const TrashIcon = _TrashIcon
export const TriangleAlertIcon = _TriangleAlertIcon
export const UnderlineIcon = _UnderlineIcon export const UnderlineIcon = _UnderlineIcon
export const UndoIcon = _UndoIcon export const UndoIcon = _UndoIcon
export const UnknownDonationIcon = _UnknownDonationIcon export const UnknownDonationIcon = _UnknownDonationIcon

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-book-open-icon lucide-book-open"><path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/></svg>

Before

Width:  |  Height:  |  Size: 403 B

View File

@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-brush-cleaning-icon lucide-brush-cleaning">
<path d="m16 22-1-4" />
<path
d="M19 13.99a1 1 0 0 0 1-1V12a2 2 0 0 0-2-2h-3a1 1 0 0 1-1-1V4a2 2 0 0 0-4 0v5a1 1 0 0 1-1 1H6a2 2 0 0 0-2 2v.99a1 1 0 0 0 1 1" />
<path d="M5 14h14l1.973 6.767A1 1 0 0 1 20 22H4a1 1 0 0 1-.973-1.233z" />
<path d="m8 22 1-4" />
</svg>

Before

Width:  |  Height:  |  Size: 542 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-keyboard-icon lucide-keyboard"><path d="M10 8h.01"/><path d="M12 12h.01"/><path d="M14 8h.01"/><path d="M16 12h.01"/><path d="M18 8h.01"/><path d="M6 8h.01"/><path d="M7 16h10"/><path d="M8 12h.01"/><rect width="20" height="16" x="2" y="4" rx="2"/></svg>

Before

Width:  |  Height:  |  Size: 456 B

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-refresh-cw-icon lucide-refresh-cw"><path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M8 16H3v5"/></svg>

Before

Width:  |  Height:  |  Size: 411 B

View File

@@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
class="lucide lucide-triangle-alert-icon lucide-triangle-alert">
<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3" />
<path d="M12 9v4" />
<path d="M12 17h.01" />
</svg>

Before

Width:  |  Height:  |  Size: 403 B

View File

@@ -83,7 +83,7 @@ export const TwitterIcon = _TwitterIcon
export const WindowsIcon = _WindowsIcon export const WindowsIcon = _WindowsIcon
export const YouTubeIcon = _YouTubeIcon export const YouTubeIcon = _YouTubeIcon
// Skin Models export { default as CapeModel } from './models/cape.gltf?url'
export { default as ClassicPlayerModel } from './models/classic-player.gltf?url' export { default as ClassicPlayerModel } from './models/classic-player.gltf?url'
export { default as SlimPlayerModel } from './models/slim-player.gltf?url' export { default as SlimPlayerModel } from './models/slim-player.gltf?url'

View File

@@ -0,0 +1,92 @@
{
"asset": { "version": "2.0", "generator": "Blockbench 4.12.4 glTF exporter" },
"scenes": [{ "nodes": [1], "name": "blockbench_export" }],
"scene": 0,
"nodes": [
{
"rotation": [0, 0, 0.19509032201612825, 0.9807852804032304],
"translation": [0.15625, 1, 0],
"name": "Cape",
"mesh": 0
},
{ "children": [0] }
],
"bufferViews": [
{ "buffer": 0, "byteOffset": 0, "byteLength": 288, "target": 34962, "byteStride": 12 },
{ "buffer": 0, "byteOffset": 288, "byteLength": 288, "target": 34962, "byteStride": 12 },
{ "buffer": 0, "byteOffset": 576, "byteLength": 192, "target": 34962, "byteStride": 8 },
{ "buffer": 0, "byteOffset": 768, "byteLength": 72, "target": 34963 }
],
"buffers": [
{
"byteLength": 840,
"uri": "data:application/octet-stream;base64,AAAAPQAAAAAAAKA+AAAAPQAAAAAAAKC+AAAAPQAAgL8AAKA+AAAAPQAAgL8AAKC+AAAAvQAAAAAAAKC+AAAAvQAAAAAAAKA+AAAAvQAAgL8AAKC+AAAAvQAAgL8AAKA+AAAAvQAAAAAAAKC+AAAAPQAAAAAAAKC+AAAAvQAAAAAAAKA+AAAAPQAAAAAAAKA+AAAAvQAAgL8AAKA+AAAAPQAAgL8AAKA+AAAAvQAAgL8AAKC+AAAAPQAAgL8AAKC+AAAAvQAAAAAAAKA+AAAAPQAAAAAAAKA+AAAAvQAAgL8AAKA+AAAAPQAAgL8AAKA+AAAAPQAAAAAAAKC+AAAAvQAAAAAAAKC+AAAAPQAAgL8AAKC+AAAAvQAAgL8AAKC+AACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAPwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAACAvwAAAAAAAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgD8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAgL8AAAAAAAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIA/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AAAAAAAAAAAAAIC/AACAPAAAgD0AADA+AACAPQAAgDwAAAg/AAAwPgAACD8AAEA+AACAPQAAsD4AAIA9AABAPgAACD8AALA+AAAIPwAAgDwAAAA9AACAPAAAgD0AADA+AAAAPQAAMD4AAIA9AAAwPgAAAD0AAKg+AAAAPQAAMD4AAAAAAACoPgAAAAAAAEA+AACAPQAAMD4AAIA9AABAPgAACD8AADA+AAAIPwAAAAAAAIA9AACAPAAAgD0AAAAAAAAIPwAAgDwAAAg/AAACAAEAAgADAAEABAAGAAUABgAHAAUACAAKAAkACgALAAkADAAOAA0ADgAPAA0AEAASABEAEgATABEAFAAWABUAFgAXABUA"
}
],
"accessors": [
{
"bufferView": 0,
"componentType": 5126,
"count": 24,
"max": [0.03125, 0, 0.3125],
"min": [-0.03125, -1, -0.3125],
"type": "VEC3"
},
{
"bufferView": 1,
"componentType": 5126,
"count": 24,
"max": [1, 1, 1],
"min": [-1, -1, -1],
"type": "VEC3"
},
{
"bufferView": 2,
"componentType": 5126,
"count": 24,
"max": [0.34375, 0.53125],
"min": [0, 0],
"type": "VEC2"
},
{
"bufferView": 3,
"componentType": 5123,
"count": 36,
"max": [23],
"min": [0],
"type": "SCALAR"
}
],
"materials": [
{
"pbrMetallicRoughness": {
"metallicFactor": 0,
"roughnessFactor": 1,
"baseColorTexture": { "index": 0 }
},
"alphaMode": "MASK",
"alphaCutoff": 0.05,
"doubleSided": true
}
],
"textures": [{ "sampler": 0, "source": 0, "name": "cape.png" }],
"samplers": [{ "magFilter": 9728, "minFilter": 9728, "wrapS": 33071, "wrapT": 33071 }],
"images": [
{
"mimeType": "image/png",
"uri": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAAAgCAYAAACinX6EAAAAAXNSR0IArs4c6QAABCRJREFUaEPtlktoE1EUhm/sIzFpYsBgHyn2oRZt0C5ESxWFYutKNyKIWBBR6kpBd3Ur2J0FRdCiuKmIIG4qLnzgQqTUXSlVqdoXTU0lljRpYtOHI/8dznBnOm1mphMwJWeTzJ0795zznf/cex2MMXam2S/ht38siJ8V1lgd5mOBARerc3rZnmK37rwvCyk2nE6wezMRh+4EncH9B1ql+fkU64rPsaBz5bqh4T7Daxn1Kc5zVNeEePJIci0Aq71bzenY6JChwAnA0OBHQ/OtJLnWNwqAy61R1tO3k891ueRKoDKwtqbv7MGbgCnfRgHQoqG9hyX4hc/yiho+/HNqVPFtdj2jwTpQgd/RKT7/0ZUku/o4qAJw50KYXbzr4e+3BioYzc3kwGzAUCLWFw2+UBiCb3bNTDHiPQeAP3CGNmg/6VcSBpDu3hhvDQpOCwABwrQKsRIsqYAChy/EQAXwlPiZ3a3igNPkXIyTjr8o5L7PPdzGf59c+sV/faeWeIIIAHPJ8M3kc7l1K09LKghWAIgqoPZ7djPFTlxb4D6yAgBOQfntrUVWVeRk44tplXJorOVGkVIJTBCTpw9ECFYBIEkyMfmsAXh3u1qi5MlxbbGX/x1ZSCjBAAwgfPr6h49R5UVavk0FXC2wju5p07s6iqEF0PtqSlEf+bKzDRwdgaDU7AkoySJ5Oo/D6ZRq/H0yyuJ/lxkSheG/FgCNm7kL0BoEAG32sqtY1YIHd2/mGzTMVgD3y2slqjgW9xY6ma9AThAARIMiBtOpjADwTWc0bEkB+FZsSTxTW0KBsGPXx0yvrUpEeHAAQIM7wBJLcu8DwHaP/H8i6VSND6SiSjBlRW4WWUypVIBbIgzjVgB0tpdKqLReS0KVPTMTfH0ra2cEgAmAAEd+l1z52LybqwBQYAQAyZPh6gtDW4jjuC4fHx8wXSm0JDbeI95SRYHiFZlUaWVtPQiKAugl5E8ASAUYiy8vc0C474uGasPE5PHc4g0wK/f4obom6UNimol7kTZwQLANwOuqBokqDEeQf4lfvvnNxZJcBTBAGZplWQcQ3tcgwY9oWlXinRW4ugoAgNAWWe6ocn1QvgyRTUb4RZFVljlY/3hSBYCqb6cCZo8ekuATVRZPI/gQW8FWAI1VHganVgDQUajdA6y2gAgAcZFBjTBSpG0AcAqc3VVmCMDTbxGWZvIRCaMNkJ7pFMCzVQD4liCQ8kRFUlvaBkCvL+wYw2ZmNUgCgLajFhRPJlv3ADuS1VtjPQCk823S574ffN/x1dQqy8dHR5SN2Spcbaymz2mjwNYDAD4IQn3TDpVLQIAqNjwAANRrAdoI/3sAOF7Xe1khCNQGVH1bL0JGJb1R52VtD8gVYHkAuVKpbMWZV0C2yObKunkF5EqlshVnXgHZIpsr6/4DlbxcPydnT74AAAAASUVORK5CYII="
}
],
"meshes": [
{
"primitives": [
{
"mode": 4,
"attributes": { "POSITION": 0, "NORMAL": 1, "TEXCOORD_0": 2 },
"indices": 3,
"material": 0
}
]
}
]
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Some files were not shown because too many files have changed in this diff Show More