Merge branch 'main' into tunneling

This commit is contained in:
Josiah Glosson
2025-01-27 12:27:35 -06:00
51 changed files with 809 additions and 233 deletions

View File

@@ -1,22 +1,22 @@
<script setup>
import { computed, ref, onMounted, watch, onUnmounted } from 'vue'
import { RouterView, useRouter, useRoute } from 'vue-router'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { RouterView, useRoute, useRouter } from 'vue-router'
import {
ArrowBigUpDashIcon,
LogInIcon,
CompassIcon,
DownloadIcon,
HomeIcon,
LeftArrowIcon,
LibraryIcon,
LogInIcon,
LogOutIcon,
MaximizeIcon,
MinimizeIcon,
PlusIcon,
RestoreIcon,
RightArrowIcon,
SettingsIcon,
XIcon,
DownloadIcon,
CompassIcon,
MinimizeIcon,
MaximizeIcon,
RestoreIcon,
LogOutIcon,
RightArrowIcon,
LeftArrowIcon,
} from '@modrinth/assets'
import { Avatar, Button, ButtonStyled, Notifications, OverflowMenu } from '@modrinth/ui'
import { useLoading, useTheming } from '@/store/state'
@@ -32,12 +32,12 @@ 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 { isDev, getOS, restartApp } from '@/helpers/utils.js'
import { initAnalytics, debugAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics'
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'
import URLConfirmModal from '@/components/ui/URLConfirmModal.vue'
import { install_from_file } from './helpers/pack'
import { create_profile_and_install_from_file } from './helpers/pack'
import { useError } from '@/store/error.js'
import { useCheckDisableMouseover } from '@/composables/macCssFix.js'
import ModInstallModal from '@/components/ui/install_flow/ModInstallModal.vue'
@@ -51,7 +51,7 @@ 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, logout, login } from '@/helpers/mr_auth.js'
import { 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 dayjs from 'dayjs'
@@ -296,7 +296,7 @@ async function handleCommand(e) {
if (e.event === 'RunMRPack') {
// RunMRPack should directly install a local mrpack given a path
if (e.path.endsWith('.mrpack')) {
await install_from_file(e.path).catch(handleError)
await create_profile_and_install_from_file(e.path).catch(handleError)
trackEvent('InstanceCreate', {
source: 'CreationModalFileDrop',
})

View File

@@ -1,10 +1,17 @@
<script setup>
import { onUnmounted, ref, computed, onMounted } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { SpinnerIcon, GameIcon, TimerIcon, StopCircleIcon, PlayIcon } from '@modrinth/assets'
import { ButtonStyled, Avatar } from '@modrinth/ui'
import {
DownloadIcon,
GameIcon,
PlayIcon,
SpinnerIcon,
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import { Avatar, ButtonStyled } from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { kill, run } from '@/helpers/profile'
import { finish_install, kill, run } from '@/helpers/profile'
import { get_by_profile_path } from '@/helpers/process'
import { process_listener } from '@/helpers/events'
import { handleError } from '@/store/state.js'
@@ -42,7 +49,8 @@ const modLoading = computed(
currentEvent.value === 'installing' ||
(currentEvent.value === 'launched' && !playing.value),
)
const installing = computed(() => props.instance.install_stage !== 'installed')
const installing = computed(() => props.instance.install_stage.includes('installing'))
const installed = computed(() => props.instance.install_stage === 'installed')
const router = useRouter()
@@ -84,6 +92,12 @@ const stop = async (e, context) => {
})
}
const repair = async (e) => {
e?.stopPropagation()
await finish_install(props.instance)
}
const openFolder = async () => {
await showProfileInFolder(props.instance.path)
}
@@ -195,6 +209,15 @@ onUnmounted(() => unlisten())
class="animate-spin w-8 h-8"
tabindex="-1"
/>
<ButtonStyled v-else-if="!installed" size="large" color="brand" circular>
<button
v-tooltip="'Repair'"
class="transition-all scale-75 group-hover:scale-100 group-focus-within:scale-100 origin-bottom opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 card-shadow"
@click="(e) => repair(e)"
>
<DownloadIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else size="large" color="brand" circular>
<button
v-tooltip="'Play'"

View File

@@ -199,16 +199,16 @@
<script setup>
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import {
PlusIcon,
UploadIcon,
XIcon,
CodeIcon,
FolderOpenIcon,
InfoIcon,
FolderSearchIcon,
InfoIcon,
PlusIcon,
UpdatedIcon,
UploadIcon,
XIcon,
} from '@modrinth/assets'
import { Avatar, Button, Chips, Checkbox } from '@modrinth/ui'
import { Avatar, Button, Checkbox, Chips } from '@modrinth/ui'
import { computed, onUnmounted, ref, shallowRef } from 'vue'
import { get_loaders } from '@/helpers/tags'
import { create } from '@/helpers/profile'
@@ -218,7 +218,7 @@ import { get_game_versions, get_loader_versions } from '@/helpers/metadata'
import { handleError } from '@/store/notifications.js'
import Multiselect from 'vue-multiselect'
import { trackEvent } from '@/helpers/analytics'
import { install_from_file } from '@/helpers/pack.js'
import { create_profile_and_install_from_file } from '@/helpers/pack.js'
import {
get_default_launcher_path,
get_importable_instances,
@@ -263,7 +263,7 @@ defineExpose({
hide()
const { paths } = event.payload
if (paths && paths.length > 0 && paths[0].endsWith('.mrpack')) {
await install_from_file(paths[0]).catch(handleError)
await create_profile_and_install_from_file(paths[0]).catch(handleError)
trackEvent('InstanceCreate', {
source: 'CreationModalFileDrop',
})
@@ -419,7 +419,7 @@ const openFile = async () => {
const newProject = await open({ multiple: false })
if (!newProject) return
hide()
await install_from_file(newProject.path ?? newProject).catch(handleError)
await create_profile_and_install_from_file(newProject.path ?? newProject).catch(handleError)
trackEvent('InstanceCreate', {
source: 'CreationModalFileOpen',

View File

@@ -20,7 +20,7 @@ import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
const props = defineProps<{
credentials: unknown | null
signIn: () => void2
signIn: () => void
}>()
const userCredentials = computed(() => props.credentials)

View File

@@ -1,7 +1,7 @@
<script setup>
import { XIcon, DownloadIcon } from '@modrinth/assets'
import { DownloadIcon, XIcon } from '@modrinth/assets'
import { Button } from '@modrinth/ui'
import { install as pack_install } from '@/helpers/pack'
import { create_profile_and_install as pack_install } from '@/helpers/pack'
import { ref } from 'vue'
import { trackEvent } from '@/helpers/analytics'
import { handleError } from '@/store/state.js'

View File

@@ -7,7 +7,7 @@ import { invoke } from '@tauri-apps/api/core'
import { create } from './profile'
// Installs pack from a version ID
export async function install(projectId, versionId, packTitle, iconUrl) {
export async function create_profile_and_install(projectId, versionId, packTitle, iconUrl) {
const location = {
type: 'fromVersionId',
project_id: projectId,
@@ -28,8 +28,18 @@ export async function install(projectId, versionId, packTitle, iconUrl) {
return await invoke('plugin:pack|pack_install', { location, profile })
}
export async function install_to_existing_profile(projectId, versionId, title, profilePath) {
const location = {
type: 'fromVersionId',
project_id: projectId,
version_id: versionId,
title,
}
return await invoke('plugin:pack|pack_install', { location, profile: profilePath })
}
// Installs pack from a path
export async function install_from_file(path) {
export async function create_profile_and_install_from_file(path) {
const location = {
type: 'fromFile',
path: path,

View File

@@ -4,6 +4,8 @@
* and deserialized into a usable JS object.
*/
import { invoke } from '@tauri-apps/api/core'
import { install_to_existing_profile } from '@/helpers/pack.js'
import { handleError } from '@/store/notifications.js'
/// Add instance
/*
@@ -186,3 +188,17 @@ export async function edit(path, editProfile) {
export async function edit_icon(path, iconPath) {
return await invoke('plugin:profile|profile_edit_icon', { path, iconPath })
}
export async function finish_install(instance) {
if (instance.install_stage !== 'pack_installed') {
let linkedData = instance.linked_data
await install_to_existing_profile(
linkedData.project_id,
linkedData.version_id,
instance.name,
instance.path,
).catch(handleError)
} else {
await install(instance.path, false).catch(handleError)
}
}

View File

@@ -32,7 +32,12 @@ type GameInstance = {
hooks: Hooks
}
type InstallStage = 'installed' | 'installing' | 'pack_installing' | 'not_installed'
type InstallStage =
| 'installed'
| 'minecraft_installing'
| 'pack_installed'
| 'pack_installing'
| 'not_installed'
type LinkedData = {
project_id: ModrinthId

View File

@@ -30,9 +30,23 @@
</template>
<template #actions>
<div class="flex gap-2">
<ButtonStyled v-if="instance.install_stage !== 'installed'" color="brand" size="large">
<ButtonStyled
v-if="instance.install_stage.includes('installing')"
color="brand"
size="large"
>
<button disabled>Installing...</button>
</ButtonStyled>
<ButtonStyled
v-else-if="instance.install_stage !== 'installed'"
color="brand"
size="large"
>
<button @click="repairInstance()">
<DownloadIcon />
Repair
</button>
</ButtonStyled>
<ButtonStyled v-else-if="playing === true" color="red" size="large">
<button @click="stopInstance('InstancePage')">
<StopCircleIcon />
@@ -137,38 +151,39 @@
<script setup>
import {
Avatar,
ContentPageHeader,
ButtonStyled,
OverflowMenu,
ContentPageHeader,
LoadingIndicator,
OverflowMenu,
} from '@modrinth/ui'
import {
UserPlusIcon,
ServerIcon,
PackageIcon,
SettingsIcon,
PlayIcon,
StopCircleIcon,
EditIcon,
FolderOpenIcon,
ClipboardCopyIcon,
PlusIcon,
ExternalIcon,
HashIcon,
GlobeIcon,
EyeIcon,
XIcon,
CheckCircleIcon,
UpdatedIcon,
MoreVerticalIcon,
ClipboardCopyIcon,
DownloadIcon,
EditIcon,
ExternalIcon,
EyeIcon,
FolderOpenIcon,
GameIcon,
GlobeIcon,
HashIcon,
MoreVerticalIcon,
PackageIcon,
PlayIcon,
PlusIcon,
ServerIcon,
SettingsIcon,
StopCircleIcon,
TimerIcon,
UpdatedIcon,
UserPlusIcon,
XIcon,
} from '@modrinth/assets'
import { get, get_full_path, kill, run } from '@/helpers/profile'
import { finish_install, get, get_full_path, kill, run } from '@/helpers/profile'
import { get_by_profile_path } from '@/helpers/process'
import { process_listener, profile_listener } from '@/helpers/events'
import { useRoute, useRouter } from 'vue-router'
import { ref, onUnmounted, computed, watch } from 'vue'
import { computed, onUnmounted, ref, watch } from 'vue'
import { handleError, useBreadcrumbs, useLoading } from '@/store/state'
import { showProfileInFolder } from '@/helpers/utils.js'
import ContextMenu from '@/components/ui/ContextMenu.vue'
@@ -294,6 +309,10 @@ const stopInstance = async (context) => {
})
}
const repairInstance = async () => {
await finish_install(instance.value)
}
const handleRightClick = (event) => {
const baseOptions = [
{ name: 'add_content' },

View File

@@ -2,14 +2,14 @@ import { defineStore } from 'pinia'
import {
add_project_from_version,
check_installed,
list,
get,
get_projects,
list,
remove_project,
} from '@/helpers/profile.js'
import { handleError } from '@/store/notifications.js'
import { get_project, get_version_many } from '@/helpers/cache.js'
import { install as packInstall } from '@/helpers/pack.js'
import { create_profile_and_install as packInstall } from '@/helpers/pack.js'
import { trackEvent } from '@/helpers/analytics.js'
import dayjs from 'dayjs'

View File

@@ -4,6 +4,7 @@
)]
use native_dialog::{MessageDialog, MessageType};
use std::env;
use tauri::{Listener, Manager};
use theseus::prelude::*;
@@ -29,7 +30,12 @@ async fn initialize_state(app: tauri::AppHandle) -> api::Result<()> {
theseus::EventState::init(app.clone()).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()?;

View File

@@ -1,4 +1,7 @@
{
"admin.billing.error.not-found": {
"message": "User not found"
},
"auth.authorize.action.authorize": {
"message": "Authorize"
},
@@ -338,6 +341,12 @@
"layout.nav.search": {
"message": "Search"
},
"profile.button.billing": {
"message": "Manage user billing"
},
"profile.button.info": {
"message": "View user details"
},
"profile.button.manage-projects": {
"message": "Manage projects"
},

View File

@@ -184,7 +184,19 @@
</div>
</div>
</div>
<NewModal ref="downloadModal">
<NewModal
ref="downloadModal"
:on-show="
() => {
navigateTo({ query: route.query, hash: '#download' });
}
"
:on-hide="
() => {
navigateTo({ query: route.query, hash: '' });
}
"
>
<template #title>
<Avatar :src="project.icon_url" :alt="project.title" class="icon" size="32px" />
<div class="truncate text-lg font-extrabold text-contrast">
@@ -275,7 +287,7 @@
</div>
<ScrollablePanel :class="project.game_versions.length > 4 ? 'h-[15rem]' : ''">
<ButtonStyled
v-for="version in project.game_versions
v-for="gameVersion in project.game_versions
.filter(
(x) =>
(versionFilter && x.includes(versionFilter)) ||
@@ -284,30 +296,39 @@
)
.slice()
.reverse()"
:key="version"
:color="currentGameVersion === version ? 'brand' : 'standard'"
:key="gameVersion"
:color="currentGameVersion === gameVersion ? 'brand' : 'standard'"
>
<button
v-tooltip="
!possibleGameVersions.includes(version)
? `${project.title} does not support ${version} for ${formatCategory(currentPlatform)}`
!possibleGameVersions.includes(gameVersion)
? `${project.title} does not support ${gameVersion} for ${formatCategory(currentPlatform)}`
: null
"
:class="{
'looks-disabled !text-brand-red': !possibleGameVersions.includes(version),
'looks-disabled !text-brand-red': !possibleGameVersions.includes(gameVersion),
}"
@click="
() => {
userSelectedGameVersion = version;
userSelectedGameVersion = gameVersion;
gameVersionAccordion.close();
if (!currentPlatform && platformAccordion) {
platformAccordion.open();
}
navigateTo({
query: {
...route.query,
...(userSelectedGameVersion && { version: userSelectedGameVersion }),
...(userSelectedPlatform && { loader: userSelectedPlatform }),
},
hash: route.hash,
});
}
"
>
{{ version }}
<CheckIcon v-if="userSelectedGameVersion === version" />
{{ gameVersion }}
<CheckIcon v-if="userSelectedGameVersion === gameVersion" />
</button>
</ButtonStyled>
</ScrollablePanel>
@@ -379,6 +400,15 @@
if (!currentGameVersion && gameVersionAccordion) {
gameVersionAccordion.open();
}
navigateTo({
query: {
...route.query,
...(userSelectedGameVersion && { version: userSelectedGameVersion }),
...(userSelectedPlatform && { loader: userSelectedPlatform }),
},
hash: route.hash,
});
}
"
>
@@ -506,7 +536,7 @@
placeholder="Search collections..."
class="search-input menu-search"
/>
<div v-if="collections.length > 0" class="collections-list">
<div v-if="collections.length > 0" class="collections-list text-primary">
<Checkbox
v-for="option in collections
.slice()
@@ -785,31 +815,31 @@
</template>
<script setup>
import {
ScaleIcon,
AlignLeftIcon as DescriptionIcon,
BookmarkIcon,
BookTextIcon,
CalendarIcon,
ChartIcon,
CheckIcon,
ClipboardCopyIcon,
CopyrightIcon,
AlignLeftIcon as DescriptionIcon,
DownloadIcon,
ExternalIcon,
ImageIcon as GalleryIcon,
GameIcon,
HeartIcon,
ImageIcon as GalleryIcon,
InfoIcon,
LinkIcon as LinksIcon,
MoreVerticalIcon,
PlusIcon,
ReportIcon,
ScaleIcon,
SearchIcon,
SettingsIcon,
TagsIcon,
UsersIcon,
VersionIcon,
WrenchIcon,
BookTextIcon,
CalendarIcon,
} from "@modrinth/assets";
import {
Avatar,
@@ -818,32 +848,33 @@ import {
NewModal,
OverflowMenu,
PopoutMenu,
ScrollablePanel,
ProjectBackgroundGradient,
ProjectHeader,
ProjectSidebarCompatibility,
ProjectSidebarCreators,
ProjectSidebarLinks,
ProjectSidebarDetails,
ProjectBackgroundGradient,
ProjectSidebarLinks,
ScrollablePanel,
} from "@modrinth/ui";
import { formatCategory, isRejected, isStaff, isUnderReview, renderString } from "@modrinth/utils";
import dayjs from "dayjs";
import VersionSummary from "@modrinth/ui/src/components/version/VersionSummary.vue";
import { formatCategory, isRejected, isStaff, isUnderReview, renderString } from "@modrinth/utils";
import { navigateTo } from "#app";
import dayjs from "dayjs";
import ModrinthIcon from "~/assets/images/utils/modrinth.svg?component";
import Accordion from "~/components/ui/Accordion.vue";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
import Badge from "~/components/ui/Badge.vue";
import NavTabs from "~/components/ui/NavTabs.vue";
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
import MessageBanner from "~/components/ui/MessageBanner.vue";
import ModerationChecklist from "~/components/ui/ModerationChecklist.vue";
import NavStack from "~/components/ui/NavStack.vue";
import NavStackItem from "~/components/ui/NavStackItem.vue";
import NavTabs from "~/components/ui/NavTabs.vue";
import ProjectMemberHeader from "~/components/ui/ProjectMemberHeader.vue";
import MessageBanner from "~/components/ui/MessageBanner.vue";
import { reportProject } from "~/utils/report-helpers.ts";
import Breadcrumbs from "~/components/ui/Breadcrumbs.vue";
import { userCollectProject } from "~/composables/user.js";
import CollectionCreateModal from "~/components/ui/CollectionCreateModal.vue";
import ModerationChecklist from "~/components/ui/ModerationChecklist.vue";
import Accordion from "~/components/ui/Accordion.vue";
import ModrinthIcon from "~/assets/images/utils/modrinth.svg?component";
import AutomaticAccordion from "~/components/ui/AutomaticAccordion.vue";
import AdPlaceholder from "~/components/ui/AdPlaceholder.vue";
import { reportProject } from "~/utils/report-helpers.ts";
const data = useNuxtApp();
const route = useNativeRoute();
@@ -1247,6 +1278,23 @@ if (!route.name.startsWith("type-id-settings")) {
const onUserCollectProject = useClientTry(userCollectProject);
const { version, loader } = route.query;
if (version !== undefined && project.value.game_versions.includes(version)) {
userSelectedGameVersion.value = version;
}
if (loader !== undefined && project.value.loaders.includes(loader)) {
userSelectedPlatform.value = loader;
}
watch(downloadModal, (modal) => {
if (!modal) return;
// route.hash returns everything in the hash string, including the # itself
if (route.hash === "#download") {
modal.show();
}
});
async function setProcessing() {
startLoading();

View File

@@ -821,6 +821,13 @@ export default defineNuxtComponent({
if (route.query.version) {
versionList = versionList.filter((x) => x.game_versions.includes(route.query.version));
}
if (versionList.length === 0) {
throw createError({
fatal: true,
statusCode: 404,
message: "No version matches the filters",
});
}
version = versionList.reduce((a, b) => (a.date_published > b.date_published ? a : b));
} else {
version = props.versions.find((x) => x.id === route.params.version);

View File

@@ -0,0 +1,220 @@
<template>
<NewModal ref="refundModal">
<template #title>
<span class="text-lg font-extrabold text-contrast">Refund charge</span>
</template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-2">
<label for="visibility" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Refund type
<span class="text-brand-red">*</span>
</span>
<span> The type of refund to issue. </span>
</label>
<DropdownSelect
id="refund-type"
v-model="refundType"
:options="refundTypes"
name="Refund type"
/>
</div>
<div v-if="refundType === 'partial'" class="flex flex-col gap-2">
<label for="amount" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Amount
<span class="text-brand-red">*</span>
</span>
<span>
Enter the amount in cents of USD. For example for $2, enter 200. (net
{{ selectedCharge.net }})
</span>
</label>
<input id="amount" v-model="refundAmount" type="number" autocomplete="off" />
</div>
<div class="flex flex-col gap-2">
<label for="unprovision" class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
Unprovision
<span class="text-brand-red">*</span>
</span>
<span> Whether or not the subscription should be unprovisioned on refund. </span>
</label>
<Toggle
id="unprovision"
:model-value="unprovision"
:checked="unprovision"
@update:model-value="() => (unprovision = !unprovision)"
/>
</div>
<div class="flex gap-2">
<ButtonStyled color="brand">
<button :disabled="refunding" @click="refundCharge">
<CheckIcon aria-hidden="true" />
Refund charge
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="refundModal.hide()">
<XIcon aria-hidden="true" />
Cancel
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
<div class="normal-page no-sidebar">
<h1>{{ user.username }}'s subscriptions</h1>
<div class="normal-page__content">
<div v-for="subscription in subscriptionCharges" :key="subscription.id" class="card">
<span class="font-extrabold text-contrast">
<template v-if="subscription.product.metadata.type === 'midas'"> Modrinth Plus </template>
<template v-else-if="subscription.product.metadata.type === 'pyro'">
Modrinth Servers
</template>
<template v-else> Unknown product </template>
<template v-if="subscription.interval">
{{ subscription.interval }}
</template>
</span>
<div class="mb-4 mt-2 flex items-center gap-1">
{{ subscription.status }} ⋅ {{ $dayjs(subscription.created).format("YYYY-MM-DD") }}
<template v-if="subscription.metadata?.id"> ⋅ {{ subscription.metadata.id }}</template>
</div>
<div
v-for="charge in subscription.charges"
:key="charge.id"
class="universal-card recessed flex items-center justify-between gap-4"
>
<div class="flex w-full items-center justify-between gap-4">
<div class="flex items-center gap-1">
<Badge
:color="charge.status === 'succeeded' ? 'green' : 'red'"
:type="charge.status"
/>
{{ charge.type }}
{{ $dayjs(charge.due).format("YYYY-MM-DD") }}
<span>{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}</span>
<template v-if="subscription.interval"> ⋅ {{ subscription.interval }} </template>
</div>
<button
v-if="charge.status === 'succeeded' && charge.type !== 'refund'"
class="btn"
@click="showRefundModal(charge)"
>
Refund charge
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { Badge, NewModal, ButtonStyled, DropdownSelect, Toggle } from "@modrinth/ui";
import { formatPrice } from "@modrinth/utils";
import { CheckIcon, XIcon } from "@modrinth/assets";
import { products } from "~/generated/state.json";
const route = useRoute();
const data = useNuxtApp();
const vintl = useVIntl();
const { formatMessage } = vintl;
const messages = defineMessages({
userNotFoundError: {
id: "admin.billing.error.not-found",
defaultMessage: "User not found",
},
});
const { data: user } = await useAsyncData(`user/${route.params.id}`, () =>
useBaseFetch(`user/${route.params.id}`),
);
if (!user.value) {
throw createError({
fatal: true,
statusCode: 404,
message: formatMessage(messages.userNotFoundError),
});
}
let subscriptions, charges, refreshCharges;
try {
[{ data: subscriptions }, { data: charges, refresh: refreshCharges }] = await Promise.all([
useAsyncData(`billing/subscriptions?user_id=${route.params.id}`, () =>
useBaseFetch(`billing/subscriptions?user_id=${user.value.id}`, {
internal: true,
}),
),
useAsyncData(`billing/payments?user_id=${route.params.id}`, () =>
useBaseFetch(`billing/payments?user_id=${user.value.id}`, {
internal: true,
}),
),
]);
} catch {
throw createError({
fatal: true,
statusCode: 404,
message: formatMessage(messages.userNotFoundError),
});
}
const subscriptionCharges = computed(() => {
return subscriptions.value.map((subscription) => {
return {
...subscription,
charges: charges.value.filter((charge) => charge.subscription_id === subscription.id),
product: products.find((product) =>
product.prices.some((price) => price.id === subscription.price_id),
),
};
});
});
const refunding = ref(false);
const refundModal = ref();
const selectedCharge = ref(null);
const refundType = ref("full");
const refundTypes = ref(["full", "partial"]);
const refundAmount = ref(0);
const unprovision = ref(false);
function showRefundModal(charge) {
selectedCharge.value = charge;
refundType.value = "full";
refundAmount.value = 0;
unprovision.value = false;
refundModal.value.show();
}
async function refundCharge() {
refunding.value = true;
try {
await useBaseFetch(`billing/charge/${selectedCharge.value.id}/refund`, {
method: "POST",
body: JSON.stringify({
type: refundType.value,
amount: refundAmount.value,
unprovision: unprovision.value,
}),
internal: true,
});
await refreshCharges();
refundModal.value.hide();
} catch (err) {
data.$notify({
group: "main",
title: "Error refunding",
text: err.data?.description ?? err,
type: "error",
});
}
refunding.value = false;
}
</script>

View File

@@ -38,9 +38,13 @@
<div class="withdraw-options-scroll">
<div class="withdraw-options">
<button
v-for="method in payoutMethods.filter((x) =>
x.name.toLowerCase().includes(search.toLowerCase()),
)"
v-for="method in payoutMethods
.filter((x) => x.name.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) =>
a.type !== 'tremendous'
? -1
: a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
)"
:key="method.id"
class="withdraw-option button-base"
:class="{ selected: selectedMethodId === method.id }"

View File

@@ -25,7 +25,7 @@
</template>
</span>
<span>{{ formatPrice(charge.amount, charge.currency_code) }}</span>
<span>{{ formatPrice(vintl.locale, charge.amount, charge.currency_code) }}</span>
</div>
<div class="flex items-center gap-1">
<Badge :color="charge.status === 'succeeded' ? 'green' : 'red'" :type="charge.status" />
@@ -39,6 +39,7 @@
</template>
<script setup>
import { Breadcrumbs, Badge } from "@modrinth/ui";
import { formatPrice } from "@modrinth/utils";
import { products } from "~/generated/state.json";
definePageMeta({
@@ -66,19 +67,4 @@ const { data: charges } = await useAsyncData(
},
},
);
// TODO move to omorphia utils , duplicated from index
function formatPrice(price, currency) {
const formatter = new Intl.NumberFormat(vintl.locale, {
style: "currency",
currency,
});
const maxDigits = formatter.resolvedOptions().maximumFractionDigits;
const convertedPrice = price / Math.pow(10, maxDigits);
return formatter.format(convertedPrice);
}
console.log(charges);
</script>

View File

@@ -257,7 +257,7 @@
v-else-if="getPyroCharge(subscription).status === 'processing'"
class="text-sm text-orange"
>
Your payment is being processed. Perks will activate once payment is
Your payment is being processed. Your server will activate once payment is
complete.
</span>
<span
@@ -270,7 +270,8 @@
v-else-if="getPyroCharge(subscription).status === 'failed'"
class="text-sm text-red"
>
Your subscription payment failed. Please update your payment method.
Your subscription payment failed. Please update your payment method, then
resubscribe.
</span>
</div>
</div>
@@ -278,7 +279,8 @@
<ButtonStyled
v-if="
getPyroCharge(subscription) &&
getPyroCharge(subscription).status !== 'cancelled'
getPyroCharge(subscription).status !== 'cancelled' &&
getPyroCharge(subscription).status !== 'failed'
"
type="standard"
@click="showPyroCancelModal(subscription.id)"
@@ -291,7 +293,8 @@
<ButtonStyled
v-else-if="
getPyroCharge(subscription) &&
getPyroCharge(subscription).status === 'cancelled'
(getPyroCharge(subscription).status === 'cancelled' ||
getPyroCharge(subscription).status === 'failed')
"
type="standard"
color="green"

View File

@@ -2,6 +2,57 @@
<div v-if="user" class="experimental-styles-within">
<ModalCreation ref="modal_creation" />
<CollectionCreateModal ref="modal_collection_creation" />
<NewModal v-if="auth.user && isStaff(auth.user)" ref="userDetailsModal" header="User details">
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary">Email</span>
<div>
<span
v-tooltip="user.email_verified ? 'Email verified' : 'Email not verified'"
class="flex w-fit items-center gap-1"
>
<span>{{ user.email }}</span>
<CheckIcon v-if="user.email_verified" class="h-4 w-4 text-brand" />
<XIcon v-else class="h-4 w-4 text-red" />
</span>
</div>
</div>
<div class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary"> Auth providers </span>
<span>{{ user.auth_providers.join(", ") }}</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary"> Payment methods</span>
<span>
<template v-if="user.payout_data?.paypal_address">
Paypal ({{ user.payout_data.paypal_address }} - {{ user.payout_data.paypal_country }})
</template>
<template v-if="user.payout_data?.paypal_address && user.payout_data?.venmo_address">
,
</template>
<template v-if="user.payout_data?.venmo_address">
Venmo ({{ user.payout_data.venmo_address }})
</template>
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary"> Has password </span>
<span>
{{ user.has_password ? "Yes" : "No" }}
</span>
</div>
<div class="flex flex-col gap-1">
<span class="text-lg font-bold text-primary"> Has TOTP </span>
<span>
{{ user.has_totp ? "Yes" : "No" }}
</span>
</div>
</div>
</NewModal>
<div class="new-page sidebar" :class="{ 'alt-layout': cosmetics.leftContentLayout }">
<div class="normal-page__header py-4">
<ContentPageHeader>
@@ -74,6 +125,16 @@
shown: auth.user?.id !== user.id,
},
{ id: 'copy-id', action: () => copyId() },
{
id: 'open-billing',
action: () => navigateTo(`/admin/billing/${user.id}`),
shown: auth.user && isStaff(auth.user),
},
{
id: 'open-info',
action: () => $refs.userDetailsModal.show(),
shown: auth.user && isStaff(auth.user),
},
]"
aria-label="More options"
>
@@ -90,6 +151,14 @@
<ClipboardCopyIcon aria-hidden="true" />
{{ formatMessage(commonMessages.copyIdButton) }}
</template>
<template #open-billing>
<CurrencyIcon aria-hidden="true" />
{{ formatMessage(messages.billingButton) }}
</template>
<template #open-info>
<InfoIcon aria-hidden="true" />
{{ formatMessage(messages.infoButton) }}
</template>
</OverflowMenu>
</ButtonStyled>
</template>
@@ -264,8 +333,18 @@ import {
DownloadIcon,
ClipboardCopyIcon,
MoreVerticalIcon,
CurrencyIcon,
InfoIcon,
CheckIcon,
} from "@modrinth/assets";
import { OverflowMenu, ButtonStyled, ContentPageHeader, commonMessages } from "@modrinth/ui";
import {
OverflowMenu,
ButtonStyled,
ContentPageHeader,
commonMessages,
NewModal,
} from "@modrinth/ui";
import { isStaff } from "~/helpers/users.js";
import NavTabs from "~/components/ui/NavTabs.vue";
import ProjectCard from "~/components/ui/ProjectCard.vue";
import { reportUser } from "~/utils/report-helpers.ts";
@@ -367,6 +446,14 @@ const messages = defineMessages({
defaultMessage:
"You don't have any collections.\nWould you like to <create-link>create one</create-link>?",
},
billingButton: {
id: "profile.button.billing",
defaultMessage: "Manage user billing",
},
infoButton: {
id: "profile.button.info",
defaultMessage: "View user details",
},
userNotFoundError: {
id: "profile.error.not-found",
defaultMessage: "User not found",

View File

@@ -38,7 +38,7 @@
false,
false,
true,
true,
false,
true
]
},

View File

@@ -38,7 +38,7 @@
false,
false,
true,
true,
false,
true
]
},

View File

@@ -44,7 +44,7 @@
false,
false,
true,
true,
false,
true
]
},

View File

@@ -1,4 +1,4 @@
FROM rust:1.81.0 as build
FROM rust:1.84.0 as build
ENV PKG_CONFIG_ALLOW_CROSS=1
WORKDIR /usr/src/labrinth

View File

@@ -0,0 +1,28 @@
ALTER TABLE version_fields
DROP CONSTRAINT version_fields_enum_value_fkey;
ALTER TABLE version_fields
ALTER COLUMN enum_value SET DEFAULT -1;
UPDATE version_fields SET enum_value = -1 WHERE enum_value IS NULL;
ALTER TABLE version_fields
ALTER COLUMN enum_value SET NOT NULL;
WITH CTE AS (
SELECT ctid,
ROW_NUMBER() OVER (PARTITION BY version_id, field_id, enum_value ORDER BY ctid) AS row_num
FROM version_fields
)
DELETE FROM version_fields
WHERE ctid IN (
SELECT ctid
FROM CTE
WHERE row_num > 1
);
ALTER TABLE version_fields
ADD PRIMARY KEY (version_id, field_id, enum_value);
ALTER TABLE loader_fields_loaders
ADD PRIMARY KEY (loader_id, loader_field_id);

View File

@@ -757,7 +757,7 @@ impl VersionField {
l.field_id.0,
l.version_id.0,
l.int_value,
l.enum_value.as_ref().map(|e| e.0),
l.enum_value.as_ref().map(|e| e.0).unwrap_or(-1),
l.string_value.clone(),
)
})
@@ -772,7 +772,7 @@ impl VersionField {
&version_ids[..],
&int_values[..] as &[Option<i32>],
&string_values[..] as &[Option<String>],
&enum_values[..] as &[Option<i32>]
&enum_values[..] as &[i32]
)
.execute(&mut **transaction)
.await?;

View File

@@ -595,12 +595,12 @@ impl Project {
version_id: VersionId(m.version_id),
field_id: LoaderFieldId(m.field_id),
int_value: m.int_value,
enum_value: m.enum_value.map(LoaderFieldEnumValueId),
enum_value: if m.enum_value == -1 { None } else { Some(LoaderFieldEnumValueId(m.enum_value)) },
string_value: m.string_value,
};
if let Some(enum_value) = m.enum_value {
loader_field_enum_value_ids.insert(LoaderFieldEnumValueId(enum_value));
if m.enum_value != -1 {
loader_field_enum_value_ids.insert(LoaderFieldEnumValueId(m.enum_value));
}
acc.entry(ProjectId(m.mod_id)).or_default().push(qvf);

View File

@@ -405,7 +405,7 @@ impl TeamMember {
Ok(())
}
pub async fn delete<'a, 'b>(
pub async fn delete(
id: TeamId,
user_id: UserId,
transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>,

View File

@@ -499,12 +499,12 @@ impl Version {
version_id: VersionId(m.version_id),
field_id: LoaderFieldId(m.field_id),
int_value: m.int_value,
enum_value: m.enum_value.map(LoaderFieldEnumValueId),
enum_value: if m.enum_value == -1 { None } else { Some(LoaderFieldEnumValueId(m.enum_value)) },
string_value: m.string_value,
};
if let Some(enum_value) = m.enum_value {
loader_field_enum_value_ids.insert(LoaderFieldEnumValueId(enum_value));
if m.enum_value != -1 {
loader_field_enum_value_ids.insert(LoaderFieldEnumValueId(m.enum_value));
}
acc.entry(VersionId(m.version_id)).or_default().push(qvf);

View File

@@ -161,7 +161,7 @@ pub struct Charge {
pub id: ChargeId,
pub user_id: UserId,
pub price_id: ProductPriceId,
pub amount: u64,
pub amount: i64,
pub currency_code: String,
pub status: ChargeStatus,
pub due: DateTime<Utc>,
@@ -171,6 +171,9 @@ pub struct Charge {
pub subscription_id: Option<UserSubscriptionId>,
pub subscription_interval: Option<PriceDuration>,
pub platform: PaymentPlatform,
pub parent_charge_id: Option<ChargeId>,
pub net: Option<i64>,
}
#[derive(Serialize, Deserialize, Debug)]

View File

@@ -189,7 +189,7 @@ pub struct DelphiIngest {
pub url: String,
pub project_id: crate::models::ids::ProjectId,
pub version_id: crate::models::ids::VersionId,
pub issues: Vec<String>,
pub issues: HashMap<String, HashMap<String, String>>,
}
#[post("/_delphi", guard = "admin_key_guard")]
@@ -218,29 +218,41 @@ pub async fn delphi_result_ingest(
))
})?;
let mut header = format!("Suspicious traces found at {}", body.url);
for (issue, trace) in &body.issues {
for (path, code) in trace {
header.push_str(&format!(
"\n issue {issue} found at file {}: \n ```\n{}\n```",
path, code
));
}
}
crate::util::webhook::send_slack_webhook(
body.project_id,
&pool,
&redis,
webhook_url,
Some(format!(
"Suspicious traces found at {}. Traces: {}",
body.url,
body.issues.join(", ")
)),
Some(header),
)
.await
.ok();
let mut thread_header = format!("Suspicious traces found at [version {}](https://modrinth.com/project/{}/version/{})", body.version_id, body.project_id, body.version_id);
for (issue, trace) in &body.issues {
for path in trace.keys() {
thread_header
.push_str(&format!("\n issue {issue} found at file {}", path));
}
}
let mut transaction = pool.begin().await?;
ThreadMessageBuilder {
author_id: Some(crate::database::models::UserId(AUTOMOD_ID)),
body: MessageBody::Text {
body: format!(
"WSR; Suspicious traces found for version_id {}. Traces: {}",
body.version_id,
body.issues.join(", ")
),
body: thread_header,
private: true,
replying_to: None,
associated_images: vec![],

View File

@@ -83,12 +83,18 @@ pub async fn products(
Ok(HttpResponse::Ok().json(products))
}
#[derive(Deserialize)]
struct SubscriptionsQuery {
pub user_id: Option<crate::models::ids::UserId>,
}
#[get("subscriptions")]
pub async fn subscriptions(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
query: web::Query<SubscriptionsQuery>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
@@ -102,7 +108,18 @@ pub async fn subscriptions(
let subscriptions =
user_subscription_item::UserSubscriptionItem::get_all_user(
user.id.into(),
if let Some(user_id) = query.user_id {
if user.role.is_admin() {
user_id.into()
} else {
return Err(ApiError::InvalidInput(
"You cannot see the subscriptions of other users!"
.to_string(),
));
}
} else {
user.id.into()
},
&**pool,
)
.await?
@@ -573,12 +590,18 @@ pub async fn user_customer(
Ok(HttpResponse::Ok().json(customer))
}
#[derive(Deserialize)]
pub struct ChargesQuery {
pub user_id: Option<crate::models::ids::UserId>,
}
#[get("payments")]
pub async fn charges(
req: HttpRequest,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
query: web::Query<ChargesQuery>,
) -> Result<HttpResponse, ApiError> {
let user = get_user_from_headers(
&req,
@@ -592,7 +615,18 @@ pub async fn charges(
let charges =
crate::database::models::charge_item::ChargeItem::get_from_user(
user.id.into(),
if let Some(user_id) = query.user_id {
if user.role.is_admin() {
user_id.into()
} else {
return Err(ApiError::InvalidInput(
"You cannot see the subscriptions of other users!"
.to_string(),
));
}
} else {
user.id.into()
},
&**pool,
)
.await?;
@@ -604,7 +638,7 @@ pub async fn charges(
id: x.id.into(),
user_id: x.user_id.into(),
price_id: x.price_id.into(),
amount: x.amount as u64,
amount: x.amount,
currency_code: x.currency_code,
status: x.status,
due: x.due,
@@ -613,6 +647,8 @@ pub async fn charges(
subscription_id: x.subscription_id.map(|x| x.into()),
subscription_interval: x.subscription_interval,
platform: x.payment_platform,
parent_charge_id: x.parent_charge_id.map(|x| x.into()),
net: if user.role.is_admin() { x.net } else { None },
})
.collect::<Vec<_>>(),
))
@@ -880,11 +916,11 @@ pub async fn active_servers(
) -> Result<HttpResponse, ApiError> {
let master_key = dotenvy::var("PYRO_API_KEY")?;
if !req
if req
.head()
.headers()
.get("X-Master-Key")
.map_or(false, |it| it.as_bytes() == master_key.as_bytes())
.is_none_or(|it| it.as_bytes() != master_key.as_bytes())
{
return Err(ApiError::CustomAuthentication(
"Invalid master key".to_string(),

View File

@@ -85,11 +85,13 @@ pub async fn users_get(
#[get("{id}")]
pub async fn user_get(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let response = v3::users::user_get(info, pool, redis)
let response = v3::users::user_get(req, info, pool, redis, session_queue)
.await
.or_else(v2_reroute::flatten_404_error)?;

View File

@@ -156,7 +156,7 @@ pub struct NewOAuthApp {
}
#[post("app")]
pub async fn oauth_client_create<'a>(
pub async fn oauth_client_create(
req: HttpRequest,
new_oauth_app: web::Json<NewOAuthApp>,
pool: web::Data<PgPool>,
@@ -217,7 +217,7 @@ pub async fn oauth_client_create<'a>(
}
#[delete("app/{id}")]
pub async fn oauth_client_delete<'a>(
pub async fn oauth_client_delete(
req: HttpRequest,
client_id: web::Path<ApiOAuthClientId>,
pool: web::Data<PgPool>,

View File

@@ -86,8 +86,6 @@ pub enum CreateError {
CustomAuthenticationError(String),
#[error("Image Parsing Error: {0}")]
ImageError(#[from] ImageError),
#[error("Reroute Error: {0}")]
RerouteError(#[from] reqwest::Error),
}
impl actix_web::ResponseError for CreateError {
@@ -119,7 +117,6 @@ impl actix_web::ResponseError for CreateError {
CreateError::ValidationError(..) => StatusCode::BAD_REQUEST,
CreateError::FileValidationError(..) => StatusCode::BAD_REQUEST,
CreateError::ImageError(..) => StatusCode::BAD_REQUEST,
CreateError::RerouteError(..) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
@@ -146,7 +143,6 @@ impl actix_web::ResponseError for CreateError {
CreateError::ValidationError(..) => "invalid_input",
CreateError::FileValidationError(..) => "invalid_input",
CreateError::ImageError(..) => "invalid_image",
CreateError::RerouteError(..) => "reroute_error",
},
description: self.to_string(),
})

View File

@@ -128,14 +128,33 @@ pub async fn users_get(
}
pub async fn user_get(
req: HttpRequest,
info: web::Path<(String,)>,
pool: web::Data<PgPool>,
redis: web::Data<RedisPool>,
session_queue: web::Data<AuthQueue>,
) -> Result<HttpResponse, ApiError> {
let user_data = User::get(&info.into_inner().0, &**pool, &redis).await?;
if let Some(data) = user_data {
let response: crate::models::users::User = data.into();
let auth_user = get_user_from_headers(
&req,
&**pool,
&redis,
&session_queue,
Some(&[Scopes::SESSION_ACCESS]),
)
.await
.map(|x| x.1)
.ok();
let response: crate::models::users::User =
if auth_user.map(|x| x.role.is_admin()).unwrap_or(false) {
crate::models::users::User::from_full(data)
} else {
data.into()
};
Ok(HttpResponse::Ok().json(response))
} else {
Err(ApiError::NotFound)

View File

@@ -985,7 +985,7 @@ pub async fn upload_file(
let client = reqwest::Client::new();
let delphi_url = dotenvy::var("DELPHI_URL")?;
let res = client
match client
.post(delphi_url)
.json(&serde_json::json!({
"url": url,
@@ -993,10 +993,16 @@ pub async fn upload_file(
"version_id": version_id,
}))
.send()
.await?;
if !res.status().is_success() {
error!("Failed to upload file to Delphi: {url}");
.await
{
Ok(res) => {
if !res.status().is_success() {
error!("Failed to upload file to Delphi: {url}");
}
}
Err(e) => {
error!("Failed to upload file to Delphi: {url}: {e}");
}
}
version_files.push(VersionFileBuilder {

View File

@@ -505,7 +505,11 @@ async fn index_versions(
version_id: VersionId(m.version_id),
field_id: LoaderFieldId(m.field_id),
int_value: m.int_value,
enum_value: m.enum_value.map(LoaderFieldEnumValueId),
enum_value: if m.enum_value == -1 {
None
} else {
Some(LoaderFieldEnumValueId(m.enum_value))
},
string_value: m.string_value,
};

View File

@@ -8,5 +8,5 @@ pub fn admin_key_guard(ctx: &GuardContext) -> bool {
ctx.head()
.headers()
.get(ADMIN_KEY_HEADER)
.map_or(false, |it| it.as_bytes() == admin_key.as_bytes())
.is_some_and(|it| it.as_bytes() == admin_key.as_bytes())
}

View File

@@ -180,9 +180,8 @@ pub async fn import_mmc(
instance_folder: String, // instance folder in mmc_base_path
profile_path: &str, // path to profile
) -> crate::Result<()> {
let mmc_instance_path = mmc_base_path
.join("instances")
.join(instance_folder.clone());
let mmc_instance_path =
mmc_base_path.join("instances").join(instance_folder);
let mmc_pack =
io::read_to_string(&mmc_instance_path.join("mmc-pack.json")).await?;
@@ -209,9 +208,18 @@ pub async fn import_mmc(
profile_path: profile_path.to_string(),
};
// Managed pack
let backup_name = "Imported Modpack".to_string();
let mut minecraft_folder = mmc_instance_path.join("minecraft");
if !minecraft_folder.is_dir() {
minecraft_folder = mmc_instance_path.join(".minecraft");
if !minecraft_folder.is_dir() {
return Err(crate::ErrorKind::InputError(
"Instance is missing Minecraft directory".to_string(),
)
.into());
}
}
// Managed pack
if instance_cfg.managed_pack.unwrap_or(false) {
match instance_cfg.managed_pack_type {
Some(MMCManagedPackType::Modrinth) => {
@@ -220,38 +228,26 @@ pub async fn import_mmc(
// Modrinth Managed Pack
// Kept separate as we may in the future want to add special handling for modrinth managed packs
let backup_name = "Imported Modrinth Modpack".to_string();
let minecraft_folder = mmc_base_path.join("instances").join(instance_folder).join(".minecraft");
import_mmc_unmanaged(profile_path, minecraft_folder, backup_name, description, mmc_pack).await?;
import_mmc_unmanaged(profile_path, minecraft_folder, "Imported Modrinth Modpack".to_string(), description, mmc_pack).await?;
}
Some(MMCManagedPackType::Flame) | Some(MMCManagedPackType::ATLauncher) => {
// For flame/atlauncher managed packs
// Treat as unmanaged, but with 'minecraft' folder instead of '.minecraft'
let minecraft_folder = mmc_base_path.join("instances").join(instance_folder).join("minecraft");
import_mmc_unmanaged(profile_path, minecraft_folder, backup_name, description, mmc_pack).await?;
import_mmc_unmanaged(profile_path, minecraft_folder, "Imported Modpack".to_string(), description, mmc_pack).await?;
},
Some(_) => {
// For managed packs that aren't modrinth, flame, atlauncher
// Treat as unmanaged
let backup_name = "ImportedModpack".to_string();
let minecraft_folder = mmc_base_path.join("instances").join(instance_folder).join(".minecraft");
import_mmc_unmanaged(profile_path, minecraft_folder, backup_name, description, mmc_pack).await?;
import_mmc_unmanaged(profile_path, minecraft_folder, "ImportedModpack".to_string(), description, mmc_pack).await?;
},
_ => return Err(crate::ErrorKind::InputError({
"Instance is managed, but managed pack type not specified in instance.cfg".to_string()
}).into())
_ => return Err(crate::ErrorKind::InputError("Instance is managed, but managed pack type not specified in instance.cfg".to_string()).into())
}
} else {
// Direclty import unmanaged pack
let backup_name = "Imported Modpack".to_string();
let minecraft_folder = mmc_base_path
.join("instances")
.join(instance_folder)
.join(".minecraft");
import_mmc_unmanaged(
profile_path,
minecraft_folder,
backup_name,
"Imported Modpack".to_string(),
description,
mmc_pack,
)

View File

@@ -9,7 +9,7 @@ use crate::pack::install_from::{
};
use crate::state::{
CacheBehaviour, CachedEntry, Credentials, JavaVersion, ProcessMetadata,
ProfileFile, ProjectType, SideType,
ProfileFile, ProfileInstallStage, ProjectType, SideType,
};
use crate::event::{emit::emit_profile, ProfilePayloadType};
@@ -225,7 +225,18 @@ pub async fn list() -> crate::Result<Vec<Profile>> {
#[tracing::instrument]
pub async fn install(path: &str, force: bool) -> crate::Result<()> {
if let Some(profile) = get(path).await? {
crate::launcher::install_minecraft(&profile, None, force).await?;
let result =
crate::launcher::install_minecraft(&profile, None, force).await;
if result.is_err()
&& profile.install_stage != ProfileInstallStage::Installed
{
edit(path, |prof| {
prof.install_stage = ProfileInstallStage::NotInstalled;
async { Ok(()) }
})
.await?;
}
result?;
} else {
return Err(crate::ErrorKind::UnmanagedProfileError(path.to_string())
.as_error());

View File

@@ -111,7 +111,7 @@ async fn replace_managed_modrinth(
ignore_lock: bool,
) -> crate::Result<()> {
crate::profile::edit(profile_path, |profile| {
profile.install_stage = ProfileInstallStage::Installing;
profile.install_stage = ProfileInstallStage::MinecraftInstalling;
async { Ok(()) }
})
.await?;

View File

@@ -7,11 +7,7 @@ use crate::state::{
Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage,
};
use crate::util::io;
use crate::{
process,
state::{self as st},
State,
};
use crate::{process, state as st, State};
use chrono::Utc;
use daedalus as d;
use daedalus::minecraft::{RuleAction, VersionInfo};
@@ -199,7 +195,7 @@ pub async fn install_minecraft(
.await?;
crate::api::profile::edit(&profile.path, |prof| {
prof.install_stage = ProfileInstallStage::Installing;
prof.install_stage = ProfileInstallStage::MinecraftInstalling;
async { Ok(()) }
})
@@ -431,7 +427,7 @@ pub async fn launch_minecraft(
profile: &Profile,
) -> crate::Result<ProcessMetadata> {
if profile.install_stage == ProfileInstallStage::PackInstalling
|| profile.install_stage == ProfileInstallStage::Installing
|| profile.install_stage == ProfileInstallStage::MinecraftInstalling
{
return Err(crate::ErrorKind::LauncherError(
"Profile is still installing".to_string(),

View File

@@ -308,7 +308,7 @@ where
ProfileInstallStage::Installed
}
LegacyProfileInstallStage::Installing => {
ProfileInstallStage::Installing
ProfileInstallStage::MinecraftInstalling
}
LegacyProfileInstallStage::PackInstalling => {
ProfileInstallStage::PackInstalling

View File

@@ -178,19 +178,16 @@ pub async fn login_finish(
minecraft_entitlements(&minecraft_token.access_token).await?;
let profile = minecraft_profile(&minecraft_token.access_token).await?;
let profile_id = profile.id.unwrap_or_default();
let credentials = Credentials {
id: profile_id,
username: profile.name,
let mut credentials = Credentials {
id: Uuid::default(),
username: String::default(),
access_token: minecraft_token.access_token,
refresh_token: oauth_token.value.refresh_token,
expires: oauth_token.date
+ Duration::seconds(oauth_token.value.expires_in as i64),
active: true,
};
credentials.get_profile().await?;
credentials.upsert(exec).await?;
@@ -245,11 +242,22 @@ impl Credentials {
self.expires = oauth_token.date
+ Duration::seconds(oauth_token.value.expires_in as i64);
self.get_profile().await?;
self.upsert(exec).await?;
Ok(())
}
async fn get_profile(&mut self) -> crate::Result<()> {
let profile = minecraft_profile(&self.access_token).await?;
self.id = profile.id.unwrap_or_default();
self.username = profile.name;
Ok(())
}
#[tracing::instrument]
pub async fn get_default_credential(
exec: impl sqlx::Executor<'_, Database = sqlx::Sqlite> + Copy,

View File

@@ -53,7 +53,9 @@ pub enum ProfileInstallStage {
/// Profile is installed
Installed,
/// Profile's minecraft game is still installing
Installing,
MinecraftInstalling,
/// Pack is installed, but Minecraft installation has not begun
PackInstalled,
/// Profile created for pack, but the pack hasn't been fully installed yet
PackInstalling,
/// Profile is not installed
@@ -64,7 +66,8 @@ impl ProfileInstallStage {
pub fn as_str(&self) -> &'static str {
match *self {
Self::Installed => "installed",
Self::Installing => "installing",
Self::MinecraftInstalling => "minecraft_installing",
Self::PackInstalled => "pack_installed",
Self::PackInstalling => "pack_installing",
Self::NotInstalled => "not_installed",
}
@@ -73,7 +76,9 @@ impl ProfileInstallStage {
pub fn from_str(val: &str) -> Self {
match val {
"installed" => Self::Installed,
"installing" => Self::Installing,
"minecraft_installing" => Self::MinecraftInstalling,
"installing" => Self::MinecraftInstalling, // Backwards compatibility
"pack_installed" => Self::PackInstalled,
"pack_installing" => Self::PackInstalling,
"not_installed" => Self::NotInstalled,
_ => Self::NotInstalled,
@@ -549,11 +554,11 @@ impl Profile {
pub(crate) async fn refresh_all() -> crate::Result<()> {
let state = crate::State::get().await?;
let all = Self::get_all(&state.pool).await?;
let mut all = Self::get_all(&state.pool).await?;
let mut keys = vec![];
for profile in &all {
for profile in &mut all {
let path =
crate::api::profile::get_full_path(&profile.path).await?;
@@ -586,6 +591,17 @@ impl Profile {
}
}
}
if profile.install_stage == ProfileInstallStage::MinecraftInstalling
{
profile.install_stage = ProfileInstallStage::PackInstalled;
profile.upsert(&state.pool).await?;
} else if profile.install_stage
== ProfileInstallStage::PackInstalling
{
profile.install_stage = ProfileInstallStage::NotInstalled;
profile.upsert(&state.pool).await?;
}
}
let file_hashes = CachedEntry::get_file_hash_many(

View File

@@ -208,23 +208,6 @@ html {
}
.experimental-styles-within {
// Reset deprecated properties
--color-icon: initial !important;
--color-text: initial !important;
--color-text-inactive: initial !important;
--color-text-dark: initial !important;
--color-heading: initial !important;
--color-text-inverted: initial !important;
--color-bg-inverted: initial !important;
--color-brand: var(--color-green) !important;
--color-brand-inverted: initial !important;
--tab-underline-hovered: initial !important;
--color-grey-link: inherit !important;
--color-grey-link-hover: inherit !important; // DEPRECATED, use filters in future
--color-grey-link-active: inherit !important; // DEPRECATED, use filters in future
--color-link: var(--color-blue) !important;
--color-link-hover: var(--color-blue) !important; // DEPRECATED, use filters in future
--color-link-active: var(--color-blue) !important; // DEPRECATED, use filters in future

View File

@@ -10,7 +10,7 @@
<template #menu>
<template v-for="(option, index) in options.filter((x) => x.shown === undefined || x.shown)">
<div
v-if="option.divider"
v-if="isDivider(option)"
:key="`divider-${index}`"
class="h-px mx-3 my-2 bg-button-bg"
></div>
@@ -25,15 +25,15 @@
:v-close-popper="!option.remainOnClick"
:action="
option.action
? (event) => {
option.action(event)
? (event: MouseEvent) => {
option.action?.(event)
if (!option.remainOnClick) {
close()
}
}
: null
: undefined
"
:link="option.link ? option.link : null"
:link="option.link ? option.link : undefined"
:external="option.external ? option.external : false"
:disabled="option.disabled"
@click="
@@ -67,7 +67,7 @@ interface Divider extends BaseOption {
interface Item extends BaseOption {
id: string
action?: () => void
action?: (event?: MouseEvent) => void
link?: string
external?: boolean
color?:
@@ -99,8 +99,8 @@ withDefaults(
{
options: () => [],
disabled: false,
dropdownId: null,
tooltip: null,
dropdownId: undefined,
tooltip: undefined,
},
)
@@ -118,6 +118,10 @@ const open = () => {
dropdown.value?.show()
}
function isDivider(option: BaseOption): option is Divider {
return 'divider' in option
}
defineExpose({ open, close })
</script>

View File

@@ -41,9 +41,12 @@
<input
ref="value"
:value="currentValue"
type="text"
type="number"
class="slider-input"
:disabled="disabled"
:min="min"
:max="max"
:step="step"
@change="onInput(($event.target as HTMLInputElement).value)"
/>
</div>

View File

@@ -70,7 +70,7 @@ const props = withDefaults(
closeOnClickOutside: true,
closeOnEsc: true,
warnOnClose: false,
header: null,
header: undefined,
onHide: () => {},
onShow: () => {},
},

View File

@@ -37,7 +37,11 @@
<div v-if="project.categories.length > 0" class="hidden items-center gap-2 md:flex">
<TagsIcon class="h-6 w-6 text-secondary" />
<div class="flex flex-wrap gap-2">
<TagItem v-for="(category, index) in project.categories" :key="index">
<TagItem
v-for="(category, index) in project.categories"
:key="index"
:action="() => router.push(`/${project.project_type}s?f=categories:${category}`)"
>
{{ formatCategory(category) }}
</TagItem>
</div>
@@ -53,9 +57,12 @@ import { DownloadIcon, HeartIcon, TagsIcon } from '@modrinth/assets'
import Avatar from '../base/Avatar.vue'
import ContentPageHeader from '../base/ContentPageHeader.vue'
import { formatCategory, formatNumber, type Project } from '@modrinth/utils'
import { useRouter } from 'vue-router'
import TagItem from '../base/TagItem.vue'
import ProjectStatusBadge from './ProjectStatusBadge.vue'
const router = useRouter()
withDefaults(
defineProps<{
project: Project

View File

@@ -18,6 +18,7 @@
<TagItem
v-for="platform in project.loaders"
:key="`platform-tag-${platform}`"
:action="() => router.push(`/${project.project_type}s?g=categories:${platform}`)"
:style="`--_color: var(--color-platform-${platform})`"
>
<svg v-html="tags.loaders.find((x) => x.name === platform).icon"></svg>
@@ -78,9 +79,11 @@ import { ClientIcon, MonitorSmartphoneIcon, ServerIcon, UserIcon } from '@modrin
import { formatCategory, getVersionsToDisplay } from '@modrinth/utils'
import type { GameVersionTag, PlatformTag } from '@modrinth/utils'
import { useVIntl, defineMessages } from '@vintl/vintl'
import { useRouter } from 'vue-router'
import TagItem from '../base/TagItem.vue'
const { formatMessage } = useVIntl()
const router = useRouter()
type EnvironmentValue = 'optional' | 'required' | 'unsupported' | 'unknown'