Compare commits
16 Commits
app-update
...
coolbot/mo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54b79148aa | ||
|
|
b5589adb8a | ||
|
|
68fafa8e63 | ||
|
|
473fe52865 | ||
|
|
1032b521bf | ||
|
|
61d535707d | ||
|
|
da41659666 | ||
|
|
8db23d08ea | ||
|
|
95393eca1f | ||
|
|
6db1d66591 | ||
|
|
8052fda840 | ||
|
|
15892a88d3 | ||
|
|
dd06bb0a50 | ||
|
|
32793c50e1 | ||
|
|
0e0ca1971a | ||
|
|
bb9af18eed |
13
.github/workflows/daedalus-docker.yml
vendored
13
.github/workflows/daedalus-docker.yml
vendored
@@ -22,23 +22,26 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Fetch docker metadata
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@v3
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/modrinth/daedalus
|
||||
- name: Login to GitHub Images
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: ./apps/daedalus_client/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=ghcr.io/modrinth/daedalus:main
|
||||
cache-to: type=inline
|
||||
|
||||
13
.github/workflows/labrinth-docker.yml
vendored
13
.github/workflows/labrinth-docker.yml
vendored
@@ -20,23 +20,26 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
uses: actions/checkout@v4
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Fetch docker metadata
|
||||
id: docker_meta
|
||||
uses: docker/metadata-action@v3
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ghcr.io/modrinth/labrinth
|
||||
- name: Login to GitHub Images
|
||||
uses: docker/login-action@v1
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Build and push
|
||||
id: docker_build
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
file: ./apps/labrinth/Dockerfile
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.docker_meta.outputs.tags }}
|
||||
labels: ${{ steps.docker_meta.outputs.labels }}
|
||||
cache-from: type=registry,ref=ghcr.io/modrinth/labrinth:main
|
||||
cache-to: type=inline
|
||||
|
||||
2
.github/workflows/turbo-ci.yml
vendored
2
.github/workflows/turbo-ci.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
||||
# cargo-binstall does not have pre-built binaries for sqlx-cli, so we fall
|
||||
# back to a cached cargo install
|
||||
- name: 🧰 Setup cargo-sqlx
|
||||
uses: AlexTMjugador/cache-cargo-install-action@feat/features-support
|
||||
uses: taiki-e/cache-cargo-install-action@v2
|
||||
with:
|
||||
tool: sqlx-cli
|
||||
locked: false
|
||||
|
||||
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -8985,6 +8985,8 @@ dependencies = [
|
||||
"dashmap",
|
||||
"either",
|
||||
"enumset",
|
||||
"hyper 1.6.0",
|
||||
"hyper-util",
|
||||
"native-dialog",
|
||||
"paste",
|
||||
"serde",
|
||||
|
||||
@@ -67,6 +67,7 @@ heck = "0.5.0"
|
||||
hex = "0.4.3"
|
||||
hickory-resolver = "0.25.2"
|
||||
hmac = "0.12.1"
|
||||
hyper = "1.6.0"
|
||||
hyper-rustls = { version = "0.27.7", default-features = false, features = [
|
||||
"http1",
|
||||
"native-tokio",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"tsc:check": "vue-tsc --noEmit",
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -1,14 +1,5 @@
|
||||
<script setup>
|
||||
import {
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
ref,
|
||||
watch,
|
||||
useTemplateRef,
|
||||
provide,
|
||||
nextTick,
|
||||
} from 'vue'
|
||||
import { computed, onMounted, onUnmounted, ref, watch, provide } from 'vue'
|
||||
import { RouterView, useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
ArrowBigUpDashIcon,
|
||||
@@ -42,7 +33,7 @@ import { useLoading, useTheming } from '@/store/state'
|
||||
import ModrinthAppLogo from '@/assets/modrinth_app.svg?component'
|
||||
import AccountsCard from '@/components/ui/AccountsCard.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 RunningAppBar from '@/components/ui/RunningAppBar.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 { command_listener, warning_listener } from '@/helpers/events.js'
|
||||
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 { getCurrentWindow } from '@tauri-apps/api/window'
|
||||
import { getVersion } from '@tauri-apps/api/app'
|
||||
@@ -68,20 +59,19 @@ import { get_opening_command, initialize_state } from '@/helpers/state'
|
||||
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
|
||||
import { renderString } from '@modrinth/utils'
|
||||
import { useFetch } from '@/helpers/fetch.js'
|
||||
import { check } from '@tauri-apps/plugin-updater'
|
||||
import NavButton from '@/components/ui/NavButton.vue'
|
||||
import { get as getCreds, login, logout } from '@/helpers/mr_auth.js'
|
||||
import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.js'
|
||||
import { get_user } from '@/helpers/cache.js'
|
||||
import AppSettingsModal from '@/components/ui/modal/AppSettingsModal.vue'
|
||||
import AuthGrantFlowWaitModal from '@/components/ui/modal/AuthGrantFlowWaitModal.vue'
|
||||
import PromotionWrapper from '@/components/ui/PromotionWrapper.vue'
|
||||
import { hide_ads_window, init_ads_window } from '@/helpers/ads.js'
|
||||
import FriendsList from '@/components/ui/friends/FriendsList.vue'
|
||||
import { openUrl } from '@tauri-apps/plugin-opener'
|
||||
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 { generateSkinPreviews } from './helpers/rendering/batch-skin-renderer'
|
||||
import { defineMessages, useVIntl } from '@vintl/vintl'
|
||||
import { createTooltip, destroyTooltip } from 'floating-vue'
|
||||
|
||||
const themeStore = useTheming()
|
||||
|
||||
@@ -120,18 +110,6 @@ onUnmounted(() => {
|
||||
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() {
|
||||
stateInitialized.value = true
|
||||
const {
|
||||
@@ -145,8 +123,7 @@ async function setupApp() {
|
||||
toggle_sidebar,
|
||||
developer_mode,
|
||||
feature_flags,
|
||||
pending_update_toast_for_version,
|
||||
} = await getSettings()
|
||||
} = await get()
|
||||
|
||||
if (default_page === 'Library') {
|
||||
await router.push('/library')
|
||||
@@ -233,6 +210,7 @@ async function setupApp() {
|
||||
})
|
||||
|
||||
get_opening_command().then(handleCommand)
|
||||
checkUpdates()
|
||||
fetchCredentials()
|
||||
|
||||
try {
|
||||
@@ -242,22 +220,6 @@ async function setupApp() {
|
||||
} catch (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)
|
||||
@@ -302,6 +264,8 @@ const incompatibilityWarningModal = ref()
|
||||
|
||||
const credentials = ref()
|
||||
|
||||
const modrinthLoginFlowWaitModal = ref()
|
||||
|
||||
async function fetchCredentials() {
|
||||
const creds = await getCreds().catch(handleError)
|
||||
if (creds && creds.user_id) {
|
||||
@@ -311,8 +275,24 @@ async function fetchCredentials() {
|
||||
}
|
||||
|
||||
async function signIn() {
|
||||
await login().catch(handleError)
|
||||
await fetchCredentials()
|
||||
modrinthLoginFlowWaitModal.value.show()
|
||||
|
||||
try {
|
||||
await login()
|
||||
await fetchCredentials()
|
||||
} catch (error) {
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
typeof error['message'] === 'string' &&
|
||||
error.message.includes('Login canceled')
|
||||
) {
|
||||
// Not really an error due to being a result of user interaction, show nothing
|
||||
} else {
|
||||
handleError(error)
|
||||
}
|
||||
} finally {
|
||||
modrinthLoginFlowWaitModal.value.hide()
|
||||
}
|
||||
}
|
||||
|
||||
async function logOut() {
|
||||
@@ -385,95 +365,19 @@ async function handleCommand(e) {
|
||||
}
|
||||
}
|
||||
|
||||
const availableUpdate = ref(null)
|
||||
const updateSkipped = ref(false)
|
||||
const enqueuedUpdate = ref(null)
|
||||
const updateModal = useTemplateRef('updateModal')
|
||||
const updateAvailable = ref(false)
|
||||
async function checkUpdates() {
|
||||
if (!(await areUpdatesEnabled())) {
|
||||
console.log('Skipping update check as updates are disabled in this build')
|
||||
return
|
||||
}
|
||||
const update = await check()
|
||||
updateAvailable.value = !!update
|
||||
|
||||
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(
|
||||
() => {
|
||||
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) {
|
||||
let target = e.target
|
||||
while (target != null) {
|
||||
@@ -514,17 +418,12 @@ function handleAuxClick(e) {
|
||||
<SplashScreen v-if="!stateFailed" ref="splashScreen" data-tauri-drag-region />
|
||||
<div id="teleports"></div>
|
||||
<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>
|
||||
<AppSettingsModal ref="settingsModal" />
|
||||
</Suspense>
|
||||
<Suspense>
|
||||
<AuthGrantFlowWaitModal ref="modrinthLoginFlowWaitModal" @flow-cancel="cancelLogin" />
|
||||
</Suspense>
|
||||
<Suspense>
|
||||
<InstanceCreationModal ref="installationModal" />
|
||||
</Suspense>
|
||||
@@ -572,18 +471,8 @@ function handleAuxClick(e) {
|
||||
<PlusIcon />
|
||||
</NavButton>
|
||||
<div class="flex flex-grow"></div>
|
||||
<NavButton
|
||||
v-if="!!availableUpdate"
|
||||
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 v-if="updateAvailable" v-tooltip.right="'Install update'" :to="() => restartApp()">
|
||||
<DownloadIcon />
|
||||
</NavButton>
|
||||
<NavButton v-tooltip.right="'Settings'" :to="() => $refs.settingsModal.show()">
|
||||
<SettingsIcon />
|
||||
|
||||
@@ -305,12 +305,16 @@ const [
|
||||
get_game_versions().then(shallowRef).catch(handleError),
|
||||
get_loaders()
|
||||
.then((value) =>
|
||||
value
|
||||
.filter((item) => item.supported_project_types.includes('modpack'))
|
||||
.map((item) => item.name.toLowerCase()),
|
||||
ref(
|
||||
value
|
||||
.filter((item) => item.supported_project_types.includes('modpack'))
|
||||
.map((item) => item.name.toLowerCase()),
|
||||
),
|
||||
)
|
||||
.then(ref)
|
||||
.catch(handleError),
|
||||
.catch((err) => {
|
||||
handleError(err)
|
||||
return ref([])
|
||||
}),
|
||||
])
|
||||
loaders.value.unshift('vanilla')
|
||||
|
||||
|
||||
@@ -1,12 +1,6 @@
|
||||
<template>
|
||||
<div class="progress-bar">
|
||||
<div
|
||||
class="progress-bar__fill"
|
||||
:style="{
|
||||
width: `${progress}%`,
|
||||
'background-color': error ? 'var(--color-red)' : 'var(--color-brand)',
|
||||
}"
|
||||
></div>
|
||||
<div class="progress-bar__fill" :style="{ width: `${progress}%` }"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -19,10 +13,6 @@ defineProps({
|
||||
return value >= 0 && value <= 100
|
||||
},
|
||||
},
|
||||
error: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -37,6 +27,7 @@ defineProps({
|
||||
|
||||
.progress-bar__fill {
|
||||
height: 100%;
|
||||
background-color: var(--color-brand);
|
||||
transition: width 0.3s;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,42 @@
|
||||
<script setup lang="ts">
|
||||
import { LogInIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { ref } from 'vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
|
||||
defineProps({
|
||||
onFlowCancel: {
|
||||
type: Function,
|
||||
default() {
|
||||
return async () => {}
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref()
|
||||
|
||||
function show() {
|
||||
modal.value.show()
|
||||
}
|
||||
|
||||
function hide() {
|
||||
modal.value.hide()
|
||||
}
|
||||
|
||||
defineExpose({ show, hide })
|
||||
</script>
|
||||
<template>
|
||||
<ModalWrapper ref="modal" @hide="onFlowCancel">
|
||||
<template #title>
|
||||
<span class="items-center gap-2 text-lg font-extrabold text-contrast">
|
||||
<LogInIcon /> Sign in
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<div class="flex justify-center gap-2">
|
||||
<SpinnerIcon class="w-12 h-12 animate-spin" />
|
||||
</div>
|
||||
<p class="text-sm text-secondary">
|
||||
Please sign in at the browser window that just opened to continue.
|
||||
</p>
|
||||
</ModalWrapper>
|
||||
</template>
|
||||
@@ -11,10 +11,6 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: null,
|
||||
},
|
||||
hideHeader: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
closable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
@@ -52,14 +48,7 @@ function onModalHide() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal
|
||||
ref="modal"
|
||||
:header="header"
|
||||
:noblur="!themeStore.advancedRendering"
|
||||
:closable="closable"
|
||||
:hide-header="hideHeader"
|
||||
@hide="onModalHide"
|
||||
>
|
||||
<Modal ref="modal" :header="header" :noblur="!themeStore.advancedRendering" @hide="onModalHide">
|
||||
<template #title>
|
||||
<slot name="title" />
|
||||
</template>
|
||||
|
||||
@@ -16,3 +16,7 @@ export async function logout() {
|
||||
export async function get() {
|
||||
return await invoke('plugin:mr-auth|get')
|
||||
}
|
||||
|
||||
export async function cancelLogin() {
|
||||
return await invoke('plugin:mr-auth|cancel_modrinth_login')
|
||||
}
|
||||
|
||||
@@ -62,9 +62,6 @@ export type AppSettings = {
|
||||
|
||||
developer_mode: boolean
|
||||
feature_flags: Record<FeatureFlag, boolean>
|
||||
|
||||
skipped_update: string | null
|
||||
pending_update_toast_for_version: string | null
|
||||
}
|
||||
|
||||
// Get full settings object
|
||||
|
||||
@@ -5,22 +5,6 @@ export async function isDev() {
|
||||
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'
|
||||
export async function getOS() {
|
||||
return await invoke('plugin:utils|get_os')
|
||||
@@ -53,6 +37,13 @@ export async function restartApp() {
|
||||
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) => {
|
||||
switch (releaseType) {
|
||||
case 'release':
|
||||
|
||||
@@ -20,45 +20,6 @@
|
||||
"app.settings.tabs.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": {
|
||||
"message": "Add and play"
|
||||
},
|
||||
|
||||
@@ -15,7 +15,7 @@ pub async fn authenticate_run() -> theseus::Result<Credentials> {
|
||||
println!("A browser window will now open, follow the login flow there.");
|
||||
let login = minecraft_auth::begin_login().await?;
|
||||
|
||||
println!("Open URL {} in a browser", login.redirect_uri.as_str());
|
||||
println!("Open URL {} in a browser", login.auth_request_uri.as_str());
|
||||
|
||||
println!("Please enter URL code: ");
|
||||
let mut input = String::new();
|
||||
|
||||
@@ -31,6 +31,8 @@ thiserror.workspace = true
|
||||
daedalus.workspace = true
|
||||
chrono.workspace = true
|
||||
either.workspace = true
|
||||
hyper = { workspace = true, features = ["server"] }
|
||||
hyper-util.workspace = true
|
||||
|
||||
url.workspace = true
|
||||
urlencoding.workspace = true
|
||||
|
||||
@@ -120,7 +120,12 @@ fn main() {
|
||||
.plugin(
|
||||
"mr-auth",
|
||||
InlinedPlugin::new()
|
||||
.commands(&["modrinth_login", "logout", "get"])
|
||||
.commands(&[
|
||||
"modrinth_login",
|
||||
"logout",
|
||||
"get",
|
||||
"cancel_modrinth_login",
|
||||
])
|
||||
.default_permission(
|
||||
DefaultPermissionRule::AllowAllCommands,
|
||||
),
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
"build": "tauri build",
|
||||
"dev": "tauri dev",
|
||||
"test": "cargo nextest run --all-targets --no-fail-fast",
|
||||
"lint": "cargo fmt --check && cargo clippy --all-targets && cargo clippy --all-targets --features updater",
|
||||
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo clippy --all-targets --features updater --fix --allow-dirty && cargo fmt"
|
||||
"lint": "cargo fmt --check && cargo clippy --all-targets",
|
||||
"fix": "cargo clippy --all-targets --fix --allow-dirty && cargo fmt"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "2.5.0"
|
||||
|
||||
@@ -33,7 +33,7 @@ pub async fn login<R: Runtime>(
|
||||
let window = tauri::WebviewWindowBuilder::new(
|
||||
&app,
|
||||
"signin",
|
||||
tauri::WebviewUrl::External(flow.redirect_uri.parse().map_err(
|
||||
tauri::WebviewUrl::External(flow.auth_request_uri.parse().map_err(
|
||||
|_| {
|
||||
theseus::ErrorKind::OtherError(
|
||||
"Error parsing auth redirect URL".to_string(),
|
||||
@@ -77,6 +77,7 @@ pub async fn login<R: Runtime>(
|
||||
window.close()?;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_user(user: uuid::Uuid) -> Result<()> {
|
||||
Ok(minecraft_auth::remove_user(user).await?)
|
||||
|
||||
@@ -22,6 +22,8 @@ pub mod cache;
|
||||
pub mod friends;
|
||||
pub mod worlds;
|
||||
|
||||
mod oauth_utils;
|
||||
|
||||
pub type Result<T> = std::result::Result<T, TheseusSerializableError>;
|
||||
|
||||
// // Main returnable Theseus GUI error
|
||||
@@ -45,12 +47,8 @@ pub enum TheseusSerializableError {
|
||||
Tauri(#[from] tauri::Error),
|
||||
|
||||
#[cfg(feature = "updater")]
|
||||
#[error("Updater error: {0}")]
|
||||
Updater(#[from] tauri_plugin_updater::Error),
|
||||
|
||||
#[cfg(feature = "updater")]
|
||||
#[error("HTTP error: {0}")]
|
||||
Http(#[from] tauri_plugin_http::reqwest::Error),
|
||||
#[error("Tauri updater error: {0}")]
|
||||
TauriUpdater(#[from] tauri_plugin_updater::Error),
|
||||
}
|
||||
|
||||
// Generic implementation of From<T> for ErrorTypeA
|
||||
@@ -108,6 +106,5 @@ impl_serialize! {
|
||||
impl_serialize! {
|
||||
IO,
|
||||
Tauri,
|
||||
Updater,
|
||||
Http,
|
||||
TauriUpdater,
|
||||
}
|
||||
|
||||
@@ -1,79 +1,70 @@
|
||||
use crate::api::Result;
|
||||
use chrono::{Duration, Utc};
|
||||
use crate::api::TheseusSerializableError;
|
||||
use crate::api::oauth_utils;
|
||||
use tauri::Manager;
|
||||
use tauri::Runtime;
|
||||
use tauri::plugin::TauriPlugin;
|
||||
use tauri::{Manager, Runtime, UserAttentionType};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use theseus::prelude::*;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
pub fn init<R: tauri::Runtime>() -> TauriPlugin<R> {
|
||||
tauri::plugin::Builder::new("mr-auth")
|
||||
.invoke_handler(tauri::generate_handler![modrinth_login, logout, get,])
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
modrinth_login,
|
||||
logout,
|
||||
get,
|
||||
cancel_modrinth_login,
|
||||
])
|
||||
.build()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn modrinth_login<R: Runtime>(
|
||||
app: tauri::AppHandle<R>,
|
||||
) -> Result<Option<ModrinthCredentials>> {
|
||||
let redirect_uri = mr_auth::authenticate_begin_flow();
|
||||
) -> Result<ModrinthCredentials> {
|
||||
let (auth_code_recv_socket_tx, auth_code_recv_socket) = oneshot::channel();
|
||||
let auth_code = tokio::spawn(oauth_utils::auth_code_reply::listen(
|
||||
auth_code_recv_socket_tx,
|
||||
));
|
||||
|
||||
let start = Utc::now();
|
||||
let auth_code_recv_socket = auth_code_recv_socket.await.unwrap()?;
|
||||
|
||||
if let Some(window) = app.get_webview_window("modrinth-signin") {
|
||||
window.close()?;
|
||||
}
|
||||
let auth_request_uri = format!(
|
||||
"{}?launcher=true&ipver={}&port={}",
|
||||
mr_auth::authenticate_begin_flow(),
|
||||
if auth_code_recv_socket.is_ipv4() {
|
||||
"4"
|
||||
} else {
|
||||
"6"
|
||||
},
|
||||
auth_code_recv_socket.port()
|
||||
);
|
||||
|
||||
let window = tauri::WebviewWindowBuilder::new(
|
||||
&app,
|
||||
"modrinth-signin",
|
||||
tauri::WebviewUrl::External(redirect_uri.parse().map_err(|_| {
|
||||
theseus::ErrorKind::OtherError(
|
||||
"Error parsing auth redirect URL".to_string(),
|
||||
app.opener()
|
||||
.open_url(auth_request_uri, None::<&str>)
|
||||
.map_err(|e| {
|
||||
TheseusSerializableError::Theseus(
|
||||
theseus::ErrorKind::OtherError(format!(
|
||||
"Failed to open auth request URI: {e}"
|
||||
))
|
||||
.into(),
|
||||
)
|
||||
.as_error()
|
||||
})?),
|
||||
)
|
||||
.min_inner_size(420.0, 632.0)
|
||||
.inner_size(420.0, 632.0)
|
||||
.max_inner_size(420.0, 632.0)
|
||||
.zoom_hotkeys_enabled(false)
|
||||
.title("Sign into Modrinth")
|
||||
.always_on_top(true)
|
||||
.center()
|
||||
.build()?;
|
||||
})?;
|
||||
|
||||
window.request_user_attention(Some(UserAttentionType::Critical))?;
|
||||
let Some(auth_code) = auth_code.await.unwrap()? else {
|
||||
return Err(TheseusSerializableError::Theseus(
|
||||
theseus::ErrorKind::OtherError("Login canceled".into()).into(),
|
||||
));
|
||||
};
|
||||
|
||||
while (Utc::now() - start) < Duration::minutes(10) {
|
||||
if window.title().is_err() {
|
||||
// user closed window, cancelling flow
|
||||
return Ok(None);
|
||||
}
|
||||
let credentials = mr_auth::authenticate_finish_flow(&auth_code).await?;
|
||||
|
||||
if window
|
||||
.url()?
|
||||
.as_str()
|
||||
.starts_with("https://launcher-files.modrinth.com")
|
||||
{
|
||||
let url = window.url()?;
|
||||
|
||||
let code = url.query_pairs().find(|(key, _)| key == "code");
|
||||
|
||||
window.close()?;
|
||||
|
||||
return if let Some((_, code)) = code {
|
||||
let val = mr_auth::authenticate_finish_flow(&code).await?;
|
||||
|
||||
Ok(Some(val))
|
||||
} else {
|
||||
Ok(None)
|
||||
};
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
if let Some(main_window) = app.get_window("main") {
|
||||
main_window.set_focus().ok();
|
||||
}
|
||||
|
||||
window.close()?;
|
||||
Ok(None)
|
||||
Ok(credentials)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -85,3 +76,8 @@ pub async fn logout() -> Result<()> {
|
||||
pub async fn get() -> Result<Option<ModrinthCredentials>> {
|
||||
Ok(theseus::mr_auth::get_credentials().await?)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn cancel_modrinth_login() {
|
||||
oauth_utils::auth_code_reply::stop_listeners();
|
||||
}
|
||||
|
||||
159
apps/app/src/api/oauth_utils/auth_code_reply.rs
Normal file
159
apps/app/src/api/oauth_utils/auth_code_reply.rs
Normal file
@@ -0,0 +1,159 @@
|
||||
//! A minimal OAuth 2.0 authorization code grant flow redirection/reply loopback URI HTTP
|
||||
//! server implementation, compliant with [RFC 6749]'s authorization code grant flow and
|
||||
//! [RFC 8252]'s best current practices for OAuth 2.0 in native apps.
|
||||
//!
|
||||
//! This server is needed for the step 4 of the OAuth authentication dance represented in
|
||||
//! figure 1 of [RFC 8252].
|
||||
//!
|
||||
//! Further reading: https://www.oauth.com/oauth2-servers/oauth-native-apps/redirect-urls-for-native-apps/
|
||||
//!
|
||||
//! [RFC 6749]: https://datatracker.ietf.org/doc/html/rfc6749
|
||||
//! [RFC 8252]: https://datatracker.ietf.org/doc/html/rfc8252
|
||||
|
||||
use std::{
|
||||
net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr},
|
||||
sync::{LazyLock, Mutex},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use hyper::body::Incoming;
|
||||
use hyper_util::rt::{TokioIo, TokioTimer};
|
||||
use theseus::ErrorKind;
|
||||
use tokio::{
|
||||
net::TcpListener,
|
||||
sync::{broadcast, oneshot},
|
||||
};
|
||||
|
||||
static SERVER_SHUTDOWN: LazyLock<broadcast::Sender<()>> =
|
||||
LazyLock::new(|| broadcast::channel(1024).0);
|
||||
|
||||
/// Starts a temporary HTTP server to receive OAuth 2.0 authorization code grant flow redirects
|
||||
/// on a loopback interface with an ephemeral port. The caller can know the bound socket address
|
||||
/// by listening on the counterpart channel for `listen_socket_tx`.
|
||||
///
|
||||
/// If the server is stopped before receiving an authorization code, `Ok(None)` is returned.
|
||||
pub async fn listen(
|
||||
listen_socket_tx: oneshot::Sender<Result<SocketAddr, theseus::Error>>,
|
||||
) -> Result<Option<String>, theseus::Error> {
|
||||
// IPv4 is tried first for the best compatibility and performance with most systems.
|
||||
// IPv6 is also tried in case IPv4 is not available. Resolving "localhost" is avoided
|
||||
// to prevent failures deriving from improper name resolution setup. Any available
|
||||
// ephemeral port is used to prevent conflicts with other services. This is all as per
|
||||
// RFC 8252's recommendations
|
||||
const ANY_LOOPBACK_SOCKET: &[SocketAddr] = &[
|
||||
SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
|
||||
SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0),
|
||||
];
|
||||
|
||||
let listener = match TcpListener::bind(ANY_LOOPBACK_SOCKET).await {
|
||||
Ok(listener) => {
|
||||
listen_socket_tx
|
||||
.send(listener.local_addr().map_err(|e| {
|
||||
ErrorKind::OtherError(format!(
|
||||
"Failed to get auth code reply socket address: {e}"
|
||||
))
|
||||
.into()
|
||||
}))
|
||||
.ok();
|
||||
|
||||
listener
|
||||
}
|
||||
Err(e) => {
|
||||
let error_msg =
|
||||
format!("Failed to bind auth code reply socket: {e}");
|
||||
|
||||
listen_socket_tx
|
||||
.send(Err(ErrorKind::OtherError(error_msg.clone()).into()))
|
||||
.ok();
|
||||
|
||||
return Err(ErrorKind::OtherError(error_msg).into());
|
||||
}
|
||||
};
|
||||
|
||||
let mut auth_code = Mutex::new(None);
|
||||
let mut shutdown_notification = SERVER_SHUTDOWN.subscribe();
|
||||
|
||||
while auth_code.get_mut().unwrap().is_none() {
|
||||
let client_socket = tokio::select! {
|
||||
biased;
|
||||
_ = shutdown_notification.recv() => {
|
||||
break;
|
||||
}
|
||||
conn_accept_result = listener.accept() => {
|
||||
match conn_accept_result {
|
||||
Ok((socket, _)) => socket,
|
||||
Err(e) => {
|
||||
tracing::warn!("Failed to accept auth code reply: {e}");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(e) = hyper::server::conn::http1::Builder::new()
|
||||
.keep_alive(false)
|
||||
.header_read_timeout(Duration::from_secs(5))
|
||||
.timer(TokioTimer::new())
|
||||
.auto_date_header(false)
|
||||
.serve_connection(
|
||||
TokioIo::new(client_socket),
|
||||
hyper::service::service_fn(|req| handle_reply(req, &auth_code)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!("Failed to handle auth code reply: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(auth_code.into_inner().unwrap())
|
||||
}
|
||||
|
||||
/// Stops any active OAuth 2.0 authorization code grant flow reply listening HTTP servers.
|
||||
pub fn stop_listeners() {
|
||||
SERVER_SHUTDOWN.send(()).ok();
|
||||
}
|
||||
|
||||
async fn handle_reply(
|
||||
req: hyper::Request<Incoming>,
|
||||
auth_code_out: &Mutex<Option<String>>,
|
||||
) -> Result<hyper::Response<String>, hyper::http::Error> {
|
||||
if req.method() != hyper::Method::GET {
|
||||
return hyper::Response::builder()
|
||||
.status(hyper::StatusCode::METHOD_NOT_ALLOWED)
|
||||
.header("Allow", "GET")
|
||||
.body("".into());
|
||||
}
|
||||
|
||||
// The authorization code is guaranteed to be sent as a "code" query parameter
|
||||
// in the request URI query string as per RFC 6749 § 4.1.2
|
||||
let auth_code = req.uri().query().and_then(|query_string| {
|
||||
query_string
|
||||
.split('&')
|
||||
.filter_map(|query_pair| query_pair.split_once('='))
|
||||
.find_map(|(key, value)| (key == "code").then_some(value))
|
||||
});
|
||||
|
||||
let response = if let Some(auth_code) = auth_code {
|
||||
*auth_code_out.lock().unwrap() = Some(auth_code.to_string());
|
||||
|
||||
hyper::Response::builder()
|
||||
.status(hyper::StatusCode::OK)
|
||||
.header("Content-Type", "text/html;charset=utf-8")
|
||||
.body(
|
||||
include_str!("auth_code_reply/page.html")
|
||||
.replace("{{title}}", "Success")
|
||||
.replace("{{message}}", "You have successfully signed in! You can close this page now."),
|
||||
)
|
||||
} else {
|
||||
hyper::Response::builder()
|
||||
.status(hyper::StatusCode::BAD_REQUEST)
|
||||
.header("Content-Type", "text/html;charset=utf-8")
|
||||
.body(
|
||||
include_str!("auth_code_reply/page.html")
|
||||
.replace("{{title}}", "Error")
|
||||
.replace("{{message}}", "Authorization code not found. Please try signing in again."),
|
||||
)
|
||||
}?;
|
||||
|
||||
Ok(response)
|
||||
}
|
||||
1
apps/app/src/api/oauth_utils/auth_code_reply/page.html
Normal file
1
apps/app/src/api/oauth_utils/auth_code_reply/page.html
Normal file
File diff suppressed because one or more lines are too long
3
apps/app/src/api/oauth_utils/mod.rs
Normal file
3
apps/app/src/api/oauth_utils/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
||||
//! Assorted utilities for OAuth 2.0 authorization flows.
|
||||
|
||||
pub mod auth_code_reply;
|
||||
@@ -14,11 +14,6 @@ mod error;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos;
|
||||
|
||||
#[cfg(feature = "updater")]
|
||||
mod updater_impl;
|
||||
#[cfg(not(feature = "updater"))]
|
||||
mod updater_impl_noop;
|
||||
|
||||
// Should be called in launcher initialization
|
||||
#[tracing::instrument(skip_all)]
|
||||
#[tauri::command]
|
||||
@@ -26,9 +21,75 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
|
||||
tracing::info!("Initializing app event state...");
|
||||
theseus::EventState::init(app.clone()).await?;
|
||||
|
||||
tracing::info!("Initializing app state...");
|
||||
State::init().await?;
|
||||
#[cfg(feature = "updater")]
|
||||
'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?;
|
||||
app.asset_protocol_scope()
|
||||
.allow_directory(state.directories.caches_dir(), true)?;
|
||||
@@ -64,17 +125,6 @@ fn is_dev() -> bool {
|
||||
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
|
||||
#[tauri::command]
|
||||
async fn toggle_decorations(b: bool, window: tauri::Window) -> api::Result<()> {
|
||||
@@ -116,17 +166,7 @@ fn main() {
|
||||
|
||||
#[cfg(feature = "updater")]
|
||||
{
|
||||
use tauri_plugin_http::reqwest::header::{HeaderValue, USER_AGENT};
|
||||
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.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
}
|
||||
|
||||
builder = builder
|
||||
@@ -221,14 +261,9 @@ fn main() {
|
||||
.plugin(api::ads::init())
|
||||
.plugin(api::friends::init())
|
||||
.plugin(api::worlds::init())
|
||||
.manage(PendingUpdateData::default())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
initialize_state,
|
||||
is_dev,
|
||||
are_updates_enabled,
|
||||
get_update_size,
|
||||
enqueue_update_for_installation,
|
||||
remove_enqueued_update,
|
||||
toggle_decorations,
|
||||
show_window,
|
||||
restart_app,
|
||||
@@ -240,41 +275,8 @@ fn main() {
|
||||
match app {
|
||||
Ok(app) => {
|
||||
app.run(|app, event| {
|
||||
#[cfg(not(any(feature = "updater", target_os = "macos")))]
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
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")]
|
||||
if let tauri::RunEvent::Opened { urls } = event {
|
||||
tracing::info!("Handling webview open {urls:?}");
|
||||
@@ -302,8 +304,6 @@ fn main() {
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Error while running tauri application: {:?}", e);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// tauri doesn't expose runtime errors, so matching a string representation seems like the only solution
|
||||
@@ -332,6 +332,7 @@ fn main() {
|
||||
.show()
|
||||
.unwrap();
|
||||
|
||||
tracing::error!("Error while running tauri application: {:?}", e);
|
||||
panic!("{1}: {:?}", e, "error while running tauri application")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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() {}
|
||||
@@ -63,6 +63,7 @@
|
||||
"height": 800,
|
||||
"resizable": true,
|
||||
"title": "Modrinth App",
|
||||
"label": "main",
|
||||
"width": 1280,
|
||||
"minHeight": 700,
|
||||
"minWidth": 1100,
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM rust:1.88.0 AS build
|
||||
|
||||
WORKDIR /usr/src/daedalus
|
||||
COPY . .
|
||||
RUN cargo build --release --package daedalus_client
|
||||
RUN --mount=type=cache,target=/usr/src/daedalus/target \
|
||||
--mount=type=cache,target=/usr/local/cargo/git/db \
|
||||
--mount=type=cache,target=/usr/local/cargo/registry \
|
||||
cargo build --release --package daedalus_client
|
||||
|
||||
FROM build AS artifacts
|
||||
|
||||
RUN --mount=type=cache,target=/usr/src/daedalus/target \
|
||||
mkdir /daedalus \
|
||||
&& cp /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
@@ -11,7 +21,7 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates openssl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /usr/src/daedalus/target/release/daedalus_client /daedalus/daedalus_client
|
||||
WORKDIR /daedalus_client
|
||||
COPY --from=artifacts /daedalus /daedalus
|
||||
|
||||
CMD /daedalus/daedalus_client
|
||||
WORKDIR /daedalus_client
|
||||
CMD ["/daedalus/daedalus_client"]
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"postinstall": "nuxi prepare",
|
||||
"lint": "eslint . && prettier --check .",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<div v-if="!modPackData">Loading data...</div>
|
||||
|
||||
<div v-else-if="modPackData.length === 0">
|
||||
<p>All permissions obtained. You may skip this step!</p>
|
||||
<p>All permissions already obtained.</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!modPackData[currentIndex]">
|
||||
@@ -157,7 +157,7 @@ import type {
|
||||
} from "@modrinth/utils";
|
||||
import { ButtonStyled } from "@modrinth/ui";
|
||||
import { ref, computed, watch, onMounted } from "vue";
|
||||
import { useLocalStorage } from "@vueuse/core";
|
||||
import { useLocalStorage, useSessionStorage } from "@vueuse/core";
|
||||
|
||||
const props = defineProps<{
|
||||
projectId: string;
|
||||
@@ -182,7 +182,26 @@ const persistedModPackData = useLocalStorage<ModerationModpackItem[] | null>(
|
||||
|
||||
const persistedIndex = useLocalStorage<number>(`modpack-permissions-index-${props.projectId}`, 0);
|
||||
|
||||
const modPackData = ref<ModerationModpackItem[] | null>(null);
|
||||
const modPackData = useSessionStorage<ModerationModpackItem[] | null>(
|
||||
`modpack-permissions-data-${props.projectId}`,
|
||||
null,
|
||||
{
|
||||
serializer: {
|
||||
read: (v: any) => (v ? JSON.parse(v) : null),
|
||||
write: (v: any) => JSON.stringify(v),
|
||||
},
|
||||
},
|
||||
);
|
||||
const permanentNoFiles = useSessionStorage<ModerationModpackItem[]>(
|
||||
`modpack-permissions-permanent-no-${props.projectId}`,
|
||||
[],
|
||||
{
|
||||
serializer: {
|
||||
read: (v: any) => (v ? JSON.parse(v) : []),
|
||||
write: (v: any) => JSON.stringify(v),
|
||||
},
|
||||
},
|
||||
);
|
||||
const currentIndex = ref(0);
|
||||
|
||||
const fileApprovalTypes: ModerationModpackPermissionApprovalType[] = [
|
||||
@@ -251,7 +270,45 @@ async function fetchModPackData(): Promise<void> {
|
||||
const data = (await useBaseFetch(`moderation/project/${props.projectId}`, {
|
||||
internal: true,
|
||||
})) as ModerationModpackResponse;
|
||||
|
||||
const permanentNoItems: ModerationModpackItem[] = Object.entries(data.identified || {})
|
||||
.filter(([_, file]) => file.status === "permanent-no")
|
||||
.map(
|
||||
([sha1, file]): ModerationModpackItem => ({
|
||||
sha1,
|
||||
file_name: file.file_name,
|
||||
type: "identified",
|
||||
status: file.status,
|
||||
approved: null,
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => a.file_name.localeCompare(b.file_name));
|
||||
|
||||
permanentNoFiles.value = permanentNoItems;
|
||||
|
||||
const sortedData: ModerationModpackItem[] = [
|
||||
...Object.entries(data.identified || {})
|
||||
.filter(
|
||||
([_, file]) =>
|
||||
file.status !== "yes" &&
|
||||
file.status !== "with-attribution-and-source" &&
|
||||
file.status !== "permanent-no",
|
||||
)
|
||||
.map(
|
||||
([sha1, file]): ModerationModpackItem => ({
|
||||
sha1,
|
||||
file_name: file.file_name,
|
||||
type: "identified",
|
||||
status: file.status,
|
||||
approved: null,
|
||||
...(file.status === "unidentified" && {
|
||||
proof: "",
|
||||
url: "",
|
||||
title: "",
|
||||
}),
|
||||
}),
|
||||
)
|
||||
.sort((a, b) => a.file_name.localeCompare(b.file_name)),
|
||||
...Object.entries(data.unknown_files || {})
|
||||
.map(
|
||||
([sha1, fileName]): ModerationUnknownModpackItem => ({
|
||||
@@ -310,6 +367,7 @@ async function fetchModPackData(): Promise<void> {
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch modpack data:", error);
|
||||
modPackData.value = [];
|
||||
permanentNoFiles.value = [];
|
||||
persistAll();
|
||||
}
|
||||
}
|
||||
@@ -321,6 +379,14 @@ function goToPrevious(): void {
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
modPackData,
|
||||
(newValue) => {
|
||||
persistedModPackData.value = newValue;
|
||||
},
|
||||
{ deep: true },
|
||||
);
|
||||
|
||||
function goToNext(): void {
|
||||
if (modPackData.value && currentIndex.value < modPackData.value.length) {
|
||||
currentIndex.value++;
|
||||
@@ -396,6 +462,17 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
modPackData,
|
||||
(newValue) => {
|
||||
if (newValue && newValue.length === 0) {
|
||||
emit("complete");
|
||||
clearPersistedData();
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(
|
||||
() => props.projectId,
|
||||
() => {
|
||||
@@ -406,6 +483,20 @@ watch(
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
function getModpackFiles(): {
|
||||
interactive: ModerationModpackItem[];
|
||||
permanentNo: ModerationModpackItem[];
|
||||
} {
|
||||
return {
|
||||
interactive: modPackData.value || [],
|
||||
permanentNo: permanentNoFiles.value,
|
||||
};
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
getModpackFiles,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -240,24 +240,6 @@
|
||||
</div>
|
||||
|
||||
<div v-else-if="generatedMessage" class="flex items-center gap-2">
|
||||
<OverflowMenu :options="stageOptions" class="bg-transparent p-0">
|
||||
<ButtonStyled circular>
|
||||
<button v-tooltip="`Stages`">
|
||||
<ListBulletedIcon />
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
|
||||
<template
|
||||
v-for="opt in stageOptions.filter(
|
||||
(opt) => 'id' in opt && 'text' in opt && 'icon' in opt,
|
||||
)"
|
||||
#[opt.id]
|
||||
:key="opt.id"
|
||||
>
|
||||
<component :is="opt.icon" v-if="opt.icon" class="mr-2" />
|
||||
{{ opt.text }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
<ButtonStyled>
|
||||
<button @click="goBackToStages">
|
||||
<LeftArrowIcon aria-hidden="true" />
|
||||
@@ -368,21 +350,26 @@ import {
|
||||
DropdownSelect,
|
||||
MarkdownEditor,
|
||||
} from "@modrinth/ui";
|
||||
import { type Project, renderHighlightedString, type ModerationJudgements } from "@modrinth/utils";
|
||||
import {
|
||||
type Project,
|
||||
renderHighlightedString,
|
||||
type ModerationJudgements,
|
||||
type ModerationModpackItem,
|
||||
} from "@modrinth/utils";
|
||||
import { computedAsync, useLocalStorage } from "@vueuse/core";
|
||||
import type {
|
||||
Action,
|
||||
MultiSelectChipsAction,
|
||||
DropdownAction,
|
||||
ButtonAction,
|
||||
ToggleAction,
|
||||
ConditionalButtonAction,
|
||||
Stage,
|
||||
import {
|
||||
type Action,
|
||||
type MultiSelectChipsAction,
|
||||
type DropdownAction,
|
||||
type ButtonAction,
|
||||
type ToggleAction,
|
||||
type ConditionalButtonAction,
|
||||
type Stage,
|
||||
finalPermissionMessages,
|
||||
} from "@modrinth/moderation";
|
||||
import * as prettier from "prettier";
|
||||
import ModpackPermissionsFlow from "./ModpackPermissionsFlow.vue";
|
||||
import KeybindsModal from "./ChecklistKeybindsModal.vue";
|
||||
import { finalPermissionMessages } from "@modrinth/moderation/data/modpack-permissions-stage";
|
||||
import prettier from "prettier";
|
||||
|
||||
const keybindsModal = ref<InstanceType<typeof KeybindsModal>>();
|
||||
|
||||
@@ -419,7 +406,6 @@ const done = ref(false);
|
||||
|
||||
function handleModpackPermissionsComplete() {
|
||||
modpackPermissionsComplete.value = true;
|
||||
nextStage();
|
||||
}
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -823,6 +809,31 @@ const isAnyVisibleInputs = computed(() => {
|
||||
});
|
||||
});
|
||||
|
||||
function getModpackFilesFromStorage(): {
|
||||
interactive: ModerationModpackItem[];
|
||||
permanentNo: ModerationModpackItem[];
|
||||
} {
|
||||
try {
|
||||
const sessionData = sessionStorage.getItem(`modpack-permissions-data-${props.project.id}`);
|
||||
const interactive = sessionData ? (JSON.parse(sessionData) as ModerationModpackItem[]) : [];
|
||||
|
||||
const permanentNoData = sessionStorage.getItem(
|
||||
`modpack-permissions-permanent-no-${props.project.id}`,
|
||||
);
|
||||
const permanentNo = permanentNoData
|
||||
? (JSON.parse(permanentNoData) as ModerationModpackItem[])
|
||||
: [];
|
||||
|
||||
return {
|
||||
interactive: interactive || [],
|
||||
permanentNo: permanentNo || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.warn("Failed to parse session storage modpack data:", error);
|
||||
return { interactive: [], permanentNo: [] };
|
||||
}
|
||||
}
|
||||
|
||||
async function assembleFullMessage() {
|
||||
const messageParts: MessagePart[] = [];
|
||||
|
||||
@@ -1092,13 +1103,14 @@ async function generateMessage() {
|
||||
const baseMessage = await assembleFullMessage();
|
||||
let fullMessage = baseMessage;
|
||||
|
||||
if (
|
||||
props.project.project_type === "modpack" &&
|
||||
Object.keys(modpackJudgements.value).length > 0
|
||||
) {
|
||||
const modpackMessage = generateModpackMessage(modpackJudgements.value);
|
||||
if (modpackMessage) {
|
||||
fullMessage = baseMessage ? `${baseMessage}\n\n${modpackMessage}` : modpackMessage;
|
||||
if (props.project.project_type === "modpack") {
|
||||
const modpackFilesData = getModpackFilesFromStorage();
|
||||
|
||||
if (modpackFilesData.interactive.length > 0 || modpackFilesData.permanentNo.length > 0) {
|
||||
const modpackMessage = generateModpackMessage(modpackFilesData);
|
||||
if (modpackMessage) {
|
||||
fullMessage = baseMessage ? `${baseMessage}\n\n${modpackMessage}` : modpackMessage;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1129,25 +1141,32 @@ async function generateMessage() {
|
||||
}
|
||||
}
|
||||
|
||||
function generateModpackMessage(judgements: ModerationJudgements) {
|
||||
function generateModpackMessage(allFiles: {
|
||||
interactive: ModerationModpackItem[];
|
||||
permanentNo: ModerationModpackItem[];
|
||||
}) {
|
||||
const issues = [];
|
||||
|
||||
const attributeMods = [];
|
||||
const noMods = [];
|
||||
const permanentNoMods = [];
|
||||
const unidentifiedMods = [];
|
||||
const attributeMods: string[] = [];
|
||||
const noMods: string[] = [];
|
||||
const permanentNoMods: string[] = [];
|
||||
const unidentifiedMods: string[] = [];
|
||||
|
||||
for (const [, judgement] of Object.entries(judgements)) {
|
||||
if (judgement.status === "with-attribution") {
|
||||
attributeMods.push(judgement.file_name);
|
||||
} else if (judgement.status === "no") {
|
||||
noMods.push(judgement.file_name);
|
||||
} else if (judgement.status === "permanent-no") {
|
||||
permanentNoMods.push(judgement.file_name);
|
||||
} else if (judgement.status === "unidentified") {
|
||||
unidentifiedMods.push(judgement.file_name);
|
||||
allFiles.interactive.forEach((file) => {
|
||||
if (file.status === "unidentified") {
|
||||
if (file.approved === "no") {
|
||||
unidentifiedMods.push(file.file_name);
|
||||
}
|
||||
} else if (file.status === "with-attribution" && file.approved === "no") {
|
||||
attributeMods.push(file.file_name);
|
||||
} else if (file.status === "no" && file.approved === "no") {
|
||||
noMods.push(file.file_name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
allFiles.permanentNo.forEach((file) => {
|
||||
permanentNoMods.push(file.file_name);
|
||||
});
|
||||
|
||||
if (
|
||||
attributeMods.length > 0 ||
|
||||
@@ -1157,6 +1176,12 @@ function generateModpackMessage(judgements: ModerationJudgements) {
|
||||
) {
|
||||
issues.push("## Copyrighted content");
|
||||
|
||||
if (unidentifiedMods.length > 0) {
|
||||
issues.push(
|
||||
`${finalPermissionMessages.unidentified}\n${unidentifiedMods.map((mod) => `- ${mod}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (attributeMods.length > 0) {
|
||||
issues.push(
|
||||
`${finalPermissionMessages["with-attribution"]}\n${attributeMods.map((mod) => `- ${mod}`).join("\n")}`,
|
||||
@@ -1172,12 +1197,6 @@ function generateModpackMessage(judgements: ModerationJudgements) {
|
||||
`${finalPermissionMessages["permanent-no"]}\n${permanentNoMods.map((mod) => `- ${mod}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (unidentifiedMods.length > 0) {
|
||||
issues.push(
|
||||
`${finalPermissionMessages["unidentified"]}\n${unidentifiedMods.map((mod) => `- ${mod}`).join("\n")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return issues.join("\n\n");
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
<template>
|
||||
<template v-if="moderation">
|
||||
<Chips v-model="reasonFilter" :items="reasons" />
|
||||
<p v-if="reports.length === MAX_REPORTS" class="text-red">
|
||||
There are at least {{ MAX_REPORTS }} open reports. This page is at its max reports and will
|
||||
not show any more recent ones.
|
||||
</p>
|
||||
<p v-else-if="reasonFilter === 'All'">There are {{ filteredReports.length }} open reports.</p>
|
||||
<p v-else>
|
||||
There are {{ filteredReports.length }}/{{ reports.length }} open '{{ reasonFilter }}' reports.
|
||||
</p>
|
||||
</template>
|
||||
<ReportInfo
|
||||
v-for="report in reports.filter(
|
||||
(x) =>
|
||||
(moderation || x.reporterUser.id === auth.user.id) &&
|
||||
(viewMode === 'open' ? x.open : !x.open),
|
||||
)"
|
||||
v-for="report in filteredReports"
|
||||
:key="report.id"
|
||||
:report="report"
|
||||
:thread="report.thread"
|
||||
:show-message="false"
|
||||
:moderation="moderation"
|
||||
raised
|
||||
:auth="auth"
|
||||
@@ -16,11 +24,12 @@
|
||||
<p v-if="reports.length === 0">You don't have any active reports.</p>
|
||||
</template>
|
||||
<script setup>
|
||||
import { Chips } from "@modrinth/ui";
|
||||
import ReportInfo from "~/components/ui/report/ReportInfo.vue";
|
||||
import { addReportMessage } from "~/helpers/threads.js";
|
||||
import { asEncodedJsonArray, fetchSegmented } from "~/utils/fetch-helpers.ts";
|
||||
|
||||
defineProps({
|
||||
const props = defineProps({
|
||||
moderation: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -32,9 +41,14 @@ defineProps({
|
||||
});
|
||||
|
||||
const viewMode = ref("open");
|
||||
const reasonFilter = ref("All");
|
||||
const reports = ref([]);
|
||||
|
||||
let { data: rawReports } = await useAsyncData("report", () => useBaseFetch("report?count=1000"));
|
||||
const MAX_REPORTS = 1500;
|
||||
|
||||
let { data: rawReports } = await useAsyncData("report", () =>
|
||||
useBaseFetch(`report?count=${MAX_REPORTS}`),
|
||||
);
|
||||
|
||||
rawReports = rawReports.value.map((report) => {
|
||||
report.item_id = report.item_id.replace(/"/g, "");
|
||||
@@ -51,6 +65,7 @@ const userIds = [...new Set(reporterUsers.concat(reportedUsers))];
|
||||
const threadIds = [
|
||||
...new Set(rawReports.filter((report) => report.thread_id).map((report) => report.thread_id)),
|
||||
];
|
||||
const reasons = ["All", ...new Set(rawReports.map((report) => report.report_type))];
|
||||
|
||||
const [{ data: users }, { data: versions }, { data: threads }] = await Promise.all([
|
||||
await useAsyncData(`users?ids=${JSON.stringify(userIds)}`, () =>
|
||||
@@ -93,4 +108,13 @@ reports.value = rawReports.map((report) => {
|
||||
report.open = true;
|
||||
return report;
|
||||
});
|
||||
|
||||
const filteredReports = computed(() =>
|
||||
reports.value?.filter(
|
||||
(x) =>
|
||||
(props.moderation || x.reporterUser.id === props.auth.user.id) &&
|
||||
(viewMode.value === "open" ? x.open : !x.open) &&
|
||||
(reasonFilter.value === "All" || reasonFilter.value === x.report_type),
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -1,7 +1,36 @@
|
||||
<template>
|
||||
<NewModal ref="mrpackModal" header="Uploading mrpack" :closable="!isLoading" @show="onShow">
|
||||
<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
|
||||
enter-active-class="transition-all duration-300 ease-out"
|
||||
@@ -115,7 +144,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { BackupWarning, ButtonStyled, NewModal, AppearingProgressBar } from "@modrinth/ui";
|
||||
import { BackupWarning, ButtonStyled, NewModal } from "@modrinth/ui";
|
||||
import {
|
||||
UploadIcon,
|
||||
RightArrowIcon,
|
||||
@@ -158,9 +187,50 @@ const hardReset = ref(false);
|
||||
const isLoading = ref(false);
|
||||
const loadingServerCheck = ref(false);
|
||||
const mrpackFile = ref<File | null>(null);
|
||||
const uploadProgress = ref(0);
|
||||
const uploadedBytes = 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 canInstall = computed(() => !mrpackFile.value || isLoading.value || loadingServerCheck.value);
|
||||
|
||||
@@ -189,17 +259,31 @@ const handleReinstall = async () => {
|
||||
}
|
||||
|
||||
isLoading.value = true;
|
||||
uploadProgress.value = 0;
|
||||
uploadProgress.value = 0;
|
||||
uploadedBytes.value = 0;
|
||||
totalBytes.value = mrpackFile.value.size;
|
||||
|
||||
currentPhrase.value = getNextPhrase();
|
||||
phraseInterval = setInterval(() => {
|
||||
currentPhrase.value = getNextPhrase();
|
||||
}, 4500);
|
||||
|
||||
const { onProgress, promise } = props.server.general.reinstallFromMrpack(
|
||||
mrpackFile.value,
|
||||
hardReset.value,
|
||||
);
|
||||
|
||||
onProgress(({ loaded, total }) => {
|
||||
onProgress(({ loaded, total, progress }) => {
|
||||
uploadProgress.value = progress;
|
||||
uploadedBytes.value = loaded;
|
||||
totalBytes.value = total;
|
||||
|
||||
if (phraseInterval && progress >= 100) {
|
||||
clearInterval(phraseInterval);
|
||||
phraseInterval = null;
|
||||
currentPhrase.value = "Installing modpack...";
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -232,6 +316,10 @@ const handleReinstall = async () => {
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
if (phraseInterval) {
|
||||
clearInterval(phraseInterval);
|
||||
phraseInterval = null;
|
||||
}
|
||||
}
|
||||
};
|
||||
const onShow = () => {
|
||||
@@ -240,8 +328,15 @@ const onShow = () => {
|
||||
loadingServerCheck.value = false;
|
||||
isLoading.value = false;
|
||||
mrpackFile.value = null;
|
||||
uploadProgress.value = 0;
|
||||
uploadedBytes.value = 0;
|
||||
totalBytes.value = 0;
|
||||
currentPhrase.value = "Uploading...";
|
||||
usedPhrases.value.clear();
|
||||
if (phraseInterval) {
|
||||
clearInterval(phraseInterval);
|
||||
phraseInterval = null;
|
||||
}
|
||||
};
|
||||
|
||||
const show = () => mrpackModal.value?.show();
|
||||
@@ -254,4 +349,14 @@ defineExpose({ show, hide });
|
||||
.stylized-toggle:checked::after {
|
||||
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>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
<template>
|
||||
<div>
|
||||
<template v-if="flow">
|
||||
<div v-if="subtleLauncherRedirectUri">
|
||||
<iframe
|
||||
:src="subtleLauncherRedirectUri"
|
||||
class="fixed left-0 top-0 z-[9999] m-0 h-full w-full border-0 p-0"
|
||||
></iframe>
|
||||
</div>
|
||||
<div v-else>
|
||||
<template v-if="flow && !subtleLauncherRedirectUri">
|
||||
<label for="two-factor-code">
|
||||
<span class="label__title">{{ formatMessage(messages.twoFactorCodeLabel) }}</span>
|
||||
<span class="label__description">
|
||||
@@ -189,6 +195,7 @@ const auth = await useAuth();
|
||||
const route = useNativeRoute();
|
||||
|
||||
const redirectTarget = route.query.redirect || "";
|
||||
const subtleLauncherRedirectUri = ref();
|
||||
|
||||
if (route.query.code && !route.fullPath.includes("new_account=true")) {
|
||||
await finishSignIn();
|
||||
@@ -262,7 +269,32 @@ async function begin2FASignIn() {
|
||||
|
||||
async function finishSignIn(token) {
|
||||
if (route.query.launcher) {
|
||||
await navigateTo(`https://launcher-files.modrinth.com/?code=${token}`, { external: true });
|
||||
if (!token) {
|
||||
token = auth.value.token;
|
||||
}
|
||||
|
||||
const usesLocalhostRedirectionScheme =
|
||||
["4", "6"].includes(route.query.ipver) && Number(route.query.port) < 65536;
|
||||
|
||||
const redirectUrl = usesLocalhostRedirectionScheme
|
||||
? `http://${route.query.ipver === "4" ? "127.0.0.1" : "[::1]"}:${route.query.port}/?code=${token}`
|
||||
: `https://launcher-files.modrinth.com/?code=${token}`;
|
||||
|
||||
if (usesLocalhostRedirectionScheme) {
|
||||
// When using this redirection scheme, the auth token is very visible in the URL to the user.
|
||||
// While we could make it harder to find with a POST request, such is security by obscurity:
|
||||
// the user and other applications would still be able to sniff the token in the request body.
|
||||
// So, to make the UX a little better by not changing the displayed URL, while keeping the
|
||||
// token hidden from very casual observation and keeping the protocol as close to OAuth's
|
||||
// standard flows as possible, let's execute the redirect within an iframe that visually
|
||||
// covers the entire page.
|
||||
subtleLauncherRedirectUri.value = redirectUrl;
|
||||
} else {
|
||||
await navigateTo(redirectUrl, {
|
||||
external: true,
|
||||
});
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -247,16 +247,14 @@ async function createAccount() {
|
||||
},
|
||||
});
|
||||
|
||||
if (route.query.launcher) {
|
||||
await navigateTo(`https://launcher-files.modrinth.com/?code=${res.session}`, {
|
||||
external: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await useAuth(res.session);
|
||||
await useUser();
|
||||
|
||||
if (route.query.launcher) {
|
||||
await navigateTo({ path: "/auth/sign-in", query: route.query });
|
||||
return;
|
||||
}
|
||||
|
||||
if (route.query.redirect) {
|
||||
await navigateTo(route.query.redirect);
|
||||
} else {
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
FROM rust:1.88.0 AS build
|
||||
|
||||
WORKDIR /usr/src/labrinth
|
||||
COPY . .
|
||||
RUN SQLX_OFFLINE=true cargo build --release --package labrinth
|
||||
RUN --mount=type=cache,target=/usr/src/labrinth/target \
|
||||
--mount=type=cache,target=/usr/local/cargo/git/db \
|
||||
--mount=type=cache,target=/usr/local/cargo/registry \
|
||||
SQLX_OFFLINE=true cargo build --release --package labrinth
|
||||
|
||||
FROM build AS artifacts
|
||||
|
||||
RUN --mount=type=cache,target=/usr/src/labrinth/target \
|
||||
mkdir /labrinth \
|
||||
&& cp /usr/src/labrinth/target/release/labrinth /labrinth/labrinth \
|
||||
&& cp -r /usr/src/labrinth/apps/labrinth/migrations /labrinth \
|
||||
&& cp -r /usr/src/labrinth/apps/labrinth/assets /labrinth
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
@@ -14,10 +27,8 @@ RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends ca-certificates dumb-init curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --from=build /usr/src/labrinth/target/release/labrinth /labrinth/labrinth
|
||||
COPY --from=build /usr/src/labrinth/apps/labrinth/migrations/* /labrinth/migrations/
|
||||
COPY --from=build /usr/src/labrinth/apps/labrinth/assets /labrinth/assets
|
||||
WORKDIR /labrinth
|
||||
COPY --from=artifacts /labrinth /labrinth
|
||||
|
||||
WORKDIR /labrinth
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["/labrinth/labrinth"]
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"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": {
|
||||
"columns": [],
|
||||
"parameters": {
|
||||
"Right": 30
|
||||
"Right": 28
|
||||
},
|
||||
"nullable": []
|
||||
},
|
||||
"hash": "cd7c9f394e4f0ec8fd0043352f7382ac2570aaa096cd2ebb4de990f4d42cc5c9"
|
||||
"hash": "3613473fb4d836ee0fb3c292e6bf5e50912064c29ebf1a1e5ead79c44c37e64c"
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"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": {
|
||||
"columns": [
|
||||
{
|
||||
@@ -142,16 +142,6 @@
|
||||
"name": "toggle_sidebar",
|
||||
"ordinal": 27,
|
||||
"type_info": "Integer"
|
||||
},
|
||||
{
|
||||
"name": "skipped_update",
|
||||
"ordinal": 28,
|
||||
"type_info": "Text"
|
||||
},
|
||||
{
|
||||
"name": "pending_update_toast_for_version",
|
||||
"ordinal": 29,
|
||||
"type_info": "Text"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
@@ -185,10 +175,8 @@
|
||||
true,
|
||||
false,
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
true
|
||||
false
|
||||
]
|
||||
},
|
||||
"hash": "30fecc13c6ea4da1e99e35d307aa8e7c4e7f15ea99527e7974619ef8ed946abe"
|
||||
"hash": "5193f519f021b2e7013cdb67a6e1a31ae4bd7532d02f8b00b43d5645351941ca"
|
||||
}
|
||||
@@ -41,7 +41,7 @@
|
||||
{
|
||||
"name": "display_claims!: serde_json::Value",
|
||||
"ordinal": 7,
|
||||
"type_info": "Text"
|
||||
"type_info": "Null"
|
||||
}
|
||||
],
|
||||
"parameters": {
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN skipped_update TEXT NULL;
|
||||
@@ -1,2 +0,0 @@
|
||||
ALTER TABLE settings
|
||||
ADD COLUMN pending_update_toast_for_version TEXT NULL;
|
||||
@@ -176,6 +176,7 @@ pub enum LoadingBarType {
|
||||
import_location: PathBuf,
|
||||
profile_name: String,
|
||||
},
|
||||
CheckingForUpdates,
|
||||
LauncherUpdate {
|
||||
version: String,
|
||||
current_version: String,
|
||||
|
||||
@@ -25,9 +25,3 @@ pub use event::{
|
||||
};
|
||||
pub use logger::start_logger;
|
||||
pub use state::State;
|
||||
|
||||
pub const LAUNCHER_USER_AGENT: &str = concat!(
|
||||
"modrinth/theseus/",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
" (support@modrinth.com)"
|
||||
);
|
||||
|
||||
@@ -25,11 +25,12 @@ pub fn start_logger() -> Option<()> {
|
||||
.unwrap_or_else(|_| {
|
||||
tracing_subscriber::EnvFilter::new("theseus=info,theseus_gui=info")
|
||||
});
|
||||
tracing_subscriber::registry()
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::fmt::layer())
|
||||
.with(filter)
|
||||
.with(tracing_error::ErrorLayer::default())
|
||||
.init();
|
||||
.with(tracing_error::ErrorLayer::default());
|
||||
tracing::subscriber::set_global_default(subscriber)
|
||||
.expect("setting default subscriber failed");
|
||||
Some(())
|
||||
}
|
||||
|
||||
@@ -75,7 +76,7 @@ pub fn start_logger() -> Option<()> {
|
||||
let filter = tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("theseus=info"));
|
||||
|
||||
tracing_subscriber::registry()
|
||||
let subscriber = tracing_subscriber::registry()
|
||||
.with(
|
||||
tracing_subscriber::fmt::layer()
|
||||
.with_writer(file)
|
||||
@@ -83,8 +84,10 @@ pub fn start_logger() -> Option<()> {
|
||||
.with_timer(ChronoLocal::rfc_3339()),
|
||||
)
|
||||
.with(filter)
|
||||
.with(tracing_error::ErrorLayer::default())
|
||||
.init();
|
||||
.with(tracing_error::ErrorLayer::default());
|
||||
|
||||
tracing::subscriber::set_global_default(subscriber)
|
||||
.expect("Setting default subscriber failed");
|
||||
|
||||
Some(())
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::LAUNCHER_USER_AGENT;
|
||||
use crate::data::ModrinthCredentials;
|
||||
use crate::event::FriendPayload;
|
||||
use crate::event::emit::emit_friend;
|
||||
@@ -83,9 +82,13 @@ impl FriendsSocket {
|
||||
)
|
||||
.into_client_request()?;
|
||||
|
||||
let user_agent = format!(
|
||||
"modrinth/theseus/{} (support@modrinth.com)",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
);
|
||||
request.headers_mut().insert(
|
||||
"User-Agent",
|
||||
HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap(),
|
||||
HeaderValue::from_str(&user_agent).unwrap(),
|
||||
);
|
||||
|
||||
let res = connect_async(request).await;
|
||||
|
||||
@@ -85,21 +85,18 @@ pub struct MinecraftLoginFlow {
|
||||
pub verifier: String,
|
||||
pub challenge: String,
|
||||
pub session_id: String,
|
||||
pub redirect_uri: String,
|
||||
pub auth_request_uri: String,
|
||||
}
|
||||
|
||||
#[tracing::instrument]
|
||||
pub async fn login_begin(
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
) -> crate::Result<MinecraftLoginFlow> {
|
||||
let (pair, current_date, valid_date) =
|
||||
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), false, exec)
|
||||
.await?;
|
||||
let (pair, current_date) =
|
||||
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), exec).await?;
|
||||
|
||||
let verifier = generate_oauth_challenge();
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(&verifier);
|
||||
let result = hasher.finalize();
|
||||
let result = sha2::Sha256::digest(&verifier);
|
||||
let challenge = BASE64_URL_SAFE_NO_PAD.encode(result);
|
||||
|
||||
match sisu_authenticate(
|
||||
@@ -110,46 +107,15 @@ pub async fn login_begin(
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok((session_id, redirect_uri)) => Ok(MinecraftLoginFlow {
|
||||
verifier,
|
||||
challenge,
|
||||
session_id,
|
||||
redirect_uri: redirect_uri.value.msa_oauth_redirect,
|
||||
}),
|
||||
Err(err) => {
|
||||
if !valid_date {
|
||||
let (pair, current_date, _) =
|
||||
DeviceTokenPair::refresh_and_get_device_token(
|
||||
Utc::now(),
|
||||
false,
|
||||
exec,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let verifier = generate_oauth_challenge();
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
hasher.update(&verifier);
|
||||
let result = hasher.finalize();
|
||||
let challenge = BASE64_URL_SAFE_NO_PAD.encode(result);
|
||||
|
||||
let (session_id, redirect_uri) = sisu_authenticate(
|
||||
&pair.token.token,
|
||||
&challenge,
|
||||
&pair.key,
|
||||
current_date,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok(MinecraftLoginFlow {
|
||||
verifier,
|
||||
challenge,
|
||||
session_id,
|
||||
redirect_uri: redirect_uri.value.msa_oauth_redirect,
|
||||
})
|
||||
} else {
|
||||
Err(crate::ErrorKind::from(err).into())
|
||||
}
|
||||
Ok((session_id, redirect_uri)) => {
|
||||
return Ok(MinecraftLoginFlow {
|
||||
verifier,
|
||||
challenge,
|
||||
session_id,
|
||||
auth_request_uri: redirect_uri.value.msa_oauth_redirect,
|
||||
});
|
||||
}
|
||||
Err(err) => return Err(crate::ErrorKind::from(err).into()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -159,9 +125,8 @@ pub async fn login_finish(
|
||||
flow: MinecraftLoginFlow,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
) -> crate::Result<Credentials> {
|
||||
let (pair, _, _) =
|
||||
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), false, exec)
|
||||
.await?;
|
||||
let (pair, _) =
|
||||
DeviceTokenPair::refresh_and_get_device_token(Utc::now(), exec).await?;
|
||||
|
||||
let oauth_token = oauth_token(code, &flow.verifier).await?;
|
||||
let sisu_authorize = sisu_authorize(
|
||||
@@ -267,10 +232,9 @@ impl Credentials {
|
||||
}
|
||||
|
||||
let oauth_token = oauth_refresh(&self.refresh_token).await?;
|
||||
let (pair, current_date, _) =
|
||||
let (pair, current_date) =
|
||||
DeviceTokenPair::refresh_and_get_device_token(
|
||||
oauth_token.date,
|
||||
false,
|
||||
exec,
|
||||
)
|
||||
.await?;
|
||||
@@ -633,21 +597,20 @@ impl DeviceTokenPair {
|
||||
#[tracing::instrument(skip(exec))]
|
||||
async fn refresh_and_get_device_token(
|
||||
current_date: DateTime<Utc>,
|
||||
force_generate: bool,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,
|
||||
) -> crate::Result<(Self, DateTime<Utc>, bool)> {
|
||||
) -> crate::Result<(Self, DateTime<Utc>)> {
|
||||
let pair = Self::get(exec).await?;
|
||||
|
||||
if let Some(mut pair) = pair {
|
||||
if pair.token.not_after > Utc::now() && !force_generate {
|
||||
Ok((pair, current_date, false))
|
||||
if pair.token.not_after > current_date {
|
||||
Ok((pair, current_date))
|
||||
} else {
|
||||
let res = device_token(&pair.key, current_date).await?;
|
||||
|
||||
pair.token = res.value;
|
||||
pair.upsert(exec).await?;
|
||||
|
||||
Ok((pair, res.date, true))
|
||||
Ok((pair, res.date))
|
||||
}
|
||||
} else {
|
||||
let key = generate_key()?;
|
||||
@@ -660,7 +623,7 @@ impl DeviceTokenPair {
|
||||
|
||||
pair.upsert(exec).await?;
|
||||
|
||||
Ok((pair, res.date, true))
|
||||
Ok((pair, res.date))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,8 +721,8 @@ impl DeviceTokenPair {
|
||||
}
|
||||
|
||||
const MICROSOFT_CLIENT_ID: &str = "00000000402b5328";
|
||||
const REDIRECT_URL: &str = "https://login.live.com/oauth20_desktop.srf";
|
||||
const REQUESTED_SCOPES: &str = "service::user.auth.xboxlive.com::MBI_SSL";
|
||||
const AUTH_REPLY_URL: &str = "https://login.live.com/oauth20_desktop.srf";
|
||||
const REQUESTED_SCOPE: &str = "service::user.auth.xboxlive.com::MBI_SSL";
|
||||
|
||||
struct RequestWithDate<T> {
|
||||
pub date: DateTime<Utc>,
|
||||
@@ -838,7 +801,7 @@ async fn sisu_authenticate(
|
||||
"AppId": MICROSOFT_CLIENT_ID,
|
||||
"DeviceToken": token,
|
||||
"Offers": [
|
||||
REQUESTED_SCOPES
|
||||
REQUESTED_SCOPE
|
||||
],
|
||||
"Query": {
|
||||
"code_challenge": challenge,
|
||||
@@ -846,7 +809,7 @@ async fn sisu_authenticate(
|
||||
"state": generate_oauth_challenge(),
|
||||
"prompt": "select_account"
|
||||
},
|
||||
"RedirectUri": REDIRECT_URL,
|
||||
"RedirectUri": AUTH_REPLY_URL,
|
||||
"Sandbox": "RETAIL",
|
||||
"TokenType": "code",
|
||||
"TitleId": "1794566092",
|
||||
@@ -890,12 +853,12 @@ async fn oauth_token(
|
||||
verifier: &str,
|
||||
) -> Result<RequestWithDate<OAuthToken>, MinecraftAuthenticationError> {
|
||||
let mut query = HashMap::new();
|
||||
query.insert("client_id", "00000000402b5328");
|
||||
query.insert("client_id", MICROSOFT_CLIENT_ID);
|
||||
query.insert("code", code);
|
||||
query.insert("code_verifier", verifier);
|
||||
query.insert("grant_type", "authorization_code");
|
||||
query.insert("redirect_uri", "https://login.live.com/oauth20_desktop.srf");
|
||||
query.insert("scope", "service::user.auth.xboxlive.com::MBI_SSL");
|
||||
query.insert("redirect_uri", AUTH_REPLY_URL);
|
||||
query.insert("scope", REQUESTED_SCOPE);
|
||||
|
||||
let res = auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
@@ -939,11 +902,11 @@ async fn oauth_refresh(
|
||||
refresh_token: &str,
|
||||
) -> Result<RequestWithDate<OAuthToken>, MinecraftAuthenticationError> {
|
||||
let mut query = HashMap::new();
|
||||
query.insert("client_id", "00000000402b5328");
|
||||
query.insert("client_id", MICROSOFT_CLIENT_ID);
|
||||
query.insert("refresh_token", refresh_token);
|
||||
query.insert("grant_type", "refresh_token");
|
||||
query.insert("redirect_uri", "https://login.live.com/oauth20_desktop.srf");
|
||||
query.insert("scope", "service::user.auth.xboxlive.com::MBI_SSL");
|
||||
query.insert("redirect_uri", AUTH_REPLY_URL);
|
||||
query.insert("scope", REQUESTED_SCOPE);
|
||||
|
||||
let res = auth_retry(|| {
|
||||
REQWEST_CLIENT
|
||||
@@ -1007,7 +970,7 @@ async fn sisu_authorize(
|
||||
"/authorize",
|
||||
json!({
|
||||
"AccessToken": format!("t={access_token}"),
|
||||
"AppId": "00000000402b5328",
|
||||
"AppId": MICROSOFT_CLIENT_ID,
|
||||
"DeviceToken": device_token,
|
||||
"ProofKey": {
|
||||
"kty": "EC",
|
||||
|
||||
@@ -190,7 +190,7 @@ impl ModrinthCredentials {
|
||||
}
|
||||
|
||||
pub const fn get_login_url() -> &'static str {
|
||||
concat!(env!("MODRINTH_URL"), "auth/sign-in?launcher=true")
|
||||
concat!(env!("MODRINTH_URL"), "auth/sign-in")
|
||||
}
|
||||
|
||||
pub async fn finish_login_flow(
|
||||
@@ -198,6 +198,12 @@ pub async fn finish_login_flow(
|
||||
semaphore: &FetchSemaphore,
|
||||
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite>,
|
||||
) -> crate::Result<ModrinthCredentials> {
|
||||
// The authorization code actually is the access token, since Labrinth doesn't
|
||||
// issue separate authorization codes. Therefore, this is equivalent to an
|
||||
// implicit OAuth grant flow, and no additional exchanging or finalization is
|
||||
// needed. TODO not do this for the reasons outlined at
|
||||
// https://oauth.net/2/grant-types/implicit/
|
||||
|
||||
let info = fetch_info(code, semaphore, exec).await?;
|
||||
|
||||
Ok(ModrinthCredentials {
|
||||
|
||||
@@ -38,9 +38,6 @@ pub struct Settings {
|
||||
|
||||
pub developer_mode: 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)]
|
||||
@@ -66,8 +63,7 @@ impl Settings {
|
||||
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,
|
||||
hook_pre_launch, hook_wrapper, hook_post_exit,
|
||||
custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,
|
||||
skipped_update, pending_update_toast_for_version
|
||||
custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar
|
||||
FROM settings
|
||||
"
|
||||
)
|
||||
@@ -121,9 +117,6 @@ impl Settings {
|
||||
.as_ref()
|
||||
.and_then(|x| serde_json::from_str(x).ok())
|
||||
.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,
|
||||
feature_flags = $27,
|
||||
hide_nametag_skins_page = $28,
|
||||
|
||||
skipped_update = $29,
|
||||
pending_update_toast_for_version = $30
|
||||
hide_nametag_skins_page = $28
|
||||
",
|
||||
max_concurrent_writes,
|
||||
max_concurrent_downloads,
|
||||
@@ -209,9 +199,7 @@ impl Settings {
|
||||
self.migrated,
|
||||
self.toggle_sidebar,
|
||||
feature_flags,
|
||||
self.hide_nametag_skins_page,
|
||||
self.skipped_update,
|
||||
self.pending_update_toast_for_version,
|
||||
self.hide_nametag_skins_page
|
||||
)
|
||||
.execute(exec)
|
||||
.await?;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
//! Functions for fetching information from the Internet
|
||||
use super::io::{self, IOError};
|
||||
use crate::LAUNCHER_USER_AGENT;
|
||||
use crate::event::LoadingBarId;
|
||||
use crate::event::emit::emit_loading;
|
||||
use bytes::Bytes;
|
||||
@@ -20,8 +19,11 @@ pub struct FetchSemaphore(pub Semaphore);
|
||||
|
||||
pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
let header =
|
||||
reqwest::header::HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap();
|
||||
let header = reqwest::header::HeaderValue::from_str(&format!(
|
||||
"modrinth/theseus/{} (support@modrinth.com)",
|
||||
env!("CARGO_PKG_VERSION")
|
||||
))
|
||||
.unwrap();
|
||||
headers.insert(reqwest::header::USER_AGENT, header);
|
||||
reqwest::Client::builder()
|
||||
.tcp_keepalive(Some(time::Duration::from_secs(10)))
|
||||
|
||||
@@ -132,7 +132,6 @@ import _RadioButtonCheckedIcon from './icons/radio-button-checked.svg?component'
|
||||
import _RadioButtonIcon from './icons/radio-button.svg?component'
|
||||
import _ReceiptTextIcon from './icons/receipt-text.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 _ReportIcon from './icons/report.svg?component'
|
||||
import _RestoreIcon from './icons/restore.svg?component'
|
||||
@@ -324,7 +323,6 @@ export const RadioButtonCheckedIcon = _RadioButtonCheckedIcon
|
||||
export const RadioButtonIcon = _RadioButtonIcon
|
||||
export const ReceiptTextIcon = _ReceiptTextIcon
|
||||
export const RedoIcon = _RedoIcon
|
||||
export const RefreshCwIcon = _RefreshCwIcon
|
||||
export const ReplyIcon = _ReplyIcon
|
||||
export const ReportIcon = _ReportIcon
|
||||
export const RestoreIcon = _RestoreIcon
|
||||
|
||||
@@ -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 |
@@ -0,0 +1,2 @@
|
||||
**Client:** `%PROJECT_CLIENT_SIDE%` \
|
||||
**Server:** `%PROJECT_SERVER_SIDE%`
|
||||
@@ -1,3 +1,5 @@
|
||||
## Misuse of Slug
|
||||
## Misuse of custom URL
|
||||
|
||||
Per section 5.2 of %RULES% must accurately represent your project.
|
||||
We ask that you ensure your project's %PROJECT_SLUG_FLINK% accurately represents your project.
|
||||
Your current slug of `%PROJECT_SLUG%` may not accurately match your project's Name or contain excess information.
|
||||
A mismatched URL may make it more difficult for users to find your content. Abbreviations or similar are fine to use if applicable. If your preferred URL is not available, and you cannot find a matching public project, let us know in this moderation thread when you resubmit your project, and our moderation team may be able to free it up for your project.
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
## Private Use
|
||||
|
||||
Under normal circumstances, your project would be rejected due to the issues listed above.
|
||||
However, since your project is not intended for for public use, these requirements will be waived and your project will be unlisted. This means it will remain accessible through a direct link without appearing in public search results, allowing you to share it privately.
|
||||
However, since your project is not intended for public use, these requirements will be waived and your project will be unlisted. This means it will remain accessible through a direct link without appearing in public search results, allowing you to share it privately.
|
||||
If you're okay with this, or submitted your project to be unlisted already, than no further action is necessary.
|
||||
If you would like to publish your project publicly, please address all moderation concerns before resubmitting this project.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
## Unsupported Project
|
||||
|
||||
Unfortunately, Modrinth does not currently support the upload of %INVALID_TYPE%.
|
||||
|
||||
If you would like to publish this project in the future and help Modrinth grow, consider creating an [issue](https://github.com/modrinth/code/issues) suggesting support for this type of content.
|
||||
|
||||
We appreciate your understanding and look forward to hosting your other creations.
|
||||
@@ -31,7 +31,9 @@ const categories: Stage = {
|
||||
weight: 701,
|
||||
suggestedStatus: 'flagged',
|
||||
severity: 'low',
|
||||
shouldShow: (project) => project.categories.includes('optimization'),
|
||||
shouldShow: (project) =>
|
||||
project.categories.includes('optimization') ||
|
||||
project.additional_categories.includes('optimization'),
|
||||
message: async () =>
|
||||
(await import('../messages/categories/inaccurate.md?raw')).default +
|
||||
(await import('../messages/categories/optimization_misused.md?raw')).default,
|
||||
|
||||
@@ -25,6 +25,7 @@ const gallery: Stage = {
|
||||
weight: 901,
|
||||
suggestedStatus: 'flagged',
|
||||
severity: 'low',
|
||||
shouldShow: (project) => project.gallery && project.gallery.length > 0,
|
||||
message: async () => (await import('../messages/gallery/not-relevant.md?raw')).default,
|
||||
} as ButtonAction,
|
||||
],
|
||||
|
||||
@@ -7,7 +7,8 @@ const sideTypes: Stage = {
|
||||
id: 'environment',
|
||||
icon: GlobeIcon,
|
||||
guidance_url: 'https://modrinth.com/legal/rules#miscellaneous',
|
||||
navigate: '/settings#side-types',
|
||||
navigate: '/settings',
|
||||
text: async () => (await import('../messages/checklist-text/side_types.md?raw')).default,
|
||||
actions: [
|
||||
{
|
||||
id: 'side_types_inaccurate_modpack',
|
||||
|
||||
@@ -150,7 +150,24 @@ const versions: Stage = {
|
||||
severity: `medium`,
|
||||
weight: 1004,
|
||||
message: async () => (await import('../messages/versions/broken_version.md?raw')).default,
|
||||
},
|
||||
} as ButtonAction,
|
||||
{
|
||||
id: 'unsupported_project_type',
|
||||
type: 'button',
|
||||
label: `Unsupported`,
|
||||
suggestedStatus: `rejected`,
|
||||
severity: `medium`,
|
||||
weight: 1005,
|
||||
message: async () =>
|
||||
(await import('../messages/versions/unsupported_project.md?raw')).default,
|
||||
relevantExtraInput: [
|
||||
{
|
||||
label: 'Project Type',
|
||||
required: true,
|
||||
variable: 'INVALID_TYPE',
|
||||
},
|
||||
],
|
||||
} as ButtonAction,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from './types/messages'
|
||||
export * from './types/stage'
|
||||
export * from './types/keybinds'
|
||||
export * from './utils'
|
||||
export { finalPermissionMessages } from './data/modpack-permissions-stage'
|
||||
|
||||
export { default as checklist } from './data/checklist'
|
||||
export { default as keybinds } from './data/keybinds'
|
||||
|
||||
@@ -317,6 +317,11 @@ export function flattenProjectVariables(project: Project): Record<string, string
|
||||
vars[`PROJECT_PERMANENT_LINK`] = `https://modrinth.com/project/${project.id}`
|
||||
vars[`PROJECT_SETTINGS_LINK`] = `https://modrinth.com/project/${project.id}/settings`
|
||||
vars[`PROJECT_SETTINGS_FLINK`] = `[Settings](https://modrinth.com/project/${project.id}/settings)`
|
||||
vars[`PROJECT_TITLE_FLINK`] = `[Name](https://modrinth.com/project/${project.id}/settings)`
|
||||
vars[`PROJECT_SLUG_FLINK`] = `[URL](https://modrinth.com/project/${project.id}/settings)`
|
||||
vars[`PROJECT_SUMMARY_FLINK`] = `[Summary](https://modrinth.com/project/${project.id}/settings)`
|
||||
vars[`PROJECT_ENVIRONMENT_FLINK`] =
|
||||
`[Environment Information](https://modrinth.com/project/${project.id}/settings)`
|
||||
vars[`PROJECT_TAGS_LINK`] = `https://modrinth.com/project/${project.id}/settings/tags`
|
||||
vars[`PROJECT_TAGS_FLINK`] = `[Tags](https://modrinth.com/project/${project.id}/settings/tags)`
|
||||
vars[`PROJECT_DESCRIPTION_LINK`] =
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
<template>
|
||||
<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="isVisible" class="w-full">
|
||||
<div class="mb-2 flex justify-between text-sm">
|
||||
<Transition name="phrase-fade" mode="out-in">
|
||||
<span :key="currentPhrase" class="text-md font-semibold">{{ currentPhrase }}</span>
|
||||
</Transition>
|
||||
<div class="flex flex-col items-end">
|
||||
<span class="text-secondary">{{ Math.round(progress) }}%</span>
|
||||
<span class="text-xs text-secondary"
|
||||
>{{ formatBytes(currentValue) }} / {{ formatBytes(maxValue) }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="h-2 w-full rounded-full bg-divider">
|
||||
<div
|
||||
class="h-2 animate-pulse bg-brand rounded-full transition-all duration-300 ease-out"
|
||||
:style="{ width: `${progress}%` }"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatBytes } from '@modrinth/utils'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
maxValue: number
|
||||
currentValue: number
|
||||
tips?: string[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
tips: () => [
|
||||
'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('')
|
||||
const usedPhrases = ref(new Set<number>())
|
||||
let phraseInterval: NodeJS.Timeout | null = null
|
||||
|
||||
const progress = computed(() => {
|
||||
if (props.maxValue === 0) return 0
|
||||
return Math.min((props.currentValue / props.maxValue) * 100, 100)
|
||||
})
|
||||
|
||||
const isVisible = computed(() => props.maxValue > 0 && props.currentValue >= 0)
|
||||
|
||||
function getNextPhrase() {
|
||||
if (usedPhrases.value.size >= props.tips.length) {
|
||||
const currentPhraseIndex = props.tips.indexOf(currentPhrase.value)
|
||||
usedPhrases.value.clear()
|
||||
if (currentPhraseIndex !== -1) {
|
||||
usedPhrases.value.add(currentPhraseIndex)
|
||||
}
|
||||
}
|
||||
const availableIndices = props.tips
|
||||
.map((_, index) => index)
|
||||
.filter((index) => !usedPhrases.value.has(index))
|
||||
|
||||
const randomIndex = availableIndices[Math.floor(Math.random() * availableIndices.length)]
|
||||
usedPhrases.value.add(randomIndex)
|
||||
|
||||
return props.tips[randomIndex]
|
||||
}
|
||||
|
||||
function startPhraseRotation() {
|
||||
if (phraseInterval) {
|
||||
clearInterval(phraseInterval)
|
||||
}
|
||||
|
||||
currentPhrase.value = getNextPhrase()
|
||||
phraseInterval = setInterval(() => {
|
||||
currentPhrase.value = getNextPhrase()
|
||||
}, 4500)
|
||||
}
|
||||
|
||||
function stopPhraseRotation() {
|
||||
if (phraseInterval) {
|
||||
clearInterval(phraseInterval)
|
||||
phraseInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
watch(isVisible, (newVisible) => {
|
||||
if (newVisible) {
|
||||
startPhraseRotation()
|
||||
} else {
|
||||
stopPhraseRotation()
|
||||
usedPhrases.value.clear()
|
||||
}
|
||||
})
|
||||
|
||||
watch(progress, (newProgress) => {
|
||||
if (newProgress >= 100) {
|
||||
stopPhraseRotation()
|
||||
currentPhrase.value = 'Installing modpack...'
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopPhraseRotation()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.phrase-fade-enter-active,
|
||||
.phrase-fade-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.phrase-fade-enter-from,
|
||||
.phrase-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,120 +0,0 @@
|
||||
<template>
|
||||
<div class="joined-buttons">
|
||||
<ButtonStyled :color="color">
|
||||
<button :disabled="disabled" @click="handlePrimaryAction">
|
||||
<component :is="primaryAction.icon" v-if="primaryAction.icon" aria-hidden="true" />
|
||||
{{ primaryAction.label }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="dropdownActions.length > 0" :color="color">
|
||||
<OverflowMenu class="btn-dropdown-animation" :options="dropdownOptions" :disabled="disabled">
|
||||
<DropdownIcon />
|
||||
<template v-for="action in dropdownActions" :key="action.id" #[action.id]>
|
||||
<component :is="action.icon" v-if="action.icon" aria-hidden="true" />
|
||||
{{ action.label }}
|
||||
</template>
|
||||
</OverflowMenu>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ButtonStyled, OverflowMenu } from '../index'
|
||||
import { DropdownIcon } from '@modrinth/assets'
|
||||
import type { Component } from 'vue'
|
||||
|
||||
// TODO: This should be moved to a shared types file.
|
||||
type Colors = 'standard' | 'brand' | 'red' | 'orange' | 'green' | 'blue' | 'purple'
|
||||
|
||||
export interface JoinedButtonAction {
|
||||
id: string
|
||||
label: string
|
||||
icon?: Component
|
||||
action: () => void
|
||||
color?: Colors
|
||||
hoverFilled?: boolean
|
||||
}
|
||||
|
||||
interface Props {
|
||||
actions: JoinedButtonAction[]
|
||||
color?: Colors
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
color: 'standard',
|
||||
disabled: false,
|
||||
})
|
||||
|
||||
const primaryAction = computed(() => props.actions[0])
|
||||
|
||||
const dropdownActions = computed(() => props.actions.slice(1))
|
||||
|
||||
const colorMap: Record<
|
||||
Colors,
|
||||
| 'red'
|
||||
| 'orange'
|
||||
| 'green'
|
||||
| 'blue'
|
||||
| 'purple'
|
||||
| 'highlight'
|
||||
| 'primary'
|
||||
| 'danger'
|
||||
| 'secondary'
|
||||
| undefined
|
||||
> = {
|
||||
standard: 'secondary',
|
||||
brand: 'primary',
|
||||
red: 'red',
|
||||
orange: 'orange',
|
||||
green: 'green',
|
||||
blue: 'blue',
|
||||
purple: 'purple',
|
||||
}
|
||||
|
||||
const dropdownOptions = computed(() =>
|
||||
dropdownActions.value.map((action) => ({
|
||||
id: action.id,
|
||||
color: action.color ? colorMap[action.color] : undefined,
|
||||
action: action.action,
|
||||
hoverFilled: action.hoverFilled ?? true,
|
||||
})),
|
||||
)
|
||||
|
||||
function handlePrimaryAction() {
|
||||
if (primaryAction.value && !props.disabled) {
|
||||
primaryAction.value.action()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.joined-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.joined-buttons > :deep(.btn) {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.joined-buttons > :deep(.btn:first-child) {
|
||||
border-top-left-radius: var(--radius-md);
|
||||
border-bottom-left-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.joined-buttons > :deep(.btn:last-child) {
|
||||
border-top-right-radius: var(--radius-md);
|
||||
border-bottom-right-radius: var(--radius-md);
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
.joined-buttons > :deep(.btn:not(:last-child)) {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.btn-dropdown-animation {
|
||||
padding: 0.5rem !important;
|
||||
}
|
||||
</style>
|
||||
@@ -9,11 +9,7 @@
|
||||
@mouseenter="stopTimer(item)"
|
||||
@mouseleave="setNotificationTimer(item)"
|
||||
>
|
||||
<div
|
||||
class="vue-notification-template vue-notification"
|
||||
:class="{ [item.type]: true }"
|
||||
@click="item.clickAction"
|
||||
>
|
||||
<div class="vue-notification-template vue-notification" :class="{ [item.type]: true }">
|
||||
<div class="notification-title" v-html="item.title"></div>
|
||||
<div class="notification-content" v-html="item.text"></div>
|
||||
</div>
|
||||
@@ -35,14 +31,11 @@ const notifications = ref([])
|
||||
|
||||
defineExpose({
|
||||
addNotification: (notification) => {
|
||||
notification.clickAction = notification.clickAction ?? (() => {})
|
||||
|
||||
const existingNotif = notifications.value.find(
|
||||
(x) =>
|
||||
x.text === notification.text &&
|
||||
x.title === notification.title &&
|
||||
x.type === notification.type &&
|
||||
x.clickAction === notification.clickAction,
|
||||
x.type === notification.type,
|
||||
)
|
||||
if (existingNotif) {
|
||||
setNotificationTimer(existingNotif)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
// Base content
|
||||
export { default as Accordion } from './base/Accordion.vue'
|
||||
export { default as Admonition } from './base/Admonition.vue'
|
||||
export { default as AppearingProgressBar } from './base/AppearingProgressBar.vue'
|
||||
export { default as AutoLink } from './base/AutoLink.vue'
|
||||
export { default as Avatar } from './base/Avatar.vue'
|
||||
export { default as Badge } from './base/Badge.vue'
|
||||
@@ -22,8 +21,6 @@ export { default as FileInput } from './base/FileInput.vue'
|
||||
export { default as FilterBar } from './base/FilterBar.vue'
|
||||
export type { FilterBarOption } from './base/FilterBar.vue'
|
||||
export { default as HeadingLink } from './base/HeadingLink.vue'
|
||||
export { default as JoinedButtons } from './base/JoinedButtons.vue'
|
||||
export type { JoinedButtonAction } from './base/JoinedButtons.vue'
|
||||
export { default as LoadingIndicator } from './base/LoadingIndicator.vue'
|
||||
export { default as ManySelect } from './base/ManySelect.vue'
|
||||
export { default as MarkdownEditor } from './base/MarkdownEditor.vue'
|
||||
|
||||
@@ -22,7 +22,6 @@
|
||||
<div class="modal-body flex flex-col bg-bg-raised rounded-2xl">
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
v-if="!hideHeader"
|
||||
class="grid grid-cols-[auto_min-content] items-center gap-12 p-6 border-solid border-0 border-b-[1px] border-divider max-w-full"
|
||||
>
|
||||
<div class="flex text-wrap break-words items-center gap-3 min-w-0">
|
||||
@@ -61,7 +60,6 @@ const props = withDefaults(
|
||||
closeOnClickOutside?: boolean
|
||||
warnOnClose?: boolean
|
||||
header?: string
|
||||
hideHeader?: boolean
|
||||
onHide?: () => void
|
||||
onShow?: () => void
|
||||
}>(),
|
||||
@@ -73,7 +71,6 @@ const props = withDefaults(
|
||||
closeOnEsc: true,
|
||||
warnOnClose: false,
|
||||
header: undefined,
|
||||
hideHeader: false,
|
||||
onHide: () => {},
|
||||
onShow: () => {},
|
||||
},
|
||||
@@ -137,7 +134,7 @@ function updateMousePosition(event: { clientX: number; clientY: number }) {
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (props.closeOnEsc && event.key === 'Escape' && props.closable) {
|
||||
if (props.closeOnEsc && event.key === 'Escape') {
|
||||
hide()
|
||||
mouseX.value = window.innerWidth / 2
|
||||
mouseY.value = window.innerHeight / 2
|
||||
|
||||
@@ -315,7 +315,7 @@ export interface ModerationPermissionType {
|
||||
export interface ModerationBaseModpackItem {
|
||||
sha1: string
|
||||
file_name: string
|
||||
type: 'unknown' | 'flame'
|
||||
type: 'unknown' | 'flame' | 'identified'
|
||||
status: ModerationModpackPermissionApprovalType['id'] | null
|
||||
approved: ModerationPermissionType['id'] | null
|
||||
}
|
||||
@@ -334,9 +334,26 @@ export interface ModerationFlameModpackItem extends ModerationBaseModpackItem {
|
||||
url: string
|
||||
}
|
||||
|
||||
export type ModerationModpackItem = ModerationUnknownModpackItem | ModerationFlameModpackItem
|
||||
export interface ModerationIdentifiedModpackItem extends ModerationBaseModpackItem {
|
||||
type: 'identified'
|
||||
proof?: string
|
||||
url?: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export type ModerationModpackItem =
|
||||
| ModerationUnknownModpackItem
|
||||
| ModerationFlameModpackItem
|
||||
| ModerationIdentifiedModpackItem
|
||||
|
||||
export interface ModerationModpackResponse {
|
||||
identified?: Record<
|
||||
string,
|
||||
{
|
||||
file_name: string
|
||||
status: ModerationModpackPermissionApprovalType['id']
|
||||
}
|
||||
>
|
||||
unknown_files?: Record<string, string>
|
||||
flame_files?: Record<
|
||||
string,
|
||||
@@ -350,8 +367,8 @@ export interface ModerationModpackResponse {
|
||||
}
|
||||
|
||||
export interface ModerationJudgement {
|
||||
type: 'flame' | 'unknown'
|
||||
status: string
|
||||
type: 'flame' | 'unknown' | 'identified'
|
||||
status: string | null
|
||||
id?: string
|
||||
link?: string
|
||||
title?: string
|
||||
|
||||
Reference in New Issue
Block a user