feat:移除了弹窗,服务器添加sls

This commit is contained in:
2026-09-08 22:39:45 +08:00
commit 6a295f9a7a
4082 changed files with 1322534 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,360 @@
<template>
<canvas id="about_scene" class="size-full" />
</template>
<script setup lang="ts">
import * as THREE from 'three'
import { type GLTF, GLTFLoader } from 'three/examples/jsm/Addons.js'
import { onMounted, onScopeDispose, useTemplateRef } from 'vue'
import { useTheming } from '@/store/theme'
const themeStore = useTheming()
function isDarkMode() {
if (themeStore.selectedTheme == 'system') {
return matchMedia('(prefers-color-scheme: dark)').matches
}
return ['dark', 'oled'].includes(themeStore.selectedTheme)
}
function loadGLTF(url: string): Promise<GLTF> {
return new Promise((res, rej) => {
const loader = new GLTFLoader()
loader.load(
url,
(data) => {
res(data)
},
undefined,
rej,
)
})
}
function createTip(position: THREE.Vector3, color: THREE.ColorRepresentation = 0x00ff00) {
const tipGeometry = new THREE.SphereGeometry(2)
const tipMaterial = new THREE.MeshBasicMaterial({ color })
const tipMesh = new THREE.Mesh(tipGeometry, tipMaterial)
tipMesh.position.copy(position)
return tipMesh
}
function createWaterMaterial(): THREE.ShaderMaterial {
return new THREE.ShaderMaterial({
uniforms: {
time: { value: 0 },
seed: { value: Math.random() * 83 + 17 },
color: { value: new THREE.Color(0.3, 0.3, 1.0) },
},
transparent: true,
vertexShader: `#define WATER_VERT
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `#define WATER_FRAG
uniform float time;
uniform float seed;
uniform vec3 color;
varying vec2 vUv;
vec2 randomGradient(vec2 p) {
float n = sin(dot(p, vec2(127.1, 311.7)));
float angle = fract(n * 43758.5453123) * 6.28318530718 * seed;
return vec2(cos(angle), sin(angle));
}
float perlinNoise(vec2 p) {
vec2 i = floor(p);
vec2 f = fract(p);
vec2 u = f * f * (3.0 - 2.0 * f);
vec2 g1 = randomGradient(i);
vec2 g2 = randomGradient(i + vec2(1.0, 0.0));
vec2 g3 = randomGradient(i + vec2(0.0, 1.0));
vec2 g4 = randomGradient(i + vec2(1.0, 1.0));
vec2 d1 = f;
vec2 d2 = f - vec2(1.0, 0.0);
vec2 d3 = f - vec2(0.0, 1.0);
vec2 d4 = f - vec2(1.0, 1.0);
float v1 = dot(g1, d1);
float v2 = dot(g2, d2);
float v3 = dot(g3, d3);
float v4 = dot(g4, d4);
return mix(mix(v1, v2, u.x), mix(v3, v4, u.x), u.y);
}
void main() {
float height = 0.0;
height += perlinNoise(vec2(vUv.x * 10.0, time * 0.8)) * 0.3;
height += perlinNoise(vec2(vUv.x * 5.0, time * 0.4)) * 0.35;
height += perlinNoise(vec2(vUv.x * 2.5, time * 0.2)) * 0.15;
height += perlinNoise(vec2(vUv.x * 2.0, time * 0.2)) * 0.2;
height = clamp(height, -1.0, 1.0);
height = height * 0.8 + 0.6;
float thickness = 0.008;
if(vUv.y < height - thickness) {
float scalar = 1.0 - height + vUv.y;
scalar = scalar * scalar * scalar * 0.6;
gl_FragColor = vec4(color, scalar);
} else if(vUv.y > height + thickness) {
gl_FragColor = vec4(0.0, 0.0, 0.0, 0.0);
} else {
gl_FragColor = vec4(color, 1.0);
}
}`,
})
}
function createWater(material: THREE.ShaderMaterial, position: THREE.Vector3) {
const geometry = new THREE.PlaneGeometry(120, 16)
const waterMesh = new THREE.Mesh(geometry, material)
waterMesh.position.copy(position)
return waterMesh
}
function createCircleMaterial(): THREE.ShaderMaterial {
return new THREE.ShaderMaterial({
uniforms: {
color: { value: new THREE.Color(0.3, 0.3, 1.0) },
},
transparent: true,
vertexShader: `#define CIRCLE_VERT
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`,
fragmentShader: `#define CIRCLE_FRAG
varying vec2 vUv;
uniform vec3 color;
float remap(float v, float inMin, float inMax, float outMin, float outMax) {
float t = (v - inMin) / (inMax - inMin);
return outMin + (outMax - outMin) * t;
}
void main() {
float dis = distance(vUv, vec2(0.5));
float thickness = 0.05;
gl_FragColor = vec4(0.0);
if(dis <= 0.35 && dis >= 0.35 - thickness) {
gl_FragColor = vec4(color, 0.8);
} else {
// emissive
float scalar = 0.0;
if(dis >= 0.35) {
scalar = clamp(0.5 - dis, 0.0, 0.15);
scalar = remap(scalar, 0.0, 0.15, 0.0, 1.0);
} else {
scalar = clamp(0.35 - dis, 0.0, 0.5);
scalar = remap(scalar, 0.0, 0.35, 1.0, 0.0);
}
scalar = clamp(scalar * scalar * scalar, 0.0, 1.0);
gl_FragColor = vec4(color, scalar);
}
}`,
})
}
function createCircle(material: THREE.ShaderMaterial, position: THREE.Vector3) {
const geometry = new THREE.PlaneGeometry(0.6, 0.6)
const mesh = new THREE.Mesh(geometry, material)
mesh.position.copy(position)
return mesh
}
function main() {
const canvas = document.querySelector<HTMLCanvasElement>('#about_scene')
if (!canvas) return console.error('No canvas')
let isUpdating = true
const canvasSize = new THREE.Vector2(
canvas.getBoundingClientRect().width,
canvas.getBoundingClientRect().height,
)
const renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: true,
canvas,
})
renderer.setPixelRatio(devicePixelRatio)
renderer.setSize(canvasSize.x, canvasSize.y)
const deltaClock = new THREE.Clock()
const elapseClock = new THREE.Clock()
deltaClock.start()
elapseClock.start()
const scene = new THREE.Scene()
const camera = new THREE.PerspectiveCamera(30, canvasSize.x / canvasSize.y, 1, 3000)
camera.fov *= 0.7
camera.position.set(-10, 5, 30)
camera.lookAt(0, 0, 0)
const ambientLight = new THREE.AmbientLight(0xffffff)
scene.add(ambientLight)
const dirLight = new THREE.DirectionalLight(0xffffff, 4.0)
dirLight.position.set(-30, 30, 28)
scene.add(dirLight)
scene.add(createTip(dirLight.position, 0xffff00))
scene.add(createTip(camera.position))
const accentColor =
getComputedStyle(document.documentElement).getPropertyValue('--color-brand').trim() || '#4444ff'
const waterMaterial = createWaterMaterial()
waterMaterial.uniforms.color.value = new THREE.Color(accentColor).multiplyScalar(
isDarkMode() ? 0.6 : 2.4,
)
// .multiplyScalar(0.6)
// .multiplyScalar(2.4)
scene.add(createWater(waterMaterial, new THREE.Vector3(0, -6.5, 4)))
scene.add(createWater(waterMaterial, new THREE.Vector3(2, -8, -10)))
scene.add(createWater(waterMaterial, new THREE.Vector3(16, -8, -26)))
async function load() {
const axlGLTF = await loadGLTF('/models/axolotl.gltf')
const axlModel = axlGLTF.scene
axlModel.scale.multiplyScalar(5)
axlModel.rotateY(Math.PI / 2)
axlModel.position.add(new THREE.Vector3(0, -2.5, 0))
scene.add(axlModel)
const mixer = new THREE.AnimationMixer(axlModel)
const axlSwimAnim = axlGLTF.animations.filter((a) => a.name === 'swim')[0]
if (!axlSwimAnim) return console.error('Missing animation swim')
mixer.clipAction(axlSwimAnim).play()
// // Axl Label
// const axlLabelGLTF = await loadGLTF('/models/axl_label.glb')
// const axlLabel = axlLabelGLTF.scene
// axlLabel.scale.multiplyScalar(8)
// axlLabel.rotateY(-Math.PI / 2)
// axlLabel.position.set(0, 5.2, 0)
// scene.add(axlLabel)
const originAxlModelPosition = axlModel.position.clone()
return function (deltaTime: number, elapsedTime: number) {
axlModel.position.set(
originAxlModelPosition.x,
originAxlModelPosition.y + Math.sin(elapsedTime),
originAxlModelPosition.z,
)
axlModel.rotation.y = Math.sin(elapsedTime * 0.3) * 0.2 + (Math.PI * 100) / 180
mixer.update(deltaTime)
}
}
let updateGLTF = (_deltaTime: number, _elapsedTime: number) => {}
load().then((updateFn) => {
if (updateFn) updateGLTF = updateFn
})
const circleMaterial = createCircleMaterial()
circleMaterial.uniforms.color.value = new THREE.Color(accentColor).multiplyScalar(
isDarkMode() ? 1.2 : 3,
)
// .multiplyScalar(1.2)
// .multiplyScalar(3)
let circleMeshList: THREE.Mesh[] = []
let nextCircleCreateTime = 0.0
function updateCircle(deltaTime: number, elapsedTime: number) {
circleMeshList = circleMeshList.filter((m) => {
m.position.y += deltaTime * 2.0
if (m.position.y >= 32) {
scene.remove(m)
return false
}
return true
})
if (elapsedTime >= nextCircleCreateTime) {
nextCircleCreateTime = elapsedTime + Math.random() * 0.8
const circle = createCircle(
circleMaterial,
new THREE.Vector3(Math.random() * 64 - 32 - 12, -20, Math.random() * 6 + 1),
)
scene.add(circle)
circleMeshList.push(circle)
}
}
function animate(_time: number) {
if (isUpdating === false) return
requestAnimationFrame(animate)
const deltaTime = deltaClock.getDelta()
const elapsedTime = elapseClock.getElapsedTime()
updateGLTF(deltaTime, elapsedTime)
waterMaterial.uniforms.time.value = elapsedTime
updateCircle(deltaTime, elapsedTime)
renderer.render(scene, camera)
}
animate(Date.now())
const originCameraPosition = camera.position.clone()
function onMouseMove(event: MouseEvent) {
const mouseXOffsetRatio = ((event.clientX - innerWidth / 2) / innerWidth) * 2
const mouseYOffsetRatio = ((event.clientY - innerHeight / 2) / innerHeight) * 2
const newPosition = new THREE.Vector3(
originCameraPosition.x + mouseXOffsetRatio,
originCameraPosition.y + mouseYOffsetRatio * 0.5,
originCameraPosition.z,
)
camera.position.copy(newPosition)
}
function updateSize() {
if (!isUpdating) return
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const w = rect.width
const h = rect.height
if (w > 0 && h > 0) {
renderer.setSize(w, h)
camera.aspect = w / h
camera.updateProjectionMatrix()
}
}
const resizeObserver = new ResizeObserver(updateSize)
resizeObserver.observe(canvas)
addEventListener('mousemove', onMouseMove)
onScopeDispose(() => {
isUpdating = false
removeEventListener('mousemove', onMouseMove)
resizeObserver.disconnect()
deltaClock.stop()
elapseClock.stop()
renderer.dispose()
})
}
onMounted(main)
</script>
<style>
#about_scene {
background: linear-gradient(
to bottom,
color-mix(in srgb, var(--color-brand) 36%, var(--surface-1) 100%),
#00000000 40%
);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,73 @@
<script setup lang="ts">
import { DropdownIcon, FolderOpenIcon, PlusIcon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
OverflowMenu,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { useRouter } from 'vue-router'
import { add_project_from_path } from '@/helpers/instance'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
installContent: { id: 'app.content.install-content', defaultMessage: 'Install content' },
addFromFile: { id: 'app.content.add-from-file', defaultMessage: 'Add from file' },
})
const props = defineProps({
instance: {
type: Object,
required: true,
},
})
const router = useRouter()
const handleAddContentFromFile = async () => {
const newProject = await open({ multiple: true })
if (!newProject) return
for (const project of newProject) {
await add_project_from_path(props.instance.id, project.path ?? project).catch(handleError)
}
}
const handleSearchContent = async () => {
await router.push({
path: `/browse/${props.instance.loader === 'vanilla' ? 'resourcepack' : 'mod'}`,
query: { i: props.instance.id },
})
}
</script>
<template>
<div class="joined-buttons">
<ButtonStyled>
<button @click="handleSearchContent">
<PlusIcon />
{{ formatMessage(messages.installContent) }}
</button>
</ButtonStyled>
<ButtonStyled>
<OverflowMenu
:options="[
{
id: 'from_file',
action: handleAddContentFromFile,
},
]"
>
<DropdownIcon />
<template #from_file>
<FolderOpenIcon />
<span class="whitespace-nowrap">{{ formatMessage(messages.addFromFile) }}</span>
</template>
</OverflowMenu>
</ButtonStyled>
</div>
</template>

View File

@ -0,0 +1,787 @@
<template>
<div class="flex gap-2 items-center">
<Dropdown
v-model:shown="notificationCenterShown"
placement="bottom-end"
:triggers="['click']"
:hide-triggers="['click']"
>
<ButtonStyled type="transparent" circular>
<button
v-tooltip="formatMessage(messages.notifications)"
:aria-label="formatMessage(messages.notifications)"
class="relative"
>
<BellIcon />
<span
v-if="hasUnreadNotifications"
class="absolute right-0 top-0 size-2 rounded-full bg-red ring-2 ring-bg-raised"
/>
</button>
</ButtonStyled>
<template #popper>
<div class="w-[22rem] max-w-[calc(100vw-2rem)] p-2">
<div class="mb-2 flex items-center justify-between px-2">
<span class="font-semibold text-contrast">{{
formatMessage(messages.notifications)
}}</span>
<button
v-if="notificationHistory.length"
class="text-xs text-secondary hover:text-contrast"
@click="clearNotificationHistory"
>
{{ formatMessage(messages.clearNotifications) }}
</button>
</div>
<div
v-if="!notificationHistory.length"
class="px-2 py-4 text-center text-sm text-secondary"
>
{{ formatMessage(messages.noNotifications) }}
</div>
<div v-else class="flex max-h-[22rem] flex-col gap-1 overflow-auto">
<div
v-for="item in notificationHistory"
:key="item.key"
class="flex items-start gap-2 rounded-lg p-2 hover:bg-button-bg"
>
<div
class="mt-1 size-2 shrink-0 rounded-full"
:class="notificationDotClass(item.type)"
/>
<button class="min-w-0 flex-1 text-left" @click="openNotification(item)">
<div class="truncate text-sm font-medium text-contrast">{{ item.title }}</div>
<div v-if="item.text" class="line-clamp-2 text-xs text-secondary">
{{ item.text }}
</div>
</button>
<button
v-tooltip="formatMessage(messages.dismissNotification)"
class="shrink-0 text-secondary hover:text-contrast"
@click="dismissNotification(item)"
>
<XIcon class="size-4" />
</button>
</div>
</div>
</div>
</template>
</Dropdown>
<ButtonStyled
v-if="!isDownloadsPage && hasActiveDownloads && !hasVisibleActiveDownloadToasts"
color="brand"
type="transparent"
circular
>
<button v-tooltip="formatMessage(messages.viewActiveDownloads)" @click="goToDownloads">
<DownloadIcon />
</button>
</ButtonStyled>
<div v-if="offline" class="flex items-center gap-1">
<UnplugIcon class="text-secondary" />
<span class="text-sm text-contrast"> {{ formatMessage(messages.offline) }} </span>
</div>
<AppUpdateButton />
<div
class="flex border-solid border-surface-5 text-sm items-center gap-2 py-1.5 px-3 rounded-xl border"
>
<template v-if="selectedProcess">
<OnlineIndicatorIcon />
<div class="text-contrast flex items-center gap-2">
<router-link
v-tooltip="formatMessage(messages.viewInstance)"
:to="`/instance/${encodeURIComponent(selectedProcess.instance.id)}`"
class="hover:underline"
>
{{ selectedProcess.instance.name }}
</router-link>
<Dropdown
v-if="currentProcesses.length > 1"
placement="bottom"
:triggers="['click']"
:hide-triggers="['click']"
@show="showInstances = true"
@hide="showInstances = false"
>
<ButtonStyled type="transparent" circular size="small">
<button
v-tooltip="
showInstances
? formatMessage(messages.hideMoreRunningInstances)
: formatMessage(messages.showMoreRunningInstances)
"
>
<DropdownIcon :class="{ 'rotate-180': !!showInstances }" />
</button>
</ButtonStyled>
<template #popper>
<div class="flex w-[20rem] max-h-[24rem] flex-col gap-2 overflow-auto">
<div
v-for="process in currentProcesses"
:key="process.uuid"
class="flex w-full items-center gap-2 rounded-xl bg-surface-4 p-2 text-sm"
>
<button
v-tooltip.left="
process.uuid === selectedProcess.uuid
? formatMessage(messages.primaryInstance)
: formatMessage(messages.makePrimaryInstance)
"
class="flex flex-grow items-center gap-2"
:class="{
'active:scale-95 transition-transform': process.uuid !== selectedProcess.uuid,
}"
:disabled="process.uuid === selectedProcess.uuid"
@click="selectProcess(process)"
>
<OnlineIndicatorIcon />
<span class="mr-auto text-contrast flex items-center gap-2">
{{ process.instance.name }}
<StarIcon v-if="process.uuid === selectedProcess.uuid" class="text-orange" />
</span>
</button>
<button
v-tooltip="formatMessage(messages.stopInstance)"
class="active:scale-95 flex"
@click.stop="stop(process)"
>
<StopCircleIcon class="text-red size-5" />
</button>
<button
v-tooltip="formatMessage(messages.viewLogs)"
class="active:scale-95 flex"
@click.stop="goToTerminal(process.instance.id)"
>
<TerminalSquareIcon class="text-secondary size-5" />
</button>
</div>
</div>
</template>
</Dropdown>
</div>
<button
v-tooltip="formatMessage(messages.stopInstance)"
class="active:scale-95 flex"
@click="stop(selectedProcess)"
>
<StopCircleIcon class="text-red size-5" />
</button>
<button
v-tooltip="formatMessage(messages.viewLogs)"
class="active:scale-95 flex"
@click="goToTerminal()"
>
<TerminalSquareIcon class="text-secondary size-5" />
</button>
</template>
<template v-else>
<span class="size-2 rounded-full bg-secondary" />
<span class="text-secondary"> {{ formatMessage(messages.noInstancesRunning) }} </span>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import {
BellIcon,
DownloadIcon,
DropdownIcon,
OnlineIndicatorIcon,
StarIcon,
StopCircleIcon,
TerminalSquareIcon,
UnplugIcon,
XIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
injectPopupNotificationManager,
type PopupNotification,
type PopupNotificationProgressItem,
useVIntl,
type WebNotification,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { Dropdown } from 'floating-vue'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import AppUpdateButton from '@/components/ui/app-update-button/index.vue'
import { useInstallJobNotifications } from '@/composables/browse/install-job-notifications'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { trackEvent } from '@/helpers/analytics'
import { loading_listener, process_listener } from '@/helpers/events'
import { get_many as getInstances } from '@/helpers/instance'
import { get_all as getRunningProcesses, kill as killProcess } from '@/helpers/process'
import type { LoadingBar } from '@/helpers/state'
import { progress_bars_list } from '@/helpers/state'
import type { GameInstance } from '@/helpers/types'
import { downloadBarTypes, injectDownloadManager } from '@/providers/download-manager'
const notificationManager = injectNotificationManager()
const { handleError } = notificationManager
const popupNotificationManager = injectPopupNotificationManager()
const downloadManager = injectDownloadManager()
const { formatMessage } = useVIntl()
type NotificationHistoryItem = {
key: string
createdAt?: number
title: string
text?: string
type?: 'error' | 'warning' | 'success' | 'info' | 'download'
collapsed?: boolean
expand: () => void
dismiss: () => void
}
const notificationHistory = computed<NotificationHistoryItem[]>(() =>
[
...notificationManager.getNotifications().map((item: WebNotification) => ({
key: `web-${item.id}`,
createdAt: item.createdAt,
title: item.title ?? formatMessage(messages.notifications),
text: item.text,
type: item.type,
collapsed: item.collapsed,
expand: () => notificationManager.expandNotification(item.id),
dismiss: () => notificationManager.removeNotification(item.id),
})),
...popupNotificationManager.getNotifications().map((item: PopupNotification) => ({
key: `popup-${item.id}`,
createdAt: item.createdAt,
title: item.title,
text:
item.text ??
(item.progressItems
?.filter((progressItem) => progressItem.text)
.map((progressItem) => `${progressItem.title}: ${progressItem.text}`)
.join('\n') ||
undefined),
type: item.type,
collapsed: item.collapsed,
expand: () => popupNotificationManager.expandNotification(item.id),
dismiss: () => popupNotificationManager.removeNotification(item.id),
})),
].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)),
)
const hasUnreadNotifications = computed(() =>
notificationHistory.value.some(
(item) => !item.collapsed && ['error', 'warning'].includes(item.type ?? ''),
),
)
function notificationDotClass(type?: NotificationHistoryItem['type']): string {
if (type === 'error') return 'bg-red'
if (type === 'warning') return 'bg-orange'
if (type === 'success') return 'bg-green'
if (type === 'download') return 'bg-green'
return 'bg-blue'
}
function dismissNotification(item: NotificationHistoryItem) {
item.dismiss()
}
async function openNotification(item: NotificationHistoryItem) {
item.expand()
notificationCenterShown.value = false
}
function clearNotificationHistory() {
notificationManager.clearAllNotifications()
popupNotificationManager.clearAllNotifications()
}
const router = useRouter()
const route = useRoute()
const isDownloadsPage = computed(
() => route.path === '/downloads' || route.path.startsWith('/downloads/'),
)
const showInstances = ref(false)
const notificationCenterShown = ref(false)
interface RunningProcess {
uuid: string
instance_id: string
instance: GameInstance
}
interface LoadingEventPayload {
event: LoadingBar['bar_type']
loader_uuid: string
fraction: number | null
message: string
}
const messages = defineMessages({
offline: {
id: 'app.action-bar.offline',
defaultMessage: 'Offline',
},
viewInstance: {
id: 'app.action-bar.view-instance',
defaultMessage: 'View instance',
},
showMoreRunningInstances: {
id: 'app.action-bar.show-more-running-instances',
defaultMessage: 'Show more running instances',
},
hideMoreRunningInstances: {
id: 'app.action-bar.hide-more-running-instances',
defaultMessage: 'Hide more running instances',
},
primaryInstance: {
id: 'app.action-bar.primary-instance',
defaultMessage: 'Primary instance',
},
makePrimaryInstance: {
id: 'app.action-bar.make-primary-instance',
defaultMessage: 'Make primary instance',
},
stopInstance: {
id: 'app.action-bar.stop-instance',
defaultMessage: 'Stop instance',
},
viewLogs: {
id: 'app.action-bar.view-logs',
defaultMessage: 'View logs',
},
noInstancesRunning: {
id: 'app.action-bar.no-instances-running',
defaultMessage: 'No instances running',
},
notifications: {
id: 'app.action-bar.notifications',
defaultMessage: 'Notifications',
},
clearNotifications: {
id: 'app.action-bar.notifications.clear',
defaultMessage: 'Clear all',
},
noNotifications: {
id: 'app.action-bar.notifications.empty',
defaultMessage: 'No notifications',
},
dismissNotification: {
id: 'app.action-bar.notifications.dismiss',
defaultMessage: 'Dismiss notification',
},
downloadingJava: {
id: 'app.action-bar.downloading-java',
defaultMessage: 'Downloading Java {version}',
},
downloadingModpack: {
id: 'app.downloads.phase.downloading-pack-file',
defaultMessage: 'Downloading modpack',
},
downloads: {
id: 'app.action-bar.downloads',
defaultMessage: 'Downloads',
},
viewActiveDownloads: {
id: 'app.action-bar.view-active-downloads',
defaultMessage: 'View active downloads',
},
exportingModpack: {
id: 'app.action-bar.exporting-modpack',
defaultMessage: 'Exporting modpack',
},
})
const currentProcesses = ref<RunningProcess[]>([])
const selectedProcess = ref<RunningProcess | undefined>()
const refresh = async () => {
const processes = ((await getRunningProcesses().catch((error) => {
handleError(error)
return []
})) ?? []) as Array<{ uuid: string; instance_id: string }>
const instanceIds = processes.map((process) => process.instance_id)
const instances: GameInstance[] = await getInstances(instanceIds).catch((error) => {
handleError(error)
return []
})
currentProcesses.value = processes
.map((process) => {
const instance = instances.find((item) => process.instance_id === item.id)
if (!instance) {
return null
}
return {
...process,
instance,
}
})
.filter((process): process is RunningProcess => process !== null)
if (!selectedProcess.value || !currentProcesses.value.includes(selectedProcess.value)) {
selectedProcess.value = currentProcesses.value[0]
}
}
await refresh()
const { offline } = useNetworkStatus()
const unlistenProcess = await process_listener(async () => {
await refresh()
})
const stop = async (process: RunningProcess) => {
try {
await killProcess(process.uuid).catch(handleError)
trackEvent('InstanceStop', {
loader: process.instance.loader,
game_version: process.instance.game_version,
source: 'AppBar',
})
} catch (e) {
console.error(e)
}
await refresh()
}
function goToTerminal(instanceId?: string) {
const selectedInstanceId = instanceId ?? selectedProcess.value?.instance.id
if (!selectedInstanceId) {
return
}
router.push(`/instance/${encodeURIComponent(selectedInstanceId)}/logs`)
}
const currentLoadingBars = ref<LoadingBar[]>([])
const currentLoadingBarIconUrls = ref<Record<string, string | null>>({})
const notificationId = ref<string | number | null>(null)
const dismissed = ref(false)
function getLoadingBarKey(loadingBar: LoadingBar): string {
return `${loadingBar.loading_bar_uuid ?? loadingBar.id}`
}
function getLoadingProgress(loadingBar: LoadingBar): number {
if (!loadingBar.total || loadingBar.total <= 0) {
return 0
}
return Math.max(0, Math.min(1, (loadingBar.current ?? 0) / (loadingBar.total ?? 0)))
}
function getLoadingText(loadingBar: LoadingBar): string {
return loadingBar.message ?? ''
}
function getDisplayIconUrl(icon: string | null | undefined): string | null {
if (!icon) {
return null
}
if (/^(https?:|data:|blob:|asset:|tauri:)/.test(icon)) {
return icon
}
return convertFileSrc(icon)
}
function getNotification(): PopupNotification | null {
if (!notificationId.value) {
return null
}
const notification = popupNotificationManager
.getNotifications()
.find((notification) => notification.id === notificationId.value)
return notification ?? null
}
function collapseNotification(): void {
if (!notificationId.value) {
return
}
popupNotificationManager.collapseNotification(notificationId.value)
}
function removeNotification(): void {
if (!notificationId.value) {
return
}
popupNotificationManager.removeNotification(notificationId.value)
notificationId.value = null
}
function buildDownloadItems(): PopupNotificationProgressItem[] {
return [
...installJobNotifications.progressItems.value,
...currentLoadingBars.value.map((bar) => {
const isPackDownload = bar.bar_type?.type === 'pack_download'
return {
id: getLoadingBarKey(bar),
title: bar.title ?? '',
text: getLoadingText(bar),
iconUrl: currentLoadingBarIconUrls.value[getLoadingBarKey(bar)] ?? null,
progress: getLoadingProgress(bar),
waiting: !bar.total || bar.total <= 0,
// Pack downloads report file counts, so prefer count UI over raw percentage.
progressType: isPackDownload ? 'count' : 'percentage',
progressCurrent: bar.current,
progressTotal: bar.total,
}
}),
]
}
const hasVisibleActiveDownloadToasts = computed(() => {
const notification = getNotification()
return !!notification && !notification.collapsed
})
const hasActiveDownloads = computed(
() =>
installJobNotifications.active.value ||
currentLoadingBars.value.some((bar) => downloadBarTypes.has(bar.bar_type?.type ?? '')),
)
const hasDownloadNotificationItems = computed(
() => installJobNotifications.hasItems.value || currentLoadingBars.value.length > 0,
)
function updateNotification(resummon = false): void {
const shouldResummon = resummon && !isDownloadsPage.value
if (shouldResummon) {
dismissed.value = false
}
if (!hasDownloadNotificationItems.value) {
removeNotification()
dismissed.value = false
return
}
if (notificationId.value && !getNotification()) {
notificationId.value = null
dismissed.value = true
}
if (dismissed.value && !shouldResummon) {
return
}
let notif = getNotification()
if (notif?.collapsed && shouldResummon) {
notif.collapsed = false
}
const progressItems = buildDownloadItems()
if (notif) {
notif.title = installJobNotifications.hasItems.value
? installJobNotifications.title.value
: formatMessage(messages.downloads)
notif.text = undefined
notif.progressItems = progressItems
notif.buttons = installJobNotifications.buttons.value
notif.onClick = hasDownloadNotificationItems.value ? goToDownloads : undefined
notif.progress = undefined
notif.waiting = undefined
notif.autoCloseMs =
progressItems.length > 0 && progressItems.every((item) => item.showProgress === false)
? 30 * 1000
: null
if (!notif.collapsed) popupNotificationManager.setNotificationTimer(notif)
} else {
notif = popupNotificationManager.addPopupNotification({
title: installJobNotifications.hasItems.value
? installJobNotifications.title.value
: formatMessage(messages.downloads),
type: 'download',
autoCloseMs: null,
progressItems,
buttons: installJobNotifications.buttons.value,
onClick: hasDownloadNotificationItems.value ? goToDownloads : undefined,
})
notificationId.value = notif.id
if (isDownloadsPage.value) {
popupNotificationManager.collapseNotification(notif.id)
}
if (progressItems.length > 0 && progressItems.every((item) => item.showProgress === false)) {
notif.autoCloseMs = 30 * 1000
popupNotificationManager.setNotificationTimer(notif)
}
}
}
function formatLoadingBars(loadingBar: LoadingBar): LoadingBar {
const formatted = { ...loadingBar }
if (formatted.bar_type?.type === 'java_download') {
formatted.title = formatMessage(messages.downloadingJava, {
version: formatted.bar_type.version,
})
}
if (formatted.bar_type?.type === 'pack_file_download') {
formatted.message = formatMessage(messages.downloadingModpack)
}
if (formatted.bar_type?.instance_id) {
formatted.title = formatted.bar_type.instance_name ?? formatted.bar_type.instance_id
}
if (formatted.bar_type?.type === 'zip_extract') {
formatted.title = formatMessage(messages.exportingModpack)
}
if (formatted.bar_type?.pack_name) {
formatted.title = formatted.bar_type.pack_name
}
return formatted
}
function isVisibleLoadingBar(loadingBar: LoadingBar): boolean {
return (
loadingBar.bar_type?.type !== 'launcher_update' &&
[
'java_download',
'pack_file_download',
'pack_download',
'minecraft_download',
'copy_instance',
'zip_extract',
].includes(loadingBar.bar_type?.type ?? '')
)
}
function applyLoadingEvent(payload: LoadingEventPayload): boolean {
const key = payload.loader_uuid
const index = currentLoadingBars.value.findIndex((bar) => getLoadingBarKey(bar) === key)
if (payload.fraction === null) {
if (index >= 0) {
currentLoadingBars.value.splice(index, 1)
const { [key]: _removedIcon, ...remainingIcons } = currentLoadingBarIconUrls.value
currentLoadingBarIconUrls.value = remainingIcons
}
return false
}
const loadingBar = formatLoadingBars({
loading_bar_uuid: payload.loader_uuid,
message: payload.message,
current: payload.fraction,
total: 1,
bar_type: payload.event,
})
if (!isVisibleLoadingBar(loadingBar)) return false
if (index >= 0) {
currentLoadingBars.value.splice(index, 1, loadingBar)
} else {
currentLoadingBars.value.push(loadingBar)
}
currentLoadingBarIconUrls.value[key] = getDisplayIconUrl(payload.event?.icon)
return index < 0
}
async function refreshLoadingBars() {
const bars: Record<string, LoadingBar> = await progress_bars_list().catch((error) => {
handleError(error)
return {}
})
currentLoadingBars.value = Object.values(bars).map(formatLoadingBars).filter(isVisibleLoadingBar)
const instanceIds = Array.from(
new Set(
currentLoadingBars.value
.map((bar) => bar.bar_type?.instance_id)
.filter((instanceId): instanceId is string => !!instanceId),
),
)
const instances = instanceIds.length
? await getInstances(instanceIds).catch((error) => {
handleError(error)
return []
})
: []
const instanceIconUrls = new Map(
instances.map((instance) => [instance.id, getDisplayIconUrl(instance.icon_path)]),
)
currentLoadingBarIconUrls.value = Object.fromEntries(
currentLoadingBars.value.map((bar) => {
const barIconUrl = getDisplayIconUrl(bar.bar_type?.icon)
const instanceIconUrl = bar.bar_type?.instance_id
? instanceIconUrls.get(bar.bar_type.instance_id)
: null
return [getLoadingBarKey(bar), barIconUrl ?? instanceIconUrl ?? null]
}),
)
currentLoadingBars.value.sort((a, b) => {
const aKey = `${a.loading_bar_uuid ?? a.id ?? ''}`
const bKey = `${b.loading_bar_uuid ?? b.id ?? ''}`
return aKey.localeCompare(bKey)
})
updateNotification()
}
const installJobNotifications = await useInstallJobNotifications({
router,
manager: downloadManager,
handleError,
onChange: updateNotification,
})
await refreshLoadingBars()
let newBarDuringWindow = false
let loadingNotificationTimer: ReturnType<typeof setTimeout> | null = null
const unlistenLoading = await loading_listener((payload: LoadingEventPayload) => {
const isNewBar = applyLoadingEvent(payload)
if (isNewBar) {
newBarDuringWindow = true
}
if (loadingNotificationTimer !== null) {
return
}
loadingNotificationTimer = setTimeout(() => {
loadingNotificationTimer = null
if (newBarDuringWindow) {
newBarDuringWindow = false
if (isDownloadsPage.value) {
updateNotification()
} else {
removeNotification()
updateNotification(true)
}
} else {
updateNotification()
}
}, 250)
})
function goToDownloads() {
router.push('/downloads')
}
watch(
() => route.path,
() => {
if (isDownloadsPage.value) {
collapseNotification()
}
updateNotification()
},
)
function selectProcess(process: RunningProcess) {
selectedProcess.value = process
}
onBeforeUnmount(() => {
if (loadingNotificationTimer !== null) {
clearTimeout(loadingNotificationTimer)
loadingNotificationTimer = null
}
removeNotification()
dismissed.value = false
unlistenProcess()
unlistenLoading()
installJobNotifications.dispose()
})
</script>

View File

@ -0,0 +1,14 @@
<template>
<div aria-label="Axolotl" class="flex h-full items-center gap-2 font-extrabold text-contrast">
<img aria-hidden="true" class="aspect-square h-full object-contain" :src="axolotlVisual" />
<span v-if="!iconOnly" class="hidden text-sm tracking-wide xl:inline">Axolotl</span>
</div>
</template>
<script setup lang="ts">
import axolotlVisual from '@modrinth/assets/branding/axolotl.png'
defineProps<{
iconOnly?: boolean
}>()
</script>

View File

@ -0,0 +1,343 @@
<template>
<div
ref="outerRef"
data-tauri-drag-region
class="min-w-0 overflow-hidden pl-3"
:class="{ 'breadcrumb-fade-mask': isOverflowing }"
:style="isOverflowing ? { '--scroll-distance': `-${overflowAmount}px` } : undefined"
@mouseenter="onMouseEnter"
@mouseleave="onMouseLeave"
>
<div
ref="innerRef"
data-tauri-drag-region
class="flex w-fit items-center gap-1"
:class="{ 'breadcrumbs-scroll': isAnimating }"
@animationiteration="onAnimationIteration"
>
<template v-for="(breadcrumb, index) in breadcrumbs" :key="breadcrumb.name">
<router-link
v-if="breadcrumb.link"
:to="{
path: breadcrumb.link.replace('{id}', encodeURIComponent($route.params.id as string)),
query: breadcrumb.query,
}"
class="flex shrink-0 items-center gap-1 whitespace-nowrap text-primary"
>
<Avatar
v-if="resolveIconUrl(breadcrumb)"
:src="resolveIconUrl(breadcrumb)"
:alt="resolveLabel(breadcrumb.name)"
size="20px"
no-shadow
raised
class="shrink-0 !rounded-md"
/>
<component
:is="resolveIcon(breadcrumb)"
v-else-if="resolveIcon(breadcrumb)"
class="size-5 shrink-0 text-primary"
aria-hidden="true"
/>
{{ resolveLabel(breadcrumb.name) }}
</router-link>
<span
v-else
data-tauri-drag-region
class="flex shrink-0 items-center gap-1 whitespace-nowrap text-contrast font-semibold cursor-default select-none"
>
<Avatar
v-if="resolveIconUrl(breadcrumb)"
:src="resolveIconUrl(breadcrumb)"
:alt="resolveLabel(breadcrumb.name)"
size="20px"
no-shadow
raised
class="shrink-0 !rounded-md"
/>
<component
:is="resolveIcon(breadcrumb)"
v-else-if="resolveIcon(breadcrumb)"
class="size-5 shrink-0 text-primary"
aria-hidden="true"
/>
{{ resolveLabel(breadcrumb.name) }}
</span>
<ChevronRightIcon
v-if="index < breadcrumbs.length - 1"
data-tauri-drag-region
class="w-5 h-5 shrink-0"
/>
</template>
</div>
</div>
</template>
<script setup lang="ts">
import {
ArrowBigUpDashIcon,
ChangeSkinIcon,
ChevronRightIcon,
CodeIcon,
CompassIcon,
DownloadIcon,
FileTextIcon,
FlaskConicalIcon,
FolderIcon,
GlobeIcon,
HeartIcon,
HomeIcon,
ImagesIcon,
LibraryIcon,
MapIcon,
PackageIcon,
PencilIcon,
ServerIcon,
SettingsIcon,
} from '@modrinth/assets'
import { Avatar, commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { type Component, computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { resolveBreadcrumbLabel } from '@/helpers/breadcrumb-label'
import { useBreadcrumbs } from '@/store/breadcrumbs'
interface Breadcrumb {
name: string
link?: string
query?: Record<string, string>
iconUrl?: string | null
}
const route = useRoute()
const breadcrumbData = useBreadcrumbs()
const { formatMessage } = useVIntl()
const messages = defineMessages({
home: { id: 'app.navigation.home', defaultMessage: 'Home' },
worlds: { id: 'app.navigation.worlds', defaultMessage: 'Worlds' },
discoverContent: {
id: 'app.navigation.discover-content',
defaultMessage: 'Discover content',
},
skinSelector: { id: 'app.navigation.skin-selector', defaultMessage: 'Skin selector' },
multiplayer: { id: 'app.navigation.multiplayer', defaultMessage: 'Multiplayer' },
library: { id: 'app.navigation.library', defaultMessage: 'Library' },
downloads: { id: 'app.navigation.downloads', defaultMessage: 'Downloads' },
lab: { id: 'app.navigation.lab', defaultMessage: 'Lab' },
gradientText: {
id: 'app.lab.gradient-text.title',
defaultMessage: 'Gradient text generator',
},
seedMap: { id: 'app.lab.seed-map.title', defaultMessage: 'Seed map' },
schematicWorkshop: {
id: 'app.lab.schematic-preview.title',
defaultMessage: 'Schematic workshop',
},
modTranslation: {
id: 'app.lab.mod-translation.title',
defaultMessage: 'Mod translation',
},
skinEditor: { id: 'app.lab.skin-editor.title', defaultMessage: 'Skin editor' },
content: { id: 'app.instance.tabs.content', defaultMessage: 'Content' },
files: { id: 'app.instance.tabs.files', defaultMessage: 'Files' },
studio: { id: 'instance.files.studio.title', defaultMessage: 'Studio' },
logs: { id: 'app.instance.tabs.logs', defaultMessage: 'Logs' },
editWorld: { id: 'app.navigation.edit-world', defaultMessage: 'Edit world' },
upgradeInstance: { id: 'app.instance.upgrade-instance', defaultMessage: 'Upgrade instance' },
})
const staticLabels = {
Home: messages.home,
Worlds: messages.worlds,
'Discover content': messages.discoverContent,
'Skin selector': messages.skinSelector,
Multiplayer: messages.multiplayer,
Library: messages.library,
Downloads: messages.downloads,
Settings: commonMessages.settingsLabel,
Lab: messages.lab,
'Gradient text generator': messages.gradientText,
'Seed map': messages.seedMap,
'Schematic workshop': messages.schematicWorkshop,
'Mod translation': messages.modTranslation,
'Skin editor': messages.skinEditor,
Content: messages.content,
Files: messages.files,
Studio: messages.studio,
Logs: messages.logs,
'Edit world': messages.editWorld,
Upgrade: messages.upgradeInstance,
}
const staticIcons: Record<string, Component> = {
Home: HomeIcon,
Worlds: GlobeIcon,
'Discover content': CompassIcon,
'Skin selector': ChangeSkinIcon,
Multiplayer: ServerIcon,
Library: LibraryIcon,
Downloads: DownloadIcon,
Settings: SettingsIcon,
Lab: FlaskConicalIcon,
'Gradient text generator': FlaskConicalIcon,
'Seed map': MapIcon,
'Schematic workshop': CodeIcon,
'Mod translation': CodeIcon,
'Skin editor': PencilIcon,
Content: PackageIcon,
Files: FolderIcon,
Studio: CodeIcon,
Logs: FileTextIcon,
'Edit world': PencilIcon,
Upgrade: ArrowBigUpDashIcon,
Favorites: HeartIcon,
Versions: PackageIcon,
Gallery: ImagesIcon,
Screenshots: ImagesIcon,
'Drop help': FileTextIcon,
'Recipe generator': FlaskConicalIcon,
Downloaded: DownloadIcon,
Modpacks: PackageIcon,
LibraryServers: ServerIcon,
Custom: PackageIcon,
Shared: PackageIcon,
Saved: HeartIcon,
}
const breadcrumbs = computed<Breadcrumb[]>(() => {
const additionalContext =
route.meta.useContext === true
? breadcrumbData.context
: route.meta.useRootContext === true
? breadcrumbData.rootContext
: null
const crumbs = (route.meta.breadcrumb ?? []) as Breadcrumb[]
if (
additionalContext?.name.startsWith('?') &&
crumbs.some((crumb) => crumb.name === additionalContext.name)
) {
return crumbs
}
return additionalContext ? [additionalContext as Breadcrumb, ...crumbs] : crumbs
})
function resolveLabel(name: string): string {
return resolveBreadcrumbLabel(
name,
(key) => breadcrumbData.getName(key),
staticLabels,
(message) => formatMessage(message),
)
}
function resolveIcon(breadcrumb: Breadcrumb): Component | undefined {
if (breadcrumb.iconUrl || breadcrumbData.getIcon(breadcrumb.name.slice(1))) return undefined
const dynamicIcons: Record<string, Component> = {
'?Project': PackageIcon,
'?Version': PackageIcon,
'?BrowseTitle': CompassIcon,
'?FavoritesTitle': HeartIcon,
}
if (dynamicIcons[breadcrumb.name]) return dynamicIcons[breadcrumb.name]
const key = breadcrumb.name.startsWith('?') ? resolveLabel(breadcrumb.name) : breadcrumb.name
return staticIcons[key]
}
function resolveIconUrl(breadcrumb: Breadcrumb): string | null {
return (
breadcrumb.iconUrl ??
(breadcrumb.name.startsWith('?') ? breadcrumbData.getIcon(breadcrumb.name.slice(1)) : null)
)
}
// Overflow detection
const outerRef = ref<HTMLDivElement | null>(null)
const innerRef = ref<HTMLDivElement | null>(null)
const isOverflowing = ref(false)
const isAnimating = ref(false)
const overflowAmount = ref(0)
let hovered = false
let stopping = false
function checkOverflow() {
if (!outerRef.value || !innerRef.value) return
const overflow = innerRef.value.scrollWidth - outerRef.value.clientWidth
isOverflowing.value = overflow > 0
overflowAmount.value = overflow + 12
}
function onMouseEnter() {
hovered = true
stopping = false
if (isOverflowing.value) {
isAnimating.value = true
}
}
function onMouseLeave() {
hovered = false
if (isAnimating.value) {
stopping = true
}
}
function onAnimationIteration() {
if (stopping && !hovered) {
isAnimating.value = false
stopping = false
}
}
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
checkOverflow()
resizeObserver = new ResizeObserver(checkOverflow)
if (outerRef.value) resizeObserver.observe(outerRef.value)
if (innerRef.value) resizeObserver.observe(innerRef.value)
})
onBeforeUnmount(() => {
resizeObserver?.disconnect()
})
watch(
breadcrumbs,
() => {
breadcrumbData.resetToNames(breadcrumbs.value)
requestAnimationFrame(checkOverflow)
},
{ immediate: true },
)
</script>
<style scoped>
.breadcrumb-fade-mask {
mask-image: linear-gradient(
to right,
transparent,
black 12px,
black calc(100% - 12px),
transparent
);
}
.breadcrumbs-scroll {
animation: breadcrumb-scroll 10s ease-in-out infinite;
}
@keyframes breadcrumb-scroll {
0% {
transform: translateX(0);
}
35%,
65% {
transform: translateX(var(--scroll-distance));
}
100% {
transform: translateX(0);
}
}
</style>

View File

@ -0,0 +1,725 @@
<script setup lang="ts">
import { ChevronDownIcon, XIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, Checkbox, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, ref } from 'vue'
import { getActiveDependencyConflictIdentities } from '@/providers/content-selection-logic'
export interface ContentInstallPreviewDependency {
id: string
title: string
iconUrl?: string | null
versionNumber?: string
fileName?: string
description?: string
projectUrl?: string
requiredBy: string[]
alreadyInstalled: boolean
status?: 'installed' | 'included'
versionMismatch?: boolean
selectionReason?: string
required?: boolean
requiredByKeys?: string[]
}
export interface ContentInstallPreviewSkipped {
id: string
title: string
reason: string
requiredByKeys?: string[]
}
export interface ContentInstallPreviewData {
primary?: ContentInstallPreviewPrimary
primaries?: ContentInstallPreviewPrimary[]
instanceName: string
installDependencies: boolean
dependencies: ContentInstallPreviewDependency[]
skipped: ContentInstallPreviewSkipped[]
}
export interface ContentInstallPreviewPrimary {
key?: string
title: string
iconUrl?: string | null
versionNumber?: string
provider?: string
contentType?: string
error?: string
conflictIdentities?: string[]
removable?: boolean
}
export interface ContentInstallBatchPreviewResult {
approvedIds: string[]
primaryKeys: string[]
}
export interface ContentInstallConflictPrompt {
candidate: {
title: string
provider: string
contentType: string
iconUrl?: string | null
}
existing: Array<{
title: string
provider: string
fileName?: string
}>
source: 'heuristic'
confidence: 'high' | 'possible'
}
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'app.content-install.preview.header',
defaultMessage: 'Confirm installation',
},
description: {
id: 'app.content-install.preview.description',
defaultMessage:
'{count, plural, one {# dependency will be installed automatically} other {# dependencies will be installed automatically}} for {project} in {instance}.',
},
batchDescription: {
id: 'app.content-install.preview.batch-description',
defaultMessage:
'Review {projectCount, plural, one {# project} other {# projects}} and {dependencyCount, plural, one {# dependency} other {# dependencies}} for {instance}.',
},
selectedContentHeader: {
id: 'app.content-install.preview.selected-content-header',
defaultMessage: 'Selected content',
},
removeProject: {
id: 'app.content-install.preview.remove-project',
defaultMessage: 'Remove {project} from this installation',
},
dependenciesHeader: {
id: 'app.content-install.preview.dependencies-header',
defaultMessage: 'Dependencies',
},
dependenciesCount: {
id: 'app.content-install.preview.dependencies-count',
defaultMessage: '{count, plural, one {# dependency} other {# dependencies}}',
},
requiredDependenciesHeader: {
id: 'app.content-install.preview.required-dependencies-header',
defaultMessage: 'Required dependencies',
},
optionalDependenciesHeader: {
id: 'app.content-install.preview.optional-dependencies-header',
defaultMessage: 'Optional dependencies',
},
requiredBy: {
id: 'app.content-install.preview.required-by',
defaultMessage: 'Required by {projects}',
},
alreadyInstalled: {
id: 'app.content-install.preview.already-installed',
defaultMessage: 'Already installed',
},
alreadyIncluded: {
id: 'app.content-install.preview.already-included',
defaultMessage: 'Already included',
},
versionMismatch: {
id: 'app.content-install.preview.version-mismatch',
defaultMessage: 'Version may not match this instance',
},
skippedHeader: {
id: 'app.content-install.preview.skipped-header',
defaultMessage: 'Skipped',
},
onlyChecked: {
id: 'app.content-install.preview.only-checked',
defaultMessage: 'Only checked dependencies will be installed.',
},
selectAll: {
id: 'app.content-install.preview.select-all',
defaultMessage: 'Select all',
},
clearAll: {
id: 'app.content-install.preview.clear-all',
defaultMessage: 'Clear all',
},
cancel: {
id: 'app.content-install.preview.cancel',
defaultMessage: 'Cancel',
},
viewDetails: {
id: 'app.content-install.preview.view-details',
defaultMessage: 'View details',
},
openProjectPage: {
id: 'app.content-install.preview.open-project-page',
defaultMessage: 'Open project page',
},
descriptionUnavailable: {
id: 'app.content-install.preview.description-unavailable',
defaultMessage: 'No description available.',
},
install: {
id: 'app.content-install.preview.install',
defaultMessage: 'Install',
},
installResolved: {
id: 'app.content-install.preview.install-resolved',
defaultMessage: 'Install resolved content',
},
conflictHeader: {
id: 'app.content-install.preview.conflict-header',
defaultMessage: 'Possible duplicate content',
},
conflictDescription: {
id: 'app.content-install.preview.conflict-description',
defaultMessage:
'{candidate} may be the same content as an installed or selected project. Continue anyway?',
},
continueAnyway: {
id: 'app.content-install.preview.continue-anyway',
defaultMessage: 'Install anyway',
},
existingContent: {
id: 'app.content-install.preview.existing-content',
defaultMessage: 'Existing content',
},
})
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const data = ref<ContentInstallPreviewData | null>(null)
const selectedIds = ref<Set<string>>(new Set())
const expandedDependencyIds = ref<Set<string>>(new Set())
const removedPrimaryKeys = ref<Set<string>>(new Set())
let settled = false
let batchMode = false
let conflictMode = false
const conflictPrompt = ref<ContentInstallConflictPrompt | null>(null)
let resolveShow:
| ((result: string[] | ContentInstallBatchPreviewResult | boolean | null) => void)
| null = null
const primaries = computed(() => {
if (!data.value) return []
if (data.value.primaries?.length) return data.value.primaries
return data.value.primary ? [data.value.primary] : []
})
const visiblePrimaries = computed(() =>
primaries.value.filter((primary) => !primary.key || !removedPrimaryKeys.value.has(primary.key)),
)
const visiblePrimaryKeys = computed(() =>
visiblePrimaries.value.map((primary) => primary.key).filter((key): key is string => !!key),
)
const visiblePrimaryKeySet = computed(() => new Set(visiblePrimaryKeys.value))
const visibleDependencies = computed(
() =>
data.value?.dependencies.filter(
(dependency) =>
!dependency.requiredByKeys?.length ||
dependency.requiredByKeys.some((key) => visiblePrimaryKeySet.value.has(key)),
) ?? [],
)
const activeConflictIdentities = computed(() =>
getActiveDependencyConflictIdentities(data.value?.dependencies ?? [], visiblePrimaryKeySet.value),
)
function primaryError(primary: ContentInstallPreviewPrimary) {
if (!primary.error) return null
if (!primary.conflictIdentities?.length) return primary.error
return primary.conflictIdentities.some((identity) => activeConflictIdentities.value.has(identity))
? primary.error
: null
}
const visibleSkipped = computed(
() =>
data.value?.skipped.filter(
(skipped) =>
!skipped.requiredByKeys?.length ||
skipped.requiredByKeys.some((key) => visiblePrimaryKeySet.value.has(key)),
) ?? [],
)
const hasBlockingPrimary = computed(() =>
visiblePrimaries.value.some((primary) => !!primaryError(primary)),
)
const installableDependencies = computed(() => visibleDependencies.value)
const dependencyGroups = computed(() =>
[
{
id: 'required',
header: messages.requiredDependenciesHeader,
dependencies: visibleDependencies.value.filter((dependency) => dependency.required !== false),
},
{
id: 'optional',
header: messages.optionalDependenciesHeader,
dependencies: visibleDependencies.value.filter((dependency) => dependency.required === false),
},
].filter((group) => group.dependencies.length > 0),
)
const selectedInstallableCount = computed(
() =>
installableDependencies.value.filter((dependency) => selectedIds.value.has(dependency.id))
.length,
)
const hasUnresolvedDependencies = computed(() => visibleSkipped.value.length > 0)
function toggleDependency(id: string, value: boolean) {
const next = new Set(selectedIds.value)
if (value) next.add(id)
else next.delete(id)
selectedIds.value = next
}
function toggleAll(value: boolean) {
const next = new Set(selectedIds.value)
for (const dependency of installableDependencies.value) {
if (value) next.add(dependency.id)
else next.delete(dependency.id)
}
selectedIds.value = next
}
function hasDependencyDetails(dependency: ContentInstallPreviewDependency) {
return !!dependency.description || !!dependency.projectUrl
}
function toggleDependencyDetails(id: string) {
const next = new Set(expandedDependencyIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
expandedDependencyIds.value = next
}
async function openDependencyPage(dependency: ContentInstallPreviewDependency) {
if (!dependency.projectUrl) return
await openUrl(dependency.projectUrl)
}
function initialSelectedIds(value: ContentInstallPreviewData) {
if (!value.installDependencies) return new Set<string>()
return new Set(
value.dependencies
.filter((dependency) => !dependency.alreadyInstalled && dependency.required !== false)
.map((dependency) => dependency.id),
)
}
function finish(result: string[] | ContentInstallBatchPreviewResult | boolean | null) {
if (settled) return
settled = true
const resolve = resolveShow
resolveShow = null
if (resolve) resolve(result)
modal.value?.hide()
}
function confirm() {
if (conflictMode) {
finish(true)
return
}
if (hasBlockingPrimary.value || visiblePrimaries.value.length === 0) return
const approvedIds = visibleDependencies.value
.filter((dependency) => selectedIds.value.has(dependency.id))
.map((dependency) => dependency.id)
finish(batchMode ? { approvedIds, primaryKeys: visiblePrimaryKeys.value } : approvedIds)
}
function hide() {
finish(null)
}
function show(value: ContentInstallPreviewData): Promise<string[] | null> {
resolveShow?.(null)
resolveShow = null
data.value = value
batchMode = false
conflictMode = false
conflictPrompt.value = null
removedPrimaryKeys.value = new Set()
selectedIds.value = initialSelectedIds(value)
expandedDependencyIds.value = new Set()
settled = false
modal.value?.show()
return new Promise<string[] | null>((resolve) => {
resolveShow = (result) => resolve(Array.isArray(result) ? result : null)
})
}
function showBatch(
value: ContentInstallPreviewData,
): Promise<ContentInstallBatchPreviewResult | null> {
resolveShow?.(null)
resolveShow = null
data.value = value
batchMode = true
conflictMode = false
conflictPrompt.value = null
removedPrimaryKeys.value = new Set()
selectedIds.value = initialSelectedIds(value)
expandedDependencyIds.value = new Set()
settled = false
modal.value?.show()
return new Promise<ContentInstallBatchPreviewResult | null>((resolve) => {
resolveShow = (result) =>
resolve(result && !Array.isArray(result) && typeof result !== 'boolean' ? result : null)
})
}
function showConflict(value: ContentInstallConflictPrompt): Promise<boolean> {
resolveShow?.(null)
resolveShow = null
data.value = null
batchMode = false
conflictMode = true
conflictPrompt.value = value
settled = false
modal.value?.show()
return new Promise<boolean>((resolve) => {
resolveShow = (result) => resolve(result === true)
})
}
function removePrimary(key: string) {
removedPrimaryKeys.value = new Set([...removedPrimaryKeys.value, key])
}
defineExpose({ show, showBatch, showConflict })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(conflictMode ? messages.conflictHeader : messages.header)"
scrollable
max-content-height="70vh"
width="40rem"
max-width="40rem"
:on-hide="hide"
>
<div v-if="conflictPrompt" class="flex flex-col gap-4">
<div
class="flex items-center gap-3 rounded-lg border border-solid border-warning bg-warning-bg p-3"
>
<Avatar
:src="conflictPrompt.candidate.iconUrl"
:alt="conflictPrompt.candidate.title"
size="2.5rem"
:tint-by="conflictPrompt.candidate.title"
no-shadow
/>
<div class="min-w-0 flex-1">
<span class="block truncate font-semibold text-contrast">{{
conflictPrompt.candidate.title
}}</span>
<span class="block truncate text-sm text-secondary">
{{
[conflictPrompt.candidate.provider, conflictPrompt.candidate.contentType].join(' · ')
}}
</span>
</div>
</div>
<p class="m-0 text-primary">
{{
formatMessage(messages.conflictDescription, {
candidate: conflictPrompt.candidate.title,
})
}}
</p>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{
formatMessage(messages.existingContent)
}}</span>
<div
v-for="item in conflictPrompt.existing"
:key="`${item.provider}:${item.title}:${item.fileName ?? ''}`"
class="flex items-center justify-between gap-3 rounded-lg border border-solid border-surface-4 bg-surface-2 px-3 py-2"
>
<span class="min-w-0 truncate font-medium text-contrast">{{ item.title }}</span>
<span class="shrink-0 text-sm text-secondary">{{ item.provider }}</span>
</div>
</div>
</div>
<div v-else-if="data" class="flex min-w-0 flex-col gap-4">
<div v-if="batchMode" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{
formatMessage(messages.selectedContentHeader)
}}</span>
<div
v-for="primary in visiblePrimaries"
:key="primary.key ?? primary.title"
class="flex items-center gap-3 rounded-lg border border-solid border-surface-4 bg-surface-2 p-3"
>
<Avatar
:src="primary.iconUrl"
:alt="primary.title"
size="2.5rem"
:tint-by="primary.title"
no-shadow
/>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="truncate font-semibold text-contrast">{{ primary.title }}</span>
<span class="truncate text-sm text-secondary">
{{
[primary.versionNumber, primary.provider, primary.contentType]
.filter(Boolean)
.join(' · ')
}}
</span>
<span v-if="primaryError(primary)" class="text-sm text-red">
{{ primaryError(primary) }}
</span>
</div>
<ButtonStyled v-if="primary.removable && primary.key" circular type="transparent">
<button
type="button"
:aria-label="formatMessage(messages.removeProject, { project: primary.title })"
@click="removePrimary(primary.key)"
>
<XIcon />
</button>
</ButtonStyled>
</div>
</div>
<div
v-else-if="visiblePrimaries[0]"
class="flex items-center gap-3 rounded-lg border border-solid border-surface-4 bg-surface-2 p-3"
>
<Avatar
:src="visiblePrimaries[0].iconUrl"
:alt="visiblePrimaries[0].title"
size="2.5rem"
:tint-by="visiblePrimaries[0].title"
no-shadow
/>
<div class="flex min-w-0 flex-col gap-0.5">
<span class="truncate font-semibold text-contrast">{{ visiblePrimaries[0].title }}</span>
<span v-if="visiblePrimaries[0].versionNumber" class="truncate text-sm text-secondary">
{{ visiblePrimaries[0].versionNumber }}
</span>
</div>
</div>
<p class="m-0 text-primary">
{{
batchMode
? formatMessage(messages.batchDescription, {
projectCount: visiblePrimaries.length,
dependencyCount: visibleDependencies.length,
instance: data.instanceName,
})
: formatMessage(messages.description, {
count: selectedInstallableCount,
project: visiblePrimaries[0]?.title ?? '',
instance: data.instanceName,
})
}}
</p>
<div v-if="visibleDependencies.length > 0" class="flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="flex items-center gap-2 font-semibold text-contrast">
{{ formatMessage(messages.dependenciesHeader) }}
<span
class="rounded-full bg-surface-4 px-2 py-0.5 text-xs font-medium tabular-nums text-secondary"
>
{{ formatMessage(messages.dependenciesCount, { count: visibleDependencies.length }) }}
</span>
</span>
<ButtonStyled v-if="installableDependencies.length > 1" size="small" type="transparent">
<button @click="toggleAll(selectedInstallableCount !== installableDependencies.length)">
{{
selectedInstallableCount === installableDependencies.length
? formatMessage(messages.clearAll)
: formatMessage(messages.selectAll)
}}
</button>
</ButtonStyled>
</div>
<div
class="grid grid-cols-1 gap-3"
:class="{ 'sm:grid-cols-2': dependencyGroups.length > 1 }"
>
<div
v-for="group in dependencyGroups"
:key="group.id"
class="flex min-w-0 flex-col gap-2"
>
<span class="flex items-center gap-2 font-semibold text-contrast">
{{ formatMessage(group.header) }}
<span
class="rounded-full bg-surface-4 px-2 py-0.5 text-xs font-medium tabular-nums text-secondary"
>
{{ group.dependencies.length }}
</span>
</span>
<div
v-for="dependency in group.dependencies"
:key="dependency.id"
class="flex w-full min-w-0 flex-col overflow-hidden rounded-xl border border-solid border-surface-4 bg-surface-2"
:class="{ 'opacity-60': dependency.alreadyInstalled }"
>
<div class="flex items-start gap-3 p-3">
<Checkbox
:model-value="selectedIds.has(dependency.id)"
class="mt-2 shrink-0"
@update:model-value="(value) => toggleDependency(dependency.id, value)"
/>
<Avatar
:src="dependency.iconUrl"
:alt="dependency.title"
size="2.5rem"
:tint-by="dependency.title"
no-shadow
/>
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<button
v-if="hasDependencyDetails(dependency)"
type="button"
class="group flex w-full min-w-0 cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left"
:aria-expanded="expandedDependencyIds.has(dependency.id)"
:aria-label="formatMessage(messages.viewDetails, { project: dependency.title })"
@click="toggleDependencyDetails(dependency.id)"
>
<span
v-tooltip="
dependency.description
? {
content: dependency.description,
placement: 'top',
popperClass: 'preview-dependency-tooltip',
}
: null
"
class="min-w-0 truncate font-semibold text-contrast group-hover:underline"
>
{{ dependency.title }}
</span>
<ChevronDownIcon
aria-hidden="true"
class="shrink-0 text-secondary transition-transform duration-150"
:class="{
'rotate-180': expandedDependencyIds.has(dependency.id),
}"
/>
</button>
<span v-else class="truncate font-semibold text-contrast">
{{ dependency.title }}
</span>
<span
v-if="dependency.versionNumber"
class="min-w-0 truncate text-sm text-secondary"
>
{{ dependency.versionNumber }}
</span>
<span
v-if="dependency.requiredBy.length > 0"
class="min-w-0 truncate text-sm text-secondary"
>
{{
formatMessage(messages.requiredBy, {
projects: dependency.requiredBy.join(', '),
})
}}
</span>
<div class="flex flex-wrap gap-1 pt-1">
<span
v-if="dependency.versionMismatch"
class="rounded-full bg-warning-bg px-2 py-0.5 text-xs font-medium text-warning-text"
>
{{ formatMessage(messages.versionMismatch) }}
</span>
<span
v-if="dependency.selectionReason"
class="rounded-full bg-surface-4 px-2 py-0.5 text-xs font-medium text-secondary"
>
{{ dependency.selectionReason }}
</span>
<span
v-if="dependency.alreadyInstalled"
class="rounded-full bg-surface-4 px-2 py-0.5 text-xs font-medium text-secondary"
>
{{
formatMessage(
dependency.status === 'included'
? messages.alreadyIncluded
: messages.alreadyInstalled,
)
}}
</span>
</div>
</div>
</div>
<div
v-if="expandedDependencyIds.has(dependency.id)"
class="mb-3 flex w-auto min-w-0 flex-col gap-2.5 rounded-lg bg-surface-1 px-3 py-2.5 mx-3"
>
<p
v-if="dependency.description"
class="m-0 w-full min-w-0 text-sm leading-relaxed text-secondary [overflow-wrap:anywhere]"
>
{{ dependency.description }}
</p>
<p v-else class="m-0 w-full min-w-0 text-sm text-secondary">
{{ formatMessage(messages.descriptionUnavailable) }}
</p>
<ButtonStyled v-if="dependency.projectUrl" class="self-start" type="outlined">
<button type="button" @click="openDependencyPage(dependency)">
{{ formatMessage(messages.openProjectPage) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
<span class="text-sm text-secondary">{{ formatMessage(messages.onlyChecked) }}</span>
</div>
<div v-if="visibleSkipped.length > 0" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.skippedHeader) }}</span>
<div
v-for="skipped in visibleSkipped"
:key="skipped.id"
class="flex flex-wrap items-center gap-x-2 gap-y-1 rounded-lg border border-solid border-surface-4 bg-surface-2 px-3 py-2"
>
<span class="min-w-0 flex-1 truncate font-medium text-contrast">
{{ skipped.title }}
</span>
<span class="shrink-0 text-sm text-secondary">{{ skipped.reason }}</span>
</div>
</div>
</div>
<template #actions>
<div class="flex items-center justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="hide">{{ formatMessage(messages.cancel) }}</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button
:disabled="!conflictMode && (hasBlockingPrimary || visiblePrimaries.length === 0)"
@click="confirm"
>
{{
conflictMode
? formatMessage(messages.continueAnyway)
: hasUnresolvedDependencies
? formatMessage(messages.installResolved)
: formatMessage(messages.install)
}}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<style>
.preview-dependency-tooltip.v-popper--theme-tooltip .v-popper__inner {
max-width: 22rem;
white-space: normal;
overflow-wrap: anywhere;
}
</style>

View File

@ -0,0 +1,181 @@
<template>
<transition name="fade">
<div
v-show="shown"
ref="contextMenu"
class="context-menu"
:style="{
left: left,
top: top,
}"
>
<div v-for="(option, index) in options" :key="index" @click.stop="optionClicked(option.name)">
<hr v-if="option.type === 'divider'" class="divider" />
<div
v-else-if="!(isInstanceLink(item) && option.name === `add_content`)"
class="item clickable"
:class="[option.color ?? 'base']"
>
<slot :name="option.name" />
</div>
</div>
</div>
</transition>
</template>
<script setup>
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
const emit = defineEmits(['menu-closed', 'option-clicked'])
const item = ref(null)
const contextMenu = ref(null)
const options = ref([])
const left = ref('0px')
const top = ref('0px')
const shown = ref(false)
defineExpose({
showMenu: (event, passedItem, passedOptions) => {
item.value = passedItem
options.value = passedOptions
// show to get dimensions
shown.value = true
// then, adjust position if overflowing
nextTick(() => {
const menuWidth = contextMenu.value?.clientWidth || 200
const menuHeight = contextMenu.value?.clientHeight || 100
const minFromEdge = 10
if (event.pageX + menuWidth + minFromEdge >= window.innerWidth) {
left.value = Math.max(minFromEdge, event.pageX - menuWidth - minFromEdge) + 'px'
} else {
left.value = event.pageX + minFromEdge + 'px'
}
if (event.pageY + menuHeight + minFromEdge >= window.innerHeight) {
top.value = Math.max(minFromEdge, event.pageY - menuHeight - minFromEdge) + 'px'
} else {
top.value = event.pageY + minFromEdge + 'px'
}
})
},
})
const isInstanceLink = (item) => {
if (item.instance != undefined && item.instance.link) {
return true
} else if (item != undefined && item.link) {
return true
}
return false
}
const hideContextMenu = () => {
shown.value = false
emit('menu-closed')
}
const optionClicked = (option) => {
emit('option-clicked', {
item: item.value,
option: option,
})
hideContextMenu()
}
const onEscKeyRelease = (event) => {
if (event.keyCode === 27) {
hideContextMenu()
}
}
const handleClickOutside = (event) => {
const elements = document.elementsFromPoint(event.clientX, event.clientY)
if (
contextMenu.value &&
contextMenu.value.$el !== event.target &&
!elements.includes(contextMenu.value.$el)
) {
hideContextMenu()
}
}
onMounted(() => {
window.addEventListener('click', handleClickOutside)
document.body.addEventListener('keyup', onEscKeyRelease)
})
onBeforeUnmount(() => {
window.removeEventListener('click', handleClickOutside)
document.body.removeEventListener('keyup', onEscKeyRelease)
})
</script>
<style lang="scss" scoped>
.context-menu {
background-color: var(--color-raised-bg);
border-radius: var(--radius-md);
box-shadow: var(--shadow-floating);
border: 1px solid var(--color-divider);
margin: 0;
position: fixed;
z-index: 1000000;
overflow: hidden;
padding: var(--gap-sm);
.item {
align-items: center;
color: var(--color-base);
cursor: pointer;
display: flex;
gap: var(--gap-sm);
padding: var(--gap-sm);
border-radius: var(--radius-sm);
&:hover,
&:active {
&.base {
background-color: var(--color-button-bg);
color: var(--color-contrast);
}
&.primary {
background-color: var(--color-brand);
color: var(--color-accent-contrast);
font-weight: bold;
}
&.danger {
background-color: var(--color-red);
color: var(--color-accent-contrast);
font-weight: bold;
}
&.contrast {
background-color: var(--color-orange);
color: var(--color-accent-contrast);
font-weight: bold;
}
}
}
.divider {
border: 1px solid var(--color-divider);
margin: var(--gap-sm);
pointer-events: none;
}
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s ease-in-out;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>

View File

@ -0,0 +1,97 @@
<script setup lang="ts">
import {
Admonition,
ButtonStyled,
defineMessages,
injectNotificationManager,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { renderHighlightedString } from '@modrinth/utils/highlightjs'
import { computed, ref } from 'vue'
import { explain_crash_with_ai } from '@/helpers/logs'
const modal = ref<InstanceType<typeof NewModal>>()
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const loading = ref(false)
const output = ref('')
const errorMessage = ref('')
const messages = defineMessages({
title: { id: 'app.crash-analysis.ai.title', defaultMessage: 'AI crash explanation' },
disclaimer: {
id: 'app.crash-analysis.ai.disclaimer',
defaultMessage:
'A sanitized and shortened crash context is sent directly to the AI provider configured in this launcher. AI output may be inaccurate.',
},
analyzing: { id: 'app.crash-analysis.ai.analyzing', defaultMessage: 'Explaining the crash...' },
error: { id: 'app.crash-analysis.ai.error', defaultMessage: 'AI explanation failed: {message}' },
copy: { id: 'app.crash-analysis.ai.copy', defaultMessage: 'Copy explanation' },
copied: {
id: 'app.crash-analysis.ai.copied',
defaultMessage: 'AI explanation copied to your clipboard',
},
close: { id: 'app.crash-analysis.ai.close', defaultMessage: 'Close' },
})
const renderedOutput = computed(() => renderHighlightedString(output.value))
async function show(instanceId: string): Promise<void> {
output.value = ''
errorMessage.value = ''
loading.value = true
modal.value?.show()
try {
const result = await explain_crash_with_ai(instanceId)
output.value = result.content
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : String(error)
} finally {
loading.value = false
}
}
async function copy(): Promise<void> {
try {
await navigator.clipboard.writeText(output.value)
addNotification({ title: formatMessage(messages.copied), type: 'success' })
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : String(error)
}
}
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" max-width="720px">
<div class="flex flex-col gap-4">
<Admonition type="warning" :header="formatMessage(messages.title)">
{{ formatMessage(messages.disclaimer) }}
</Admonition>
<div v-if="loading" class="text-secondary">{{ formatMessage(messages.analyzing) }}</div>
<div v-else-if="errorMessage" class="rounded-lg bg-red-500/10 p-3 text-secondary">
{{ formatMessage(messages.error, { message: errorMessage }) }}
</div>
<div
v-else-if="output"
class="markdown-body max-h-[55vh] overflow-y-auto rounded-lg bg-surface-2 p-4"
v-html="renderedOutput"
/>
</div>
<template #actions>
<div class="flex flex-wrap justify-end gap-2">
<ButtonStyled v-if="output" type="outlined">
<button @click="copy">{{ formatMessage(messages.copy) }}</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="loading" @click="modal?.hide()">
{{ formatMessage(messages.close) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>

View File

@ -0,0 +1,167 @@
<script setup lang="ts">
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import type { CrashAnalysisResult } from '@/composables/useCrashAnalysis'
import { refresh_content } from '@/helpers/instance'
import { undo_added_mod } from '@/helpers/logs'
const modal = ref<InstanceType<typeof NewModal>>()
const analysis = ref<CrashAnalysisResult | null>(null)
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const busy = ref<string | null>(null)
const messages = defineMessages({
title: {
id: 'app.minecraft-crash.mod-changes-modal.title',
defaultMessage: 'Mod changes since the last successful launch',
},
description: {
id: 'app.minecraft-crash.mod-changes-modal.description',
defaultMessage: 'This comparison does not restore or modify any files.',
},
added: { id: 'app.minecraft-crash.mod-changes-modal.added', defaultMessage: 'Added ({count})' },
removed: {
id: 'app.minecraft-crash.mod-changes-modal.removed',
defaultMessage: 'Removed ({count})',
},
modified: {
id: 'app.minecraft-crash.mod-changes-modal.modified',
defaultMessage: 'Modified ({count})',
},
empty: {
id: 'app.minecraft-crash.mod-changes-modal.empty',
defaultMessage: 'No Mod file changes were detected.',
},
close: { id: 'app.minecraft-crash.mod-changes-modal.close', defaultMessage: 'Close' },
undo: { id: 'app.minecraft-crash.mod-changes-modal.undo', defaultMessage: 'Undo added Mod' },
undone: {
id: 'app.minecraft-crash.mod-changes-modal.undone',
defaultMessage: 'Added Mod removed',
},
undoConfirm: {
id: 'app.minecraft-crash.mod-changes-modal.undo-confirm',
defaultMessage: 'Remove {name}? Only this unchanged file will be deleted.',
},
undoFailed: {
id: 'app.minecraft-crash.mod-changes-modal.undo-failed',
defaultMessage: 'Could not undo this Mod change',
},
refreshFailed: {
id: 'app.minecraft-crash.mod-changes-modal.refresh-failed',
defaultMessage: 'Mod removed, but the content list could not be refreshed.',
},
})
const groups = computed(() =>
(['added', 'removed', 'modified'] as const).map((kind) => ({
kind,
items: (analysis.value?.mod_changes ?? []).filter((change) => change.kind === kind),
})),
)
const groupMessages = {
added: messages.added,
removed: messages.removed,
modified: messages.modified,
} as const
function show(nextAnalysis: CrashAnalysisResult): void {
analysis.value = nextAnalysis
modal.value?.show()
}
async function undo(change: (typeof groups.value)[number]['items'][number]): Promise<void> {
if (change.kind !== 'added' || busy.value) return
const currentAnalysis = analysis.value
if (!currentAnalysis || !change.current_sha256) return
const name = change.project_title || change.filename
if (!window.confirm(formatMessage(messages.undoConfirm, { name }))) return
busy.value = change.filename
let removed = false
try {
await undo_added_mod(currentAnalysis.instance_id, change.filename, change.current_sha256)
removed = true
currentAnalysis.mod_changes = currentAnalysis.mod_changes.filter(
(item) => item.filename !== change.filename,
)
addNotification({ title: formatMessage(messages.undone), type: 'success' })
} catch {
addNotification({ title: formatMessage(messages.undoFailed), type: 'error' })
} finally {
busy.value = null
}
if (!removed) return
try {
await refresh_content(currentAnalysis.instance_id)
} catch {
addNotification({ title: formatMessage(messages.refreshFailed), type: 'warning' })
}
}
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" max-width="680px">
<div class="flex max-h-[65vh] flex-col gap-4 overflow-y-auto">
<p class="m-0 text-secondary">{{ formatMessage(messages.description) }}</p>
<p v-if="!analysis?.mod_changes.length" class="m-0 text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<section v-for="group in groups" v-else :key="group.kind" class="flex flex-col gap-2">
<h3 class="m-0 text-sm font-semibold text-contrast">
{{ formatMessage(groupMessages[group.kind], { count: group.items.length }) }}
</h3>
<ul v-if="group.items.length" class="m-0 flex list-none flex-col gap-1 p-0">
<li
v-for="change in group.items"
:key="`${group.kind}:${change.filename}`"
class="rounded-md bg-surface-2 px-3 py-2 text-sm text-secondary"
>
<div class="flex min-w-0 items-center gap-2">
<div class="size-8 shrink-0 overflow-hidden rounded bg-surface-3">
<img
v-if="change.icon_url"
:src="change.icon_url"
:alt="change.project_title || ''"
class="size-full object-cover"
/>
</div>
<div class="min-w-0">
<div
v-if="change.project_title && change.project_title !== change.filename"
class="truncate font-sans text-sm text-contrast"
>
{{ change.project_title || change.filename }}
</div>
<div class="truncate text-xs text-secondary">
{{ change.version_number ? `v${change.version_number} · ` : ''
}}{{ change.filename }}
</div>
</div>
<ButtonStyled v-if="change.kind === 'added'" type="outlined">
<button :disabled="busy === change.filename" @click="undo(change)">
{{ formatMessage(messages.undo) }}
</button>
</ButtonStyled>
</div>
</li>
</ul>
</section>
</div>
<template #actions>
<div class="flex justify-end">
<ButtonStyled color="brand">
<button @click="modal?.hide()">{{ formatMessage(messages.close) }}</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>

View File

@ -0,0 +1,507 @@
<script setup>
import {
CheckIcon,
CopyIcon,
DownloadIcon,
DropdownIcon,
HammerIcon,
LogInIcon,
UpdatedIcon,
WrenchIcon,
XIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
Collapsible,
commonMessages,
defineMessages,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { ChatIcon } from '@/assets/icons'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { AxolotlBrandConfig } from '@/config'
import { trackEvent } from '@/helpers/analytics'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import { install_existing_instance } from '@/helpers/install'
import { cancel_directory_change } from '@/helpers/settings.ts'
import { exportErrorLogs } from '@/helpers/utils'
import { handleSevereError } from '@/store/error.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
genericTitle: { id: 'app.error.generic-title', defaultMessage: 'An error occurred' },
minecraftAuthTitle: {
id: 'app.error.minecraft-auth-title',
defaultMessage: 'Unable to sign in to Minecraft',
},
minecraftSignInTitle: {
id: 'app.error.minecraft-sign-in-title',
defaultMessage: 'Sign in to Minecraft',
},
directoryTitle: {
id: 'app.error.directory-title',
defaultMessage: 'Could not change app directory',
},
loaderTitle: { id: 'app.error.loader-title', defaultMessage: 'No loader selected' },
stateTitle: {
id: 'app.error.state-title',
defaultMessage: 'Error initializing Axolotl Launcher',
},
networkIssues: { id: 'app.error.network-issues', defaultMessage: 'Network issues' },
networkDescription: {
id: 'app.error.network-description',
defaultMessage:
'Axolotl Launcher had trouble connecting to Microsoft services. This is often caused by a poor connection. Try again, and use our support article if the issue persists.',
},
hostsDescription: {
id: 'app.error.hosts-description',
defaultMessage:
'The connection to Microsoft, Xbox, or Minecraft services was rejected. These services may be blocked by your hosts file. See our support article for steps to fix the issue.',
},
supportArticle: { id: 'app.error.support-article', defaultMessage: 'Support article' },
tryAnotherAccount: {
id: 'app.error.try-another-account',
defaultMessage: 'Try another Microsoft account',
},
accountDescription: {
id: 'app.error.account-description',
defaultMessage:
'Check that you signed in with the correct account. You may own Minecraft on another Microsoft account.',
},
tryAnotherAccountButton: {
id: 'app.error.try-another-account-button',
defaultMessage: 'Try another account',
},
officialLauncherTitle: {
id: 'app.error.official-launcher-title',
defaultMessage: 'Using PC Game Pass, coming from Bedrock, or just bought the game?',
},
officialLauncherBefore: {
id: 'app.error.official-launcher-before',
defaultMessage: 'Try signing in with the',
},
officialLauncher: {
id: 'app.error.official-launcher',
defaultMessage: 'official Minecraft Launcher',
},
officialLauncherAfter: {
id: 'app.error.official-launcher-after',
defaultMessage: 'first. When that is complete, return here and sign in.',
},
tryAgain: { id: 'app.error.try-sign-in-again', defaultMessage: 'Try signing in again' },
permissionsTitle: {
id: 'app.error.permissions-title',
defaultMessage: 'Change directory permissions',
},
permissionsDescription: {
id: 'app.error.permissions-description',
defaultMessage:
'Axolotl Launcher cannot write to the selected directory. Adjust its permissions and try again, or cancel the directory change.',
},
spaceTitle: { id: 'app.error.space-title', defaultMessage: 'Not enough space' },
spaceDescription: {
id: 'app.error.space-description',
defaultMessage:
'The disk containing the selected directory does not have enough free space. Free some space and try again, or cancel the directory change.',
},
directoryDescription: {
id: 'app.error.directory-description',
defaultMessage:
'Axolotl Launcher cannot migrate to the selected directory. Contact support for help or cancel the directory change.',
},
retryDirectory: {
id: 'app.error.retry-directory',
defaultMessage: 'Retry directory change',
},
cancelDirectory: {
id: 'app.error.cancel-directory',
defaultMessage: 'Cancel directory change',
},
minecraftRequired: {
id: 'app.error.minecraft-required',
defaultMessage:
'You are not logged in to any account. Please log in below. If you do not have a licensed account, you can create an offline account or log in with a third-party service in the sidebar.',
},
stateDescription: {
id: 'app.error.state-description',
defaultMessage:
'Axolotl Launcher failed to load correctly. A file may be corrupted or an essential file may be missing.',
},
stateFixIntro: {
id: 'app.error.state-fix-intro',
defaultMessage: 'Try one of the following:',
},
stateFixInternet: {
id: 'app.error.state-fix-internet',
defaultMessage: 'Check your internet connection, then restart the app.',
},
stateFixRedownload: {
id: 'app.error.state-fix-redownload',
defaultMessage: 'Download and install the app again.',
},
loaderDescription: {
id: 'app.error.loader-description',
defaultMessage: 'Axolotl Launcher could not find a loader version for this instance.',
},
loaderFix: {
id: 'app.error.loader-fix',
defaultMessage: 'Repair the instance using the button below.',
},
repairInstance: { id: 'app.error.repair-instance', defaultMessage: 'Repair instance' },
supportDescription: {
id: 'app.error.support-description',
defaultMessage:
'If you still need help, visit our support page and provide the following debug information.',
},
getSupport: { id: 'app.error.get-support', defaultMessage: 'Get support' },
debugInformation: { id: 'app.error.debug-information', defaultMessage: 'Debug information' },
copyDebugInfo: { id: 'app.error.copy-debug-info', defaultMessage: 'Copy debug information' },
exportLogs: { id: 'app.error.export-logs', defaultMessage: 'Export error logs' },
noErrorMessage: { id: 'app.error.no-error-message', defaultMessage: 'No error message.' },
})
const errorModal = ref()
const error = ref()
const closable = ref(true)
const errorCollapsed = ref(false)
const title = ref(formatMessage(messages.genericTitle))
const errorType = ref('unknown')
const supportLink = ref(AxolotlBrandConfig.supportUrl)
const metadata = ref({})
defineExpose({
async show(errorVal, context, canClose = true, source = null) {
console.log(errorVal, context, canClose, source)
closable.value = canClose
if (errorVal.message && errorVal.message.includes('Minecraft authentication error:')) {
title.value = formatMessage(messages.minecraftAuthTitle)
errorType.value = 'minecraft_auth'
supportLink.value = AxolotlBrandConfig.supportUrl
if (
errorVal.message.includes('existing connection was forcibly closed') ||
errorVal.message.includes('error sending request for url')
) {
metadata.value.network = true
}
if (errorVal.message.includes('because the target machine actively refused it')) {
metadata.value.hostsFile = true
}
} else if (errorVal.message && errorVal.message.includes('User is not logged in')) {
title.value = formatMessage(messages.minecraftSignInTitle)
errorType.value = 'minecraft_sign_in'
supportLink.value = AxolotlBrandConfig.supportUrl
} else if (errorVal.message && errorVal.message.includes('Move directory error:')) {
title.value = formatMessage(messages.directoryTitle)
errorType.value = 'directory_move'
supportLink.value = AxolotlBrandConfig.supportUrl
if (errorVal.message.includes('directory is not writable')) {
metadata.value.readOnly = true
}
if (errorVal.message.includes('Not enough space')) {
metadata.value.notEnoughSpace = true
}
} else if (errorVal.message && errorVal.message.includes('No loader version selected for')) {
title.value = formatMessage(messages.loaderTitle)
errorType.value = 'no_loader_version'
supportLink.value = AxolotlBrandConfig.supportUrl
metadata.value.instanceId = context.instanceId
} else if (source === 'state_init') {
title.value = formatMessage(messages.stateTitle)
errorType.value = 'state_init'
supportLink.value = AxolotlBrandConfig.supportUrl
} else {
title.value = formatMessage(messages.genericTitle)
errorType.value = 'unknown'
supportLink.value = AxolotlBrandConfig.supportUrl
metadata.value = {}
}
error.value = errorVal
errorModal.value.show()
},
})
const loadingMinecraft = ref(false)
async function loginMinecraft() {
try {
loadingMinecraft.value = true
const loggedIn = await login_flow()
if (loggedIn) {
await set_default_user(loggedIn.profile.id).catch(handleError)
}
await trackEvent('AccountLogIn', { source: 'ErrorModal' })
loadingMinecraft.value = false
errorModal.value.hide()
} catch (err) {
loadingMinecraft.value = false
handleSevereError(err)
}
}
async function cancelDirectoryChange() {
try {
await cancel_directory_change()
window.location.reload()
} catch (err) {
handleError(err)
}
}
function retryDirectoryChange() {
window.location.reload()
}
const loadingRepair = ref(false)
async function repairInstance() {
loadingRepair.value = true
try {
await install_existing_instance(metadata.value.instanceId, false)
errorModal.value.hide()
} catch (err) {
handleSevereError(err)
}
loadingRepair.value = false
}
const hasDebugInfo = computed(
() =>
errorType.value === 'directory_move' ||
errorType.value === 'minecraft_auth' ||
errorType.value === 'state_init' ||
errorType.value === 'no_loader_version',
)
const debugInfo = computed(
() => error.value.message ?? error.value ?? formatMessage(messages.noErrorMessage),
)
const copied = ref(false)
async function copyToClipboard(text) {
await navigator.clipboard.writeText(text)
copied.value = true
setTimeout(() => {
copied.value = false
}, 3000)
}
const exportingLogs = ref(false)
async function exportLogs() {
exportingLogs.value = true
try {
await exportErrorLogs(debugInfo.value)
} catch (err) {
handleError(err)
} finally {
exportingLogs.value = false
}
}
</script>
<template>
<ModalWrapper ref="errorModal" :header="title" :closable="closable">
<div class="modal-body flex flex-col gap-3 max-w-[550px]">
<div class="markdown-body">
<template v-if="errorType === 'minecraft_auth'">
<template v-if="metadata.network">
<h3>{{ formatMessage(messages.networkIssues) }}</h3>
<p>
{{ formatMessage(messages.networkDescription) }}
<a :href="AxolotlBrandConfig.supportUrl">
{{ formatMessage(messages.supportArticle) }}
</a>
</p>
</template>
<template v-else-if="metadata.hostsFile">
<h3>{{ formatMessage(messages.networkIssues) }}</h3>
<p>
{{ formatMessage(messages.hostsDescription) }}
<a :href="AxolotlBrandConfig.supportUrl">
{{ formatMessage(messages.supportArticle) }}
</a>
</p>
</template>
<template v-else>
<h3>{{ formatMessage(messages.tryAnotherAccount) }}</h3>
<p>
{{ formatMessage(messages.accountDescription) }}
</p>
<div class="flex items-center justify-center p-2 gap-2">
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
<LogInIcon /> {{ formatMessage(messages.tryAnotherAccountButton) }}
</button>
</div>
<h3>{{ formatMessage(messages.officialLauncherTitle) }}</h3>
<p>
{{ formatMessage(messages.officialLauncherBefore) }}
<a href="https://www.minecraft.net/en-us/download">
{{ formatMessage(messages.officialLauncher) }}
</a>
{{ formatMessage(messages.officialLauncherAfter) }}
</p>
</template>
<div class="flex items-center justify-center p-2 gap-2">
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
<LogInIcon /> {{ formatMessage(messages.tryAgain) }}
</button>
</div>
</template>
<template v-if="errorType === 'directory_move'">
<template v-if="metadata.readOnly">
<h3>{{ formatMessage(messages.permissionsTitle) }}</h3>
<p>
{{ formatMessage(messages.permissionsDescription) }}
</p>
</template>
<template v-else-if="metadata.notEnoughSpace">
<h3>{{ formatMessage(messages.spaceTitle) }}</h3>
<p>
{{ formatMessage(messages.spaceDescription) }}
</p>
</template>
<template v-else>
<p>
{{ formatMessage(messages.directoryDescription) }}
</p>
</template>
<div class="flex items-center justify-center p-2 gap-2">
<button class="btn" @click="retryDirectoryChange">
<UpdatedIcon /> {{ formatMessage(messages.retryDirectory) }}
</button>
<button class="btn btn-danger" @click="cancelDirectoryChange">
<XIcon /> {{ formatMessage(messages.cancelDirectory) }}
</button>
</div>
</template>
<div v-else-if="errorType === 'minecraft_sign_in'">
<p>
{{ formatMessage(messages.minecraftRequired) }}
</p>
<div class="flex items-center justify-center p-2 gap-2">
<button class="btn btn-primary" :disabled="loadingMinecraft" @click="loginMinecraft">
<LogInIcon /> {{ formatMessage(messages.minecraftSignInTitle) }}
</button>
</div>
</div>
<template v-else-if="errorType === 'state_init'">
<p>
{{ formatMessage(messages.stateDescription) }}
</p>
<p>{{ formatMessage(messages.stateFixIntro) }}</p>
<ul>
<li>{{ formatMessage(messages.stateFixInternet) }}</li>
<li>{{ formatMessage(messages.stateFixRedownload) }}</li>
</ul>
</template>
<template v-else-if="errorType === 'no_loader_version'">
<p>{{ formatMessage(messages.loaderDescription) }}</p>
<p>{{ formatMessage(messages.loaderFix) }}</p>
<div class="flex items-center justify-center p-2 gap-2">
<button class="btn btn-primary" :disabled="loadingRepair" @click="repairInstance">
<HammerIcon /> {{ formatMessage(messages.repairInstance) }}
</button>
</div>
</template>
<template v-else>
{{ debugInfo }}
</template>
<template v-if="hasDebugInfo">
<div class="w-full h-[1px] bg-surface-5 mb-3"></div>
<p>
{{ formatMessage(messages.supportDescription) }}
</p>
</template>
</div>
<div class="flex items-center gap-2">
<ButtonStyled>
<a :href="supportLink" @click="errorModal.hide()">
<ChatIcon /> {{ formatMessage(messages.getSupport) }}
</a>
</ButtonStyled>
<ButtonStyled>
<button :disabled="exportingLogs" @click="exportLogs">
<DownloadIcon /> {{ formatMessage(messages.exportLogs) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="closable">
<button @click="errorModal.hide()">
<XIcon /> {{ formatMessage(commonMessages.closeButton) }}
</button>
</ButtonStyled>
</div>
<template v-if="hasDebugInfo">
<div class="flex flex-col gap-2">
<div class="w-full h-[1px] bg-surface-5"></div>
<div class="overflow-clip">
<button
class="flex items-center justify-between w-full bg-transparent border-0 py-4 cursor-pointer"
@click="errorCollapsed = !errorCollapsed"
>
<span class="flex items-center gap-2 text-contrast font-extrabold m-0">
<WrenchIcon class="h-4 w-4" />
{{ formatMessage(messages.debugInformation) }}
</span>
<DropdownIcon
class="h-5 w-5 text-secondary transition-transform"
:class="{ 'rotate-180': !errorCollapsed }"
/>
</button>
<Collapsible :collapsed="errorCollapsed">
<div
class="p-3 bg-surface-2 rounded-2xl text-xs grid grid-cols-[1fr_auto] max-w-full items-start"
>
<div
class="m-0 p-0 rounded-none bg-transparent text-sm font-mono break-words overflow-auto"
>
{{ debugInfo }}
</div>
<ButtonStyled circular>
<button
v-tooltip="formatMessage(messages.copyDebugInfo)"
:disabled="copied"
@click="copyToClipboard(debugInfo)"
>
<template v-if="copied"> <CheckIcon class="text-green" /> </template>
<template v-else> <CopyIcon /> </template>
</button>
</ButtonStyled>
</div>
</Collapsible>
</div>
</div>
</template>
</div>
</ModalWrapper>
</template>
<style>
.light-mode {
--color-orange-bg: rgba(255, 163, 71, 0.2);
}
.dark-mode,
.oled-mode {
--color-orange-bg: rgba(224, 131, 37, 0.2);
}
</style>
<style scoped lang="scss">
.markdown-body {
overflow: auto;
}
</style>

View File

@ -0,0 +1,335 @@
<script setup>
import { FolderOpenIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
FileTreeSelect,
injectNotificationManager,
injectPopupNotificationManager,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { save } from '@tauri-apps/plugin-dialog'
import { join } from '@tauri-apps/api/path'
import { readDir, stat } from '@tauri-apps/plugin-fs'
import { ref } from 'vue'
import { PackageIcon } from '@/assets/icons'
import {
export_instance_mrpack,
get_full_path,
get_pack_export_candidates,
} from '@/helpers/instance'
import { highlightInFolder } from '@/helpers/utils'
const { handleError } = injectNotificationManager()
const popupNotificationManager = injectPopupNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: { id: 'app.export-modal.header', defaultMessage: 'Export modpack' },
modpackNameLabel: { id: 'app.export-modal.modpack-name-label', defaultMessage: 'Modpack name' },
modpackNamePlaceholder: {
id: 'app.export-modal.modpack-name-placeholder',
defaultMessage: 'Modpack name',
},
versionNumberLabel: {
id: 'app.export-modal.version-number-label',
defaultMessage: 'Version number',
},
versionNumberPlaceholder: {
id: 'app.export-modal.version-number-placeholder',
defaultMessage: '1.0.0',
},
descriptionPlaceholder: {
id: 'app.export-modal.description-placeholder',
defaultMessage: 'Enter modpack description...',
},
exportButton: { id: 'app.export-modal.export-button', defaultMessage: 'Export' },
exportComplete: {
id: 'app.export-modal.export-complete',
defaultMessage: 'Export complete',
},
exportCompleteDescription: {
id: 'app.export-modal.export-complete-description',
defaultMessage: '{name} was exported successfully.',
},
})
const props = defineProps({
instance: {
type: Object,
required: true,
},
})
defineExpose({
show: () => {
resetExportState()
exportModal.value.show()
void initFiles().catch(handleError)
},
})
const exportModal = ref(null)
const nameInput = ref(props.instance.name)
const exportDescription = ref('')
const versionInput = ref('1.0.0')
const files = ref([])
const selectedFilePaths = ref([])
const fileTreeKey = ref(0)
const filesLoadId = ref(0)
const instanceRoot = ref('')
const loadedDirectories = ref(new Set())
async function initFiles() {
const loadId = ++filesLoadId.value
const [filePaths, root] = await Promise.all([
get_pack_export_candidates(props.instance.id),
get_full_path(props.instance.id),
])
if (loadId !== filesLoadId.value) return
instanceRoot.value = root
const exportCandidates = await Promise.all(
filePaths.map((path) => buildExportCandidateItem(root, path)),
)
if (loadId !== filesLoadId.value) return
files.value = exportCandidates
selectedFilePaths.value = files.value
.filter((file) => !file.disabled && isDefaultSelectedExportCandidate(file.path))
.map((file) => file.path)
}
const exportPack = async () => {
const outputPath = await save({
defaultPath: `${nameInput.value} ${versionInput.value}.mrpack`,
filters: [
{
name: 'Modrinth Modpack',
extensions: ['mrpack'],
},
],
})
if (outputPath) {
exportModal.value.hide()
try {
await export_instance_mrpack(
props.instance.id,
outputPath,
selectedFilePaths.value,
versionInput.value,
exportDescription.value,
nameInput.value,
)
const fileName = outputPath.split(/[\\/]/).pop() ?? outputPath
popupNotificationManager.addPopupNotification({
title: formatMessage(messages.exportComplete),
text: formatMessage(messages.exportCompleteDescription, { name: fileName }),
type: 'success',
buttons: [
{
label: formatMessage(commonMessages.openInFolderButton),
icon: FolderOpenIcon,
action: () => highlightInFolder(outputPath).catch(handleError),
},
],
})
} catch (error) {
handleError(error)
}
}
}
function resetExportState() {
nameInput.value = props.instance.name
exportDescription.value = ''
versionInput.value = '1.0.0'
files.value = []
selectedFilePaths.value = []
fileTreeKey.value += 1
instanceRoot.value = ''
loadedDirectories.value = new Set()
}
async function loadExportDirectory(path) {
if (!path || !instanceRoot.value || loadedDirectories.value.has(path)) return
const loadId = filesLoadId.value
loadedDirectories.value.add(path)
try {
const entries = await readDir(await join(instanceRoot.value, ...path.split('/')))
const childItems = await Promise.all(
entries.map((entry) => buildExportDirectoryChildItem(instanceRoot.value, path, entry)),
)
if (loadId !== filesLoadId.value) return
appendExportItems(childItems)
} catch {
loadedDirectories.value.delete(path)
}
}
async function buildExportCandidateItem(instanceRoot, path) {
try {
const entries = await readDir(await join(instanceRoot, ...path.split('/')))
const metadata = await getExportCandidateMetadata(instanceRoot, path)
return {
path,
type: 'directory',
disabled: isExportCandidateDisabled(path),
modified: metadata.modified,
count: entries.length,
}
} catch {
return buildExportFileItem(instanceRoot, path)
}
}
async function buildExportDirectoryChildItem(instanceRoot, parentPath, entry) {
const path = `${parentPath}/${entry.name}`
if (entry.isDirectory) {
const metadata = await getExportCandidateMetadata(instanceRoot, path)
return {
path,
type: 'directory',
disabled: isExportCandidateDisabled(path),
modified: metadata.modified,
}
}
return buildExportFileItem(instanceRoot, path)
}
async function buildExportFileItem(instanceRoot, path) {
const metadata = await getExportCandidateMetadata(instanceRoot, path)
return {
path,
type: 'file',
disabled: isExportCandidateDisabled(path),
size: metadata.size,
modified: metadata.modified,
}
}
function appendExportItems(items) {
const nextFiles = new Map(files.value.map((file) => [normalizeExportPath(file.path), file]))
for (const item of items) {
nextFiles.set(normalizeExportPath(item.path), item)
}
files.value = [...nextFiles.values()]
}
async function getExportCandidateMetadata(instanceRoot, path) {
try {
const metadata = await stat(await join(instanceRoot, ...path.split('/')))
return {
size: metadata.size,
modified: metadata.mtime ? Math.floor(metadata.mtime.getTime() / 1000) : undefined,
}
} catch {
return {}
}
}
function normalizeExportPath(path) {
return path.replaceAll('\\', '/').split('/').filter(Boolean).join('/')
}
function isDefaultSelectedExportCandidate(path) {
return (
path.startsWith('mods') ||
path.startsWith('datapacks') ||
path.startsWith('resourcepacks') ||
path.startsWith('shaderpacks') ||
path.startsWith('config')
)
}
function isExportCandidateDisabled(path) {
return (
path === 'profile.json' ||
path.startsWith('modrinth_logs') ||
path.startsWith('.fabric') ||
path.startsWith('__MACOSX')
)
}
</script>
<template>
<NewModal
ref="exportModal"
:header="formatMessage(messages.header)"
scrollable
width="46rem"
max-width="calc(100vw - 2rem)"
>
<div class="flex flex-col gap-4">
<div class="grid grid-cols-2 gap-4">
<div class="labeled_input w-full">
<p class="text-contrast font-semibold">{{ formatMessage(messages.modpackNameLabel) }}</p>
<StyledInput
v-model="nameInput"
type="text"
:placeholder="formatMessage(messages.modpackNamePlaceholder)"
clearable
wrapper-class="w-full"
/>
</div>
<div class="labeled_input w-full">
<p class="text-contrast font-semibold">
{{ formatMessage(messages.versionNumberLabel) }}
</p>
<StyledInput
v-model="versionInput"
type="text"
:placeholder="formatMessage(messages.versionNumberPlaceholder)"
clearable
wrapper-class="w-full"
/>
</div>
</div>
<div class="flex flex-col gap-2 min-w-0">
<p class="m-0 text-contrast font-semibold">
{{ formatMessage(commonMessages.descriptionLabel) }}
</p>
<StyledInput
v-model="exportDescription"
multiline
:placeholder="formatMessage(messages.descriptionPlaceholder)"
wrapper-class="w-full"
/>
</div>
<FileTreeSelect
:key="fileTreeKey"
v-model="selectedFilePaths"
class="min-w-0"
:items="files"
@navigate="loadExportDirectory"
/>
</div>
<template #actions>
<div class="flex items-center justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="exportModal.hide">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="exportPack">
<PackageIcon />
{{ formatMessage(messages.exportButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>

View File

@ -0,0 +1,443 @@
<script setup>
import {
DownloadIcon,
GameIcon,
PlayIcon,
SpinnerIcon,
StopCircleIcon,
TimerIcon,
} from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import dayjs from 'dayjs'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { useMinecraftLaunchError } from '@/composables/useMinecraftLaunchError'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { trackEvent } from '@/helpers/analytics'
import { process_listener } from '@/helpers/events'
import { install_existing_instance, install_pack_to_existing_instance } from '@/helpers/install'
import { kill, run } from '@/helpers/instance'
import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
import { get_by_instance_id } from '@/helpers/process'
import { showInstanceInFolder } from '@/helpers/utils.js'
import { handleSevereError } from '@/store/error.js'
const { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime()
const { formatMessage } = useVIntl()
const handleMinecraftLaunchError = useMinecraftLaunchError()
const messages = defineMessages({
loading: { id: 'app.instance.loading', defaultMessage: 'Instance is loading...' },
played: { id: 'app.instance.played', defaultMessage: 'Played {time}' },
neverPlayed: { id: 'app.instance.never-played', defaultMessage: 'Never played' },
offlineInstalledOnly: {
id: 'app.instance.offline-installed-only',
defaultMessage: 'Offline mode can only launch fully downloaded instances.',
},
})
const { offline } = useNetworkStatus()
const props = defineProps({
instance: {
type: Object,
default() {
return {}
},
},
compact: {
type: Boolean,
default: false,
},
flat: {
type: Boolean,
default: false,
},
variant: {
type: String,
default: 'standard',
},
playing: {
type: Boolean,
default: undefined,
},
first: {
type: Boolean,
default: false,
},
disabled: {
type: Boolean,
default: false,
},
})
const internalPlaying = ref(false)
const isPlaying = computed(() => props.playing ?? internalPlaying.value)
const displayIcon = computed(() =>
getDisplayInstanceIcon(props.instance.icon_path, props.instance.loader),
)
const loading = ref(false)
const modLoading = computed(
() =>
loading.value ||
currentEvent.value === 'installing' ||
(currentEvent.value === 'launched' && !isPlaying.value),
)
const installing = computed(() => props.instance.install_stage.includes('installing'))
const installed = computed(() => props.instance.install_stage === 'installed')
const router = useRouter()
const seeInstance = async () => {
await router.push(`/instance/${encodeURIComponent(props.instance.id)}`)
}
const checkProcess = async () => {
if (props.playing !== undefined) return
const runningProcesses = await get_by_instance_id(props.instance.id).catch(handleError)
internalPlaying.value = runningProcesses.length > 0
}
const play = async (e, context) => {
e?.stopPropagation()
loading.value = true
await run(props.instance.id)
.catch(async (err) => {
const handled = await handleMinecraftLaunchError(err, {
instance_id: props.instance.id,
instance_name: props.instance.name,
})
if (!handled) handleSevereError(err, { instanceId: props.instance.id })
})
.finally(() => {
trackEvent('InstanceStart', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: context,
})
})
loading.value = false
}
const stop = async (e, context) => {
e?.stopPropagation()
internalPlaying.value = false
await kill(props.instance.id).catch(handleError)
trackEvent('InstanceStop', {
loader: props.instance.loader,
game_version: props.instance.game_version,
source: context,
})
}
const repair = async (e) => {
e?.stopPropagation()
if (
props.instance.install_stage !== 'pack_installed' &&
(props.instance.link?.type === 'modrinth_modpack' ||
props.instance.link?.type === 'server_project_modpack')
) {
await install_pack_to_existing_instance(props.instance.id, {
type: 'fromVersionId',
project_id: props.instance.link.project_id ?? props.instance.link.server_project_id ?? '',
version_id: props.instance.link.version_id ?? props.instance.link.content_version_id ?? '',
title: props.instance.name,
}).catch(handleError)
} else {
await install_existing_instance(props.instance.id, false).catch(handleError)
}
}
const openFolder = async () => {
await showInstanceInFolder(props.instance.id)
}
const addContent = async () => {
await router.push({
path: `/browse/${props.instance.loader === 'vanilla' ? 'datapack' : 'mod'}`,
query: { i: props.instance.id },
})
}
defineExpose({
play,
stop,
seeInstance,
openFolder,
addContent,
instance: props.instance,
})
const currentEvent = ref(null)
const unlisten =
props.playing === undefined
? await process_listener((e) => {
if (e.instance_id === props.instance.id) {
currentEvent.value = e.event
if (e.event === 'finished') {
internalPlaying.value = false
}
}
})
: () => undefined
onMounted(() => checkProcess())
onUnmounted(() => unlisten())
</script>
<template>
<template v-if="compact">
<div
class="grid cursor-pointer grid-cols-[auto_1fr_auto] items-center gap-2 rounded-lg transition-colors"
:class="
flat
? 'px-2 py-2 hover:bg-button-bg'
: 'card-shadow bg-bg-raised p-3 pl-4 hover:brightness-90'
"
@click="seeInstance"
@mouseenter="checkProcess"
>
<InstanceIcon
size="48px"
:icon-path="instance.icon_path"
:instance-id="instance.id"
:loader="instance.loader"
:alt="instance.name"
/>
<div class="h-full flex items-center font-bold text-contrast leading-normal">
<span class="line-clamp-2">{{ instance.name }}</span>
</div>
<div class="flex items-center">
<ButtonStyled v-if="isPlaying" color="red" circular @mousehover="checkProcess">
<button
v-tooltip="formatMessage(commonMessages.stopButton)"
@click="(e) => stop(e, 'InstanceCard')"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else-if="modLoading" color="standard" circular>
<button v-tooltip="formatMessage(messages.loading)" disabled>
<SpinnerIcon class="animate-spin" />
</button>
</ButtonStyled>
<ButtonStyled v-else :color="first ? 'brand' : 'standard'" circular>
<button
v-tooltip="
offline && !installed
? formatMessage(messages.offlineInstalledOnly)
: formatMessage(commonMessages.playButton)
"
:disabled="offline && !installed"
@click="(e) => play(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<!-- Translate for optical centering -->
<PlayIcon class="translate-x-[1px]" />
</button>
</ButtonStyled>
</div>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold">
<TimerIcon />
<span class="text-sm">
<template v-if="instance.last_played">
{{
formatMessage(messages.played, {
time: formatRelativeTime(dayjs(instance.last_played).toISOString()),
})
}}
</template>
<template v-else>{{ formatMessage(messages.neverPlayed) }}</template>
</span>
</div>
</div>
</template>
<div v-else-if="variant === 'library'">
<div
class="group relative flex w-full cursor-pointer select-none flex-col items-start justify-end gap-3 overflow-clip rounded-[20px] border border-solid border-surface-4 bg-surface-3 p-3 text-left transition-[border-color,filter,transform] hover:border-surface-5 hover:brightness-110 active:scale-[0.98]"
@click="seeInstance"
@mouseenter="checkProcess"
>
<div
class="relative flex aspect-square w-full shrink-0 items-center overflow-clip rounded-2xl"
>
<Avatar
size="100%"
:src="displayIcon.url"
:tint-by="instance.id"
:class="[
'pointer-events-none !rounded-2xl outline-none',
{ '!border-0 !bg-transparent !shadow-none': displayIcon.frameless },
]"
:alt="instance.name"
/>
<div
v-if="modLoading || installing"
class="pointer-events-none absolute inset-0 flex items-center justify-center bg-surface-1/30"
>
<SpinnerIcon
v-tooltip="
modLoading
? formatMessage(messages.loading)
: formatMessage(commonMessages.installingLabel)
"
class="size-[30%] animate-spin text-contrast"
tabindex="-1"
/>
</div>
<div class="absolute bottom-1.5 right-1.5 flex size-12 items-center justify-center">
<ButtonStyled v-if="isPlaying" size="large" color="red" circular>
<button
v-tooltip="formatMessage(commonMessages.stopButton)"
@click="(e) => stop(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<ButtonStyled
v-else-if="!modLoading && !installing && !installed"
size="large"
color="brand"
circular
>
<button
v-tooltip="
offline
? formatMessage(messages.offlineInstalledOnly)
: formatMessage(commonMessages.repairButton)
"
:disabled="offline"
:class="{
'pointer-events-none scale-75 opacity-0': disabled,
'scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100':
!disabled,
}"
@click="(e) => repair(e)"
>
<DownloadIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else-if="!modLoading && !installing" size="large" color="brand" circular>
<button
v-tooltip="formatMessage(commonMessages.playButton)"
:class="{
'pointer-events-none scale-75 opacity-0': disabled,
'scale-75 opacity-0 transition-all group-hover:scale-100 group-hover:opacity-100':
!disabled,
}"
@click="(e) => play(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<PlayIcon class="translate-x-[1px]" />
</button>
</ButtonStyled>
</div>
</div>
<div class="flex w-full min-w-0 flex-col items-start justify-center gap-1 px-0.5">
<p class="m-0 w-full truncate text-base font-semibold leading-5 text-contrast">
{{ instance.name }}
</p>
<p class="m-0 w-full truncate text-sm font-medium capitalize leading-[18px] text-primary">
{{ instance.loader }} {{ instance.game_version }}
</p>
</div>
</div>
</div>
<div v-else>
<div
class="button-base flex gap-3 group"
:class="
flat
? 'rounded-lg bg-transparent px-2 py-2 hover:bg-button-bg'
: 'rounded-xl bg-bg-raised p-4'
"
@click="seeInstance"
@mouseenter="checkProcess"
>
<div class="relative flex items-center justify-center">
<InstanceIcon
size="48px"
:icon-path="instance.icon_path"
:instance-id="instance.id"
:loader="instance.loader"
:alt="instance.name"
:class="`transition-all ${modLoading || installing ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div class="absolute inset-0 flex items-center justify-center">
<ButtonStyled v-if="isPlaying" size="large" color="red" circular>
<button
v-tooltip="formatMessage(commonMessages.stopButton)"
:class="{ 'scale-100 opacity-100': isPlaying }"
class="transition-all origin-bottom opacity-0 card-shadow"
@click="(e) => stop(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<StopCircleIcon />
</button>
</ButtonStyled>
<SpinnerIcon
v-else-if="modLoading || installing"
v-tooltip="
modLoading
? formatMessage(messages.loading)
: formatMessage(commonMessages.installingLabel)
"
class="animate-spin w-8 h-8"
tabindex="-1"
/>
<ButtonStyled v-else-if="!installed" size="large" color="brand" circular>
<button
v-tooltip="
offline
? formatMessage(messages.offlineInstalledOnly)
: formatMessage(commonMessages.repairButton)
"
:disabled="offline"
:class="`transition-all scale-75 origin-bottom card-shadow ${disabled ? 'opacity-0 scale-75' : 'opacity-0 group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100'}`"
@click="(e) => repair(e)"
>
<DownloadIcon />
</button>
</ButtonStyled>
<ButtonStyled v-else size="large" color="brand" circular>
<button
v-tooltip="formatMessage(commonMessages.playButton)"
:class="`transition-all scale-75 origin-bottom card-shadow ${disabled ? 'opacity-0 scale-75' : 'opacity-0 group-hover:scale-100 group-hover:opacity-100 group-focus-within:scale-100 group-focus-within:opacity-100'}`"
@click="(e) => play(e, 'InstanceCard')"
@mousehover="checkProcess"
>
<PlayIcon class="translate-x-[2px]" />
</button>
</ButtonStyled>
</div>
</div>
<div class="flex flex-col gap-1">
<p class="m-0 text-md font-bold text-contrast leading-tight line-clamp-1">
{{ instance.name }}
</p>
<div class="flex items-center col-span-3 gap-1 text-secondary font-semibold mt-auto">
<GameIcon class="shrink-0" />
<span class="text-sm capitalize">
{{ instance.loader }} {{ instance.game_version }}
</span>
</div>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,29 @@
<script setup lang="ts">
import { Avatar } from '@modrinth/ui'
import { computed } from 'vue'
import { getDisplayInstanceIcon } from '@/helpers/instance-icons'
const props = withDefaults(
defineProps<{
iconPath?: string | null
instanceId?: string | null
loader?: string | null
}>(),
{
iconPath: null,
instanceId: null,
loader: null,
},
)
const displayIcon = computed(() => getDisplayInstanceIcon(props.iconPath, props.loader))
</script>
<template>
<Avatar
:src="displayIcon.url"
:tint-by="instanceId"
:class="{ '!border-0 !rounded-none !bg-transparent !shadow-none': displayIcon.frameless }"
/>
</template>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import { GameIcon, LeftArrowIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, FormattedTag, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
type Instance = {
game_version: string
loader: string
path: string
install_stage: string
icon_path?: string
name: string
}
const props = withDefaults(
defineProps<{
instance: Instance
backTab?: string
}>(),
{ backTab: undefined },
)
const instanceLink = computed(() => {
const base = `/instance/${encodeURIComponent(props.instance.id)}`
return props.backTab ? `${base}/${props.backTab}` : base
})
const { formatMessage } = useVIntl()
const messages = defineMessages({
backToInstance: { id: 'app.instance.back', defaultMessage: 'Back to instance' },
})
</script>
<template>
<div class="flex justify-between items-center border-0 border-b border-solid border-divider pb-4">
<router-link :to="instanceLink" tabindex="-1" class="flex flex-col gap-4 text-primary">
<span class="flex items-center gap-2">
<InstanceIcon
:icon-path="instance.icon_path"
:instance-id="instance.id"
:loader="instance.loader"
:alt="instance.name"
size="48px"
/>
<span class="flex flex-col gap-2">
<span class="font-extrabold bold text-contrast">
{{ instance.name }}
</span>
<span class="text-secondary flex items-center gap-2 font-semibold">
<GameIcon class="h-5 w-5 text-secondary" />
<FormattedTag :tag="instance.loader" enforce-type="loader" />
{{ instance.game_version }}
</span>
</span>
</span>
</router-link>
<ButtonStyled>
<router-link :to="instanceLink">
<LeftArrowIcon /> {{ formatMessage(messages.backToInstance) }}
</router-link>
</ButtonStyled>
</div>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,497 @@
<script setup lang="ts">
import {
CheckIcon,
CopyIcon,
DropdownIcon,
ExternalIcon,
GlobeIcon,
SparklesIcon,
XIcon,
} from '@modrinth/assets'
import {
AutoLink,
ButtonStyled,
Collapsible,
defineMessages,
NewModal,
TagItem,
useVIntl,
} from '@modrinth/ui'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { resolveAutoGcArgs } from '@/helpers/gc/auto-selector'
import type { GcContext } from '@/helpers/gc/types'
import { lastGcLaunchReport } from '@/helpers/gc-notice'
import {
getJavaArgumentPresets,
getPresetsByGroup,
type JavaArgumentPreset,
} from '@/helpers/java-argument-presets'
const model = defineModel<string>({ required: true })
const props = withDefaults(
defineProps<{
id?: string
placeholder?: string
disabled?: boolean
gcContext?: GcContext
showAutoDetails?: boolean
}>(),
{
id: undefined,
placeholder: undefined,
disabled: false,
gcContext: undefined,
showAutoDetails: false,
},
)
const { formatMessage } = useVIntl()
const messages = defineMessages({
presetsButton: {
id: 'app.java-arguments.presets.button',
defaultMessage: 'Argument presets',
},
presetsModalTitle: {
id: 'app.java-arguments.presets.modal-title',
defaultMessage: 'Java argument presets',
},
usePreset: {
id: 'app.java-arguments.presets.use',
defaultMessage: 'Use preset',
},
presetApplied: {
id: 'app.java-arguments.presets.applied',
defaultMessage: 'Applied',
},
removePreset: {
id: 'app.java-arguments.presets.remove',
defaultMessage: 'Remove preset',
},
presetArguments: {
id: 'app.java-arguments.presets.arguments',
defaultMessage: 'Arguments',
},
collapseGroup: {
id: 'app.java-arguments.presets.collapse-group',
defaultMessage: 'Collapse group',
},
expandGroup: {
id: 'app.java-arguments.presets.expand-group',
defaultMessage: 'Expand group',
},
autoResolved: {
id: 'app.java-arguments.presets.gc.auto.resolved',
defaultMessage: 'Resolved → {strategy}',
},
autoReasonChain: {
id: 'app.java-arguments.presets.gc.auto.reason-chain',
defaultMessage: 'Decision path: {chain}',
},
autoVerified: {
id: 'app.java-arguments.presets.gc.auto.verified',
defaultMessage: 'Last launch: {chosen}',
},
})
const presets = computed(() => getJavaArgumentPresets(props.gcContext))
const groupedPresets = computed(() => getPresetsByGroup(presets.value))
const modal = ref<InstanceType<typeof NewModal>>()
const expandedPresetIds = ref(new Set<string>())
const expandedGroupIds = ref(new Set<string>())
const copiedPresetId = ref<string | null>(null)
function getPresetArgs(preset: JavaArgumentPreset): string {
return preset.resolveArgs ? preset.resolveArgs(props.gcContext) : preset.args
}
function getDisplayArgs(preset: JavaArgumentPreset): string {
if (preset.id === 'gc-auto' && props.showAutoDetails && props.gcContext) {
return resolveAutoGcArgs(props.gcContext)
}
return getPresetArgs(preset)
}
function getActivePresets(value: string): JavaArgumentPreset[] {
const trimmed = value.trimStart()
const matches = presets.value
.filter((preset) => (preset.detect ? preset.detect(trimmed) : trimmed.startsWith(preset.args)))
.sort((a, b) => {
const aIsAuto = a.id === 'gc-auto' ? 1 : 0
const bIsAuto = b.id === 'gc-auto' ? 1 : 0
return aIsAuto - bIsAuto || presets.value.indexOf(a) - presets.value.indexOf(b)
})
const result: JavaArgumentPreset[] = []
const seenGroups = new Set<string>()
for (const preset of matches) {
if (seenGroups.has(preset.group)) continue
seenGroups.add(preset.group)
result.push(preset)
}
return result
}
function removePresetArgs(preset: JavaArgumentPreset, args: string): string {
const presetArgs = getPresetArgs(preset)
if (args.startsWith(presetArgs)) {
return args.slice(presetArgs.length).trimStart()
}
if (preset.detect && preset.detect(args)) {
return args.replace(presetArgs, '').trimStart()
}
return args
}
const split = computed(() => {
const trimmed = model.value.trimStart()
const active = getActivePresets(trimmed)
let rest = trimmed
for (const preset of active) {
rest = removePresetArgs(preset, rest)
}
return { active, rest }
})
const activePresets = computed(() => split.value.active)
const rest = computed<string>({
get: () => split.value.rest,
set: (value) => {
const argsToJoin = activePresets.value.map(getPresetArgs)
model.value = argsToJoin.length ? argsToJoin.join(' ') + (value ? ` ${value}` : '') : value
},
})
function onInput(event: Event) {
rest.value = (event.target as HTMLInputElement).value
}
function removeOtherGroupPresets(preset: JavaArgumentPreset, currentArgs: string): string {
const groupPresets = presets.value.filter((p) => p.group === preset.group && p.id !== preset.id)
let result = currentArgs
for (const other of groupPresets) {
result = removePresetArgs(other, result)
}
return result
}
function applyPreset(preset: JavaArgumentPreset) {
const argsToApply = getPresetArgs(preset)
const cleanedRest = removeOtherGroupPresets(preset, split.value.rest)
model.value = argsToApply + (cleanedRest ? ` ${cleanedRest}` : '')
}
function removePreset(preset: JavaArgumentPreset) {
model.value = removePresetArgs(preset, model.value.trimStart()).trimStart()
}
function showPresets() {
modal.value?.show()
}
async function copyPresetArgs(preset: JavaArgumentPreset) {
const argsToCopy = getDisplayArgs(preset)
await navigator.clipboard.writeText(argsToCopy)
copiedPresetId.value = preset.id
setTimeout(() => {
if (copiedPresetId.value === preset.id) {
copiedPresetId.value = null
}
}, 1500)
}
function isPresetCollapsed(preset: JavaArgumentPreset) {
return !expandedPresetIds.value.has(preset.id)
}
function togglePresetCollapsed(preset: JavaArgumentPreset) {
const next = new Set(expandedPresetIds.value)
if (next.has(preset.id)) {
next.delete(preset.id)
} else {
next.add(preset.id)
}
expandedPresetIds.value = next
}
function isPresetActive(preset: JavaArgumentPreset) {
return activePresets.value.some((p) => p.id === preset.id)
}
function isGroupCollapsed(group: string) {
return !expandedGroupIds.value.has(group)
}
function toggleGroupCollapsed(group: string) {
const next = new Set(expandedGroupIds.value)
if (next.has(group)) {
next.delete(group)
} else {
next.add(group)
}
expandedGroupIds.value = next
}
function getAutoResolvedLabel(preset: JavaArgumentPreset): string | null {
if (preset.id !== 'gc-auto' || !preset.autoResolvedName) return null
return formatMessage(messages.autoResolved, { strategy: preset.autoResolvedName })
}
function getAutoReasonChainText(preset: JavaArgumentPreset): string | null {
if (preset.id !== 'gc-auto' || !preset.autoReasonChain) return null
return formatMessage(messages.autoReasonChain, { chain: preset.autoReasonChain.join(' → ') })
}
function getAutoVerifiedLabel(preset: JavaArgumentPreset): string | null {
if (preset.id !== 'gc-auto') return null
const notice = lastGcLaunchReport.value
if (!notice) return null
let chosen = notice.chosen_strategy || 'JVM default GC'
if (notice.pruned_args.length > 0) {
chosen += ` (${notice.pruned_args.length} pruned)`
}
return formatMessage(messages.autoVerified, { chosen })
}
const tagsScrollRef = ref<HTMLElement | null>(null)
const showTagsFade = ref(false)
let tagsResizeObserver: ResizeObserver | null = null
function updateTagsFade() {
const el = tagsScrollRef.value
if (!el) return
showTagsFade.value = el.scrollWidth > el.clientWidth + 1
}
watch(tagsScrollRef, (el) => {
if (el) {
updateTagsFade()
tagsResizeObserver?.disconnect()
tagsResizeObserver = new ResizeObserver(updateTagsFade)
tagsResizeObserver.observe(el)
} else {
tagsResizeObserver?.disconnect()
tagsResizeObserver = null
}
})
watch(activePresets, () => {
nextTick(updateTagsFade)
})
onBeforeUnmount(() => {
tagsResizeObserver?.disconnect()
tagsResizeObserver = null
})
</script>
<template>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<div
class="flex min-w-0 flex-1 items-center gap-2 rounded-xl bg-surface-4 px-3 transition-[box-shadow,color] focus-within:ring-4 focus-within:ring-brand-shadow"
:class="props.disabled ? 'cursor-not-allowed opacity-50' : ''"
>
<div
v-if="activePresets.length"
ref="tagsScrollRef"
class="tags-scroll flex min-w-0 max-w-[50%] shrink-0 items-center gap-1 overflow-x-auto"
:class="{ 'tags-fade-right': showTagsFade }"
@scroll="updateTagsFade"
>
<TagItem
v-for="preset in activePresets"
:key="preset.id"
class="shrink-0"
:action="props.disabled ? undefined : () => removePreset(preset)"
:aria-label="formatMessage(messages.removePreset)"
>
{{ formatMessage(preset.title) }}
<XIcon aria-hidden="true" />
</TagItem>
</div>
<input
:id="props.id"
:value="rest"
:disabled="props.disabled"
:placeholder="props.placeholder"
class="h-9 min-w-0 flex-1 bg-transparent px-0 py-2 text-base font-medium text-primary placeholder:text-secondary focus:text-contrast focus:shadow-none focus:outline-none"
autocomplete="off"
type="text"
@input="onInput"
/>
</div>
<ButtonStyled type="outlined" class="shrink-0">
<button type="button" :disabled="props.disabled" @click="showPresets">
<SparklesIcon aria-hidden="true" />
{{ formatMessage(messages.presetsButton) }}
</button>
</ButtonStyled>
</div>
<NewModal
ref="modal"
:header="formatMessage(messages.presetsModalTitle)"
width="min(640px, calc(100vw - 2rem))"
max-width="640px"
>
<div class="flex flex-col gap-6">
<div
v-for="groupEntry in groupedPresets"
:key="groupEntry.group"
class="flex flex-col gap-3"
>
<div
role="button"
tabindex="0"
class="flex w-full cursor-pointer select-none items-center justify-between gap-2"
:aria-expanded="!isGroupCollapsed(groupEntry.group)"
:aria-label="
formatMessage(
isGroupCollapsed(groupEntry.group) ? messages.expandGroup : messages.collapseGroup,
)
"
@click="toggleGroupCollapsed(groupEntry.group)"
@keydown.enter="toggleGroupCollapsed(groupEntry.group)"
@keydown.space.prevent="toggleGroupCollapsed(groupEntry.group)"
>
<h3 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(groupEntry.title) }}
</h3>
<DropdownIcon
class="size-4 shrink-0 text-secondary transition-transform"
:class="{ 'rotate-180': !isGroupCollapsed(groupEntry.group) }"
aria-hidden="true"
/>
</div>
<Collapsible :collapsed="isGroupCollapsed(groupEntry.group)">
<div class="flex flex-col gap-3">
<div
v-for="preset in groupEntry.presets"
:key="preset.id"
class="flex flex-col gap-3 rounded-xl border border-solid border-surface-4 bg-surface-2 p-4"
>
<div class="flex items-start gap-3 text-left">
<GlobeIcon class="mt-0.5 size-6 shrink-0 text-secondary" aria-hidden="true" />
<div class="min-w-0 flex-1">
<p class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(preset.title) }}
</p>
<AutoLink
:to="preset.link"
target="_blank"
rel="noreferrer"
class="inline-flex items-start gap-1 text-sm text-secondary hover:text-brand hover:underline"
>
<span class="min-w-0">{{ formatMessage(preset.description) }}</span>
<ExternalIcon class="mt-0.5 size-3.5 shrink-0" aria-hidden="true" />
</AutoLink>
<p
v-if="showAutoDetails && getAutoResolvedLabel(preset)"
class="m-0 mt-2 text-sm font-medium text-brand"
>
{{ getAutoResolvedLabel(preset) }}
</p>
<p
v-if="showAutoDetails && getAutoReasonChainText(preset)"
class="m-0 mt-1 text-xs text-secondary"
>
{{ getAutoReasonChainText(preset) }}
</p>
<p
v-if="showAutoDetails && getAutoVerifiedLabel(preset)"
class="m-0 mt-1 text-xs text-warning-text"
>
{{ getAutoVerifiedLabel(preset) }}
</p>
</div>
<ButtonStyled
:type="isPresetActive(preset) ? 'standard' : 'outlined'"
color="brand"
>
<button
type="button"
:disabled="isPresetActive(preset)"
@click="applyPreset(preset)"
>
<CheckIcon v-if="isPresetActive(preset)" aria-hidden="true" />
{{
formatMessage(
isPresetActive(preset) ? messages.presetApplied : messages.usePreset,
)
}}
</button>
</ButtonStyled>
</div>
<template v-if="preset.id !== 'gc-auto' || showAutoDetails">
<div class="flex items-center gap-2">
<div class="h-px min-w-0 flex-1 bg-surface-4" />
<button
v-tooltip="formatMessage(messages.presetArguments)"
type="button"
:aria-label="formatMessage(messages.presetArguments)"
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-secondary transition-colors hover:bg-surface-5 hover:text-contrast"
@click="togglePresetCollapsed(preset)"
>
<DropdownIcon
class="size-4 transition-transform"
:class="{ 'rotate-180': !isPresetCollapsed(preset) }"
aria-hidden="true"
/>
</button>
</div>
<Collapsible :collapsed="isPresetCollapsed(preset)">
<div class="flex items-start gap-2">
<code
class="min-w-0 flex-1 overflow-x-auto whitespace-pre-wrap break-all text-left font-mono text-xs leading-relaxed text-primary"
>
{{ getDisplayArgs(preset) }}
</code>
<button
type="button"
:aria-label="formatMessage(messages.presetArguments)"
class="flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-full border-none bg-transparent text-secondary transition-colors hover:bg-surface-5 hover:text-contrast"
@click="copyPresetArgs(preset)"
>
<CheckIcon
v-if="copiedPresetId === preset.id"
class="size-4 text-green"
aria-hidden="true"
/>
<CopyIcon v-else class="size-4" aria-hidden="true" />
</button>
</div>
</Collapsible>
</template>
</div>
</div>
</Collapsible>
</div>
</div>
</NewModal>
</div>
</template>
<style scoped>
.tags-scroll {
scrollbar-width: none;
}
.tags-scroll::-webkit-scrollbar {
display: none;
}
.tags-fade-right {
mask-image: linear-gradient(to right, black 0%, black calc(100% - 1.25rem), transparent 100%);
-webkit-mask-image: linear-gradient(
to right,
black 0%,
black calc(100% - 1.25rem),
transparent 100%
);
}
</style>

View File

@ -0,0 +1,139 @@
<template>
<ModalWrapper
ref="detectJavaModal"
:header="formatMessage(messages.selectJavaVersion)"
:show-ad-on-close="false"
>
<div class="flex flex-col gap-4">
<Table :columns="javaInstallColumns" :data="chosenInstallOptions" row-key="path">
<template #cell-version="{ value }">
<span class="font-semibold text-primary">{{ value }}</span>
</template>
<template #cell-path="{ value }">
<span v-tooltip="value" class="block truncate font-mono text-xs">{{ value }}</span>
</template>
<template #cell-actions="{ row }">
<div class="flex items-center justify-end">
<ButtonStyled v-if="currentSelected.path === row.path">
<button class="!shadow-none" disabled>
<CheckIcon /> {{ formatMessage(commonMessages.selectedLabel) }}
</button>
</ButtonStyled>
<ButtonStyled v-else>
<button class="!shadow-none" @click="setJavaInstall(row)">
<PlusIcon /> {{ formatMessage(messages.select) }}
</button>
</ButtonStyled>
</div>
</template>
<template #empty-state>
<div class="p-4 text-secondary">
{{ formatMessage(messages.noneFound) }}
</div>
</template>
</Table>
<div class="flex justify-end">
<ButtonStyled type="outlined">
<button
class="!shadow-none !border-surface-4 !border"
@click="$refs.detectJavaModal.hide()"
>
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
</div>
</div>
</ModalWrapper>
</template>
<script setup>
import { CheckIcon, PlusIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
Table,
useVIntl,
} from '@modrinth/ui'
import { onUnmounted, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { java_discovery_listener } from '@/helpers/events'
import { find_filtered_jres } from '@/helpers/jre.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
selectJavaVersion: {
id: 'app.java.select-version',
defaultMessage: 'Select Java version',
},
select: { id: 'app.java.select', defaultMessage: 'Select' },
noneFound: {
id: 'app.java.none-found',
defaultMessage: 'No Java installations found!',
},
version: { id: 'app.java.table.version', defaultMessage: 'Version' },
path: { id: 'app.java.table.path', defaultMessage: 'Path' },
actions: { id: 'app.java.table.actions', defaultMessage: 'Actions' },
})
const chosenInstallOptions = ref([])
const detectJavaModal = ref(null)
const currentSelected = ref({})
const javaInstallColumns = [
{ key: 'version', label: formatMessage(messages.version), width: '9rem' },
{ key: 'path', label: formatMessage(messages.path) },
{ key: 'actions', label: formatMessage(messages.actions), align: 'right', width: '10rem' },
]
const lastRequestedVersion = ref(null)
let unlistenJavaDiscovery = null
defineExpose({
show: async (version, currentSelectedJava) => {
lastRequestedVersion.value = version ?? null
chosenInstallOptions.value = await find_filtered_jres(version, false, false).catch(handleError)
currentSelected.value = currentSelectedJava
if (!currentSelected.value) {
currentSelected.value = { path: '', version: '' }
}
if (!unlistenJavaDiscovery) {
unlistenJavaDiscovery = await java_discovery_listener(refreshInstallOptions)
}
detectJavaModal.value.show()
},
})
async function refreshInstallOptions() {
const updated = await find_filtered_jres(lastRequestedVersion.value, false, false).catch(
() => null,
)
if (updated) {
chosenInstallOptions.value = updated
}
}
onUnmounted(() => {
if (unlistenJavaDiscovery) {
unlistenJavaDiscovery()
unlistenJavaDiscovery = null
}
})
const emit = defineEmits(['submit'])
function setJavaInstall(javaInstall) {
emit('submit', javaInstall)
detectJavaModal.value.hide()
trackEvent('JavaAutoDetect', {
path: javaInstall.path,
version: javaInstall.version,
})
}
</script>

View File

@ -0,0 +1,292 @@
<template>
<JavaDetectionModal ref="detectJavaModal" @submit="commitSelection" />
<div :id="props.id" class="flex flex-wrap justify-between items-center gap-2" :class="{ compact }">
<div class="flex items-center gap-2 w-full min-w-0">
<StyledInput
autocomplete="off"
:disabled="props.disabled"
:model-value="props.modelValue ? props.modelValue.path : ''"
:placeholder="placeholder ?? '/path/to/java'"
wrapper-class="flex-1 min-w-0"
@update:model-value="
(val) => {
emit('update:modelValue', {
...props.modelValue,
path: val,
})
}
"
@focusout="emit('commit', props.modelValue)"
/>
<ButtonStyled
:color="
!hoveringTest && !testingJava
? testingJavaSuccess === true
? 'green'
: 'red'
: 'standard'
"
color-fill="text"
>
<button
class="!shadow-none"
:aria-label="formatMessage(messages.testInstallation)"
:disabled="testingJava || props.disabled"
@click="runTest(props.modelValue?.path)"
@mouseenter="!props.disabled && (hoveringTest = true)"
@mouseleave="hoveringTest = false"
>
<SpinnerIcon v-if="testingJava" class="animate-spin h-4 w-4" />
<CheckCircleIcon
v-else-if="testingJavaSuccess === true && !hoveringTest"
class="h-4 w-4"
/>
<XCircleIcon v-else-if="testingJavaSuccess !== true && !hoveringTest" class="h-4 w-4" />
<RefreshCwIcon v-else-if="!props.disabled" class="h-4 w-4" />
</button>
</ButtonStyled>
</div>
<span class="flex items-center gap-2 m-0">
<ButtonStyled v-if="props.version">
<button
v-tooltip="recommendedInstalled ? formatMessage(messages.alreadyInstalled) : undefined"
class="!shadow-none"
:aria-label="formatMessage(messages.installRecommended)"
:disabled="props.disabled || installingJava || recommendedInstalled"
@click="reinstallJava"
>
<DownloadIcon />
{{
installingJava
? formatMessage(commonMessages.installingLabel)
: formatMessage(messages.installRecommended)
}}
</button>
</ButtonStyled>
<ButtonStyled>
<button
class="!shadow-none"
:aria-label="formatMessage(props.selectAllVersions ? messages.select : messages.detect)"
:disabled="props.disabled"
@click="autoDetect"
>
<SearchIcon />
{{ formatMessage(props.selectAllVersions ? messages.select : messages.detect) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button
class="!shadow-none"
:aria-label="formatMessage(messages.browseForExecutable)"
:disabled="props.disabled"
@click="handleJavaFileInput()"
>
<FolderSearchIcon />
{{ formatMessage(messages.browse) }}
</button>
</ButtonStyled>
</span>
</div>
</template>
<script setup>
import {
CheckCircleIcon,
DownloadIcon,
FolderSearchIcon,
RefreshCwIcon,
SearchIcon,
SpinnerIcon,
XCircleIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { computed, ref, watch } from 'vue'
import JavaDetectionModal from '@/components/ui/JavaDetectionModal.vue'
import useJavaTest from '@/composables/useJavaTest'
import { trackEvent } from '@/helpers/analytics'
import { auto_install_java, find_filtered_jres, get_jre } from '@/helpers/jre.js'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
alreadyInstalled: { id: 'app.java.already-installed', defaultMessage: 'Already installed' },
installRecommended: {
id: 'app.java.install-recommended',
defaultMessage: 'Install recommended',
},
detect: { id: 'app.java.detect', defaultMessage: 'Detect' },
select: { id: 'app.java.select', defaultMessage: 'Select' },
browse: { id: 'app.java.browse', defaultMessage: 'Browse' },
browseForExecutable: {
id: 'app.java.browse-for-executable',
defaultMessage: 'Browse for Java executable',
},
testInstallation: {
id: 'app.java.test-installation',
defaultMessage: 'Test Java installation',
},
})
const props = defineProps({
id: {
type: String,
required: false,
default: null,
},
version: {
type: Number,
required: false,
default: null,
},
modelValue: {
type: Object,
default: () => ({
path: '',
version: '',
}),
},
disabled: {
type: Boolean,
required: false,
default: false,
},
placeholder: {
type: String,
required: false,
default: null,
},
compact: {
type: Boolean,
default: false,
},
selectAllVersions: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['update:modelValue', 'commit'])
const {
testingJava,
javaTestResult: testingJavaSuccess,
testJavaInstallationDebounced,
testJavaInstallation,
} = useJavaTest()
const recommendedJavaTest = useJavaTest()
const installingJava = ref(false)
const hoveringTest = ref(false)
const testVersion = computed(() => (props.selectAllVersions ? null : props.version))
const recommendedInstalled = computed(() => {
if (props.version == null) return false
if (props.selectAllVersions) return recommendedJavaTest.javaTestResult.value === true
return testingJavaSuccess.value === true
})
let hasInitialized = false
async function runTest(path) {
await testJavaInstallation(path, testVersion.value, true)
if (props.version != null) {
await recommendedJavaTest.testJavaInstallation(path, props.version, false)
}
}
function commitSelection(javaVersion) {
emit('update:modelValue', javaVersion)
emit('commit', javaVersion)
}
watch(
() => props.modelValue?.path,
(newPath) => {
if (newPath) {
if (!hasInitialized) {
testJavaInstallation(newPath, testVersion.value, false)
if (props.version != null) {
recommendedJavaTest.testJavaInstallation(newPath, props.version, false)
}
hasInitialized = true
} else {
testJavaInstallationDebounced(newPath, testVersion.value)
if (props.version != null) {
recommendedJavaTest.testJavaInstallationDebounced(newPath, props.version)
}
}
}
},
{ immediate: true },
)
async function handleJavaFileInput() {
const filePath = await open()
if (filePath) {
let result = await get_jre(filePath.path ?? filePath).catch(handleError)
if (!result) {
result = {
path: filePath.path ?? filePath,
version: props.version?.toString() ?? '',
parsed_version: props.version ?? 0,
architecture: 'x86',
}
}
trackEvent('JavaManualSelect', {
version: props.version,
})
commitSelection(result)
}
}
const detectJavaModal = ref(null)
async function autoDetect() {
const filterVersion = props.selectAllVersions ? null : props.version
if (!props.compact) {
detectJavaModal.value.show(filterVersion, props.modelValue)
} else {
const versions = await find_filtered_jres(filterVersion, false, false).catch(handleError)
if (versions?.length > 0) {
commitSelection(versions[0])
}
}
}
async function reinstallJava() {
installingJava.value = true
try {
const path = await auto_install_java(props.version).catch(handleError)
if (!path) return
let result = await get_jre(path).catch(handleError)
if (!result) {
result = {
path: path,
version: props.version?.toString() ?? '',
parsed_version: props.version ?? 0,
architecture: 'x86',
}
}
trackEvent('JavaReInstall', { path: path, version: props.version })
commitSelection(result)
runTest(result.path)
} finally {
installingJava.value = false
}
}
</script>
<style lang="scss" scoped>
</style>

View File

@ -0,0 +1,119 @@
<script setup>
import { DownloadIcon, HeartIcon, TagIcon } from '@modrinth/assets'
import { Avatar, FormattedTag, TagItem, useCompactNumber } from '@modrinth/ui'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { computed } from 'vue'
import { useRouter } from 'vue-router'
dayjs.extend(relativeTime)
const router = useRouter()
const { formatCompactNumber } = useCompactNumber()
const props = defineProps({
project: {
type: Object,
default() {
return {}
},
},
})
const featuredCategory = computed(() => {
if (props.project.display_categories.includes('optimization')) {
return 'optimization'
}
return props.project.display_categories[0] ?? props.project.categories[0]
})
const toColor = computed(() => {
let color = props.project.color
color >>>= 0
const b = color & 0xff
const g = (color >>> 8) & 0xff
const r = (color >>> 16) & 0xff
return 'rgba(' + [r, g, b, 1].join(',') + ')'
})
const toTransparent = computed(() => {
let color = props.project.color
color >>>= 0
const b = color & 0xff
const g = (color >>> 8) & 0xff
const r = (color >>> 16) & 0xff
return (
'linear-gradient(rgba(' +
[r, g, b, 0.03].join(',') +
'), 65%, rgba(' +
[r, g, b, 0.3].join(',') +
'))'
)
})
</script>
<template>
<div
class="card-shadow bg-bg-raised rounded-xl overflow-clip cursor-pointer hover:brightness-90 transition-all"
@click="router.push(`/project/${project.slug}`)"
>
<div
class="w-full aspect-[2/1] bg-cover bg-center bg-no-repeat"
:style="{
'background-color': (project.featured_gallery ?? project.gallery[0]) ? null : toColor,
'background-image': project.featured_gallery
? `url(${project.featured_gallery})`
: project.gallery[0]
? `url(${project.gallery[0]})`
: null,
}"
>
<div
class="badges-wrapper"
:class="{
'no-image': !project.featured_gallery && !project.gallery[0],
}"
:style="{
background: !project.featured_gallery && !project.gallery[0] ? toTransparent : null,
}"
></div>
</div>
<div class="flex flex-col justify-center gap-2 px-4 py-3">
<div class="flex gap-2 items-center">
<Avatar size="48px" :src="project.icon_url" />
<div class="h-full flex items-center font-bold text-contrast leading-normal">
<span class="line-clamp-2">{{ project.title }}</span>
</div>
</div>
<p class="m-0 text-sm font-medium line-clamp-3 leading-tight h-[3.25rem]">
{{ project.description }}
</p>
<div class="flex items-center gap-2 text-sm text-secondary font-semibold mt-auto">
<div
class="flex items-center gap-1 pr-2 border-0 border-r-[1px] border-solid border-button-border"
>
<DownloadIcon />
{{ formatCompactNumber(project.downloads) }}
</div>
<div
class="flex items-center gap-1 pr-2 border-0 border-r-[1px] border-solid border-button-border"
>
<HeartIcon />
{{ formatCompactNumber(project.follows) }}
</div>
<div class="flex items-center gap-1 pr-2">
<TagIcon />
<TagItem>
<FormattedTag :tag="featuredCategory" />
</TagItem>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,191 @@
<script setup lang="ts">
import { SparklesIcon, SpinnerIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { get_memory_status, optimize_memory } from '@/helpers/jre.js'
const props = withDefaults(
defineProps<{
instanceId?: string
memory: { maximum: number; automatic: boolean; optimize_before_launch?: boolean }
showOptimizeButton?: boolean
}>(),
{ instanceId: undefined, showOptimizeButton: false },
)
const { addNotification, handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
used: { id: 'app.memory-display.used', defaultMessage: 'Memory in use' },
game: { id: 'app.memory-display.game', defaultMessage: 'Game allocation' },
remaining: { id: 'app.memory-display.remaining', defaultMessage: 'Remaining after allocation' },
available: { id: 'app.memory-display.available', defaultMessage: '{memory} available' },
optimize: { id: 'app.memory-display.optimize', defaultMessage: 'Optimize memory' },
optimizing: { id: 'app.memory-display.optimizing', defaultMessage: 'Optimizing...' },
optimized: { id: 'app.memory-display.optimized', defaultMessage: 'Memory optimization complete' },
optimizationDescription: {
id: 'app.memory-display.optimization-description',
defaultMessage: 'Free unused Windows working sets and standby memory.',
},
unsupported: {
id: 'app.memory-display.unsupported',
defaultMessage: 'Memory optimization is only available on Windows.',
},
reclaimed: { id: 'app.memory-display.reclaimed', defaultMessage: 'Freed {memory} of memory.' },
})
type MemoryStatus = {
total_bytes: number
available_bytes: number
allocated_mb: number
optimization_supported: boolean
}
const status = ref<MemoryStatus | null>(null)
const optimizing = ref(false)
let timer: ReturnType<typeof setInterval> | undefined
const totalGiB = computed(() => (status.value?.total_bytes ?? 0) / 1024 ** 3)
const availableGiB = computed(() => (status.value?.available_bytes ?? 0) / 1024 ** 3)
const usedGiB = computed(() => Math.max(totalGiB.value - availableGiB.value, 0))
const allocatedGiB = computed(() => (status.value?.allocated_mb ?? props.memory.maximum) / 1024)
const allocatedAvailableGiB = computed(() => Math.min(allocatedGiB.value, availableGiB.value))
const remainingGiB = computed(() => Math.max(availableGiB.value - allocatedGiB.value, 0))
const allocationLimited = computed(() => allocatedGiB.value > availableGiB.value)
const optimizationSupported = computed(() => status.value?.optimization_supported ?? false)
function percentage(value: number) {
return totalGiB.value > 0
? `${Math.max(0, Math.min(100, (value / totalGiB.value) * 100))}%`
: '0%'
}
function formatGiB(value: number) {
return `${value.toFixed(1)} GB`
}
async function refresh() {
try {
status.value = await get_memory_status(
props.instanceId ?? null,
props.memory.maximum,
props.memory.automatic,
)
} catch {
// The display retries on the next interval while the backend is starting.
}
}
async function handleOptimize() {
if (optimizing.value || !optimizationSupported.value) return
optimizing.value = true
try {
const result = await optimize_memory()
if (result?.supported) {
await refresh()
addNotification({
type: 'success',
title: formatMessage(messages.optimized),
text: formatMessage(messages.reclaimed, {
memory: formatGiB(result.reclaimed_bytes / 1024 ** 3),
}),
})
}
} catch (error) {
handleError(error)
} finally {
optimizing.value = false
}
}
watch(
() => [props.instanceId, props.memory.maximum, props.memory.automatic],
() => void refresh(),
)
onMounted(() => {
void refresh()
timer = setInterval(() => void refresh(), 1000)
})
onBeforeUnmount(() => {
if (timer) clearInterval(timer)
})
</script>
<template>
<div class="mt-2 min-w-0">
<div v-if="!status" class="h-10 animate-pulse rounded-lg bg-button-bg" />
<template v-else>
<div
class="flex h-2 w-full overflow-hidden rounded-full bg-bg-gray"
role="meter"
:aria-label="formatMessage(messages.used)"
:aria-valuenow="usedGiB + allocatedAvailableGiB"
aria-valuemin="0"
:aria-valuemax="totalGiB"
>
<span
class="bg-gray transition-[width] duration-500"
:style="{ width: percentage(usedGiB) }"
/>
<span
class="bg-brand transition-[width] duration-500"
:style="{ width: percentage(allocatedAvailableGiB) }"
/>
</div>
<div class="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs">
<span class="flex min-w-0 items-center gap-1.5">
<span class="size-2 shrink-0 rounded-full bg-gray" />
<span class="text-secondary">{{ formatMessage(messages.used) }}</span>
<span class="whitespace-nowrap font-semibold tabular-nums text-contrast">
{{ formatGiB(usedGiB) }} / {{ formatGiB(totalGiB) }}
</span>
</span>
<span class="flex min-w-0 items-center gap-1.5">
<span class="size-2 shrink-0 rounded-full bg-brand" />
<span class="text-secondary">{{ formatMessage(messages.game) }}</span>
<span class="whitespace-nowrap font-semibold tabular-nums text-contrast">
{{ formatGiB(allocatedGiB) }}
<span v-if="allocationLimited"
>({{ formatMessage(messages.available, { memory: formatGiB(availableGiB) }) }})</span
>
</span>
</span>
<span class="flex min-w-0 items-center gap-1.5">
<span class="size-2 shrink-0 rounded-full bg-bg-gray ring-1 ring-inset ring-divider" />
<span class="text-secondary">{{ formatMessage(messages.remaining) }}</span>
<span class="whitespace-nowrap font-semibold tabular-nums text-contrast">
{{ formatGiB(remainingGiB) }}
</span>
</span>
</div>
<div
v-if="showOptimizeButton"
class="mt-3 flex flex-col items-start justify-between gap-3 border-t border-divider pt-3 sm:flex-row sm:items-center"
>
<p class="m-0 min-w-0 flex-1 text-xs leading-tight text-secondary">
{{
formatMessage(
optimizationSupported ? messages.optimizationDescription : messages.unsupported,
)
}}
</p>
<ButtonStyled>
<button
type="button"
:disabled="optimizing || !optimizationSupported"
@click="handleOptimize"
>
<SpinnerIcon v-if="optimizing" class="animate-spin" />
<SparklesIcon v-else />
{{ formatMessage(optimizing ? messages.optimizing : messages.optimize) }}
</button>
</ButtonStyled>
</div>
</template>
</div>
</template>

View File

@ -0,0 +1,664 @@
<script setup lang="ts">
import { ExternalIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
defineMessages,
injectModrinthClient,
injectNotificationManager,
NewModal,
shareLogs,
useVIntl,
} from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import CrashAIExplanationModal from '@/components/ui/CrashAIExplanationModal.vue'
import CrashModChangesModal from '@/components/ui/CrashModChangesModal.vue'
import {
clearCrashAnalysis,
type CrashAnalysisResult,
refreshCrashAnalysis,
} from '@/composables/useCrashAnalysis'
import type { MinecraftLaunchErrorPayload } from '@/composables/useMinecraftLaunchError'
import { getAIState } from '@/helpers/ai'
import { process_listener } from '@/helpers/events.js'
import { get as getInstance } from '@/helpers/instance'
import { get_crash_analysis_ai_settings } from '@/helpers/logs.js'
import { shouldShowMinecraftCrash } from '@/helpers/process.js'
interface CrashModalPayload extends MinecraftLaunchErrorPayload {
title?: string
summary?: string
body?: string
hint?: string
}
interface ProcessEvent {
instance_id: string
uuid: string
event: 'launched' | 'finished'
crashed?: boolean
}
interface CrashWarningPayload extends MinecraftLaunchErrorPayload {
kind: 'minecraft_crash'
}
type Unlisten = () => void
const { formatMessage } = useVIntl()
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const modal = ref<InstanceType<typeof NewModal>>()
const aiModal = ref<InstanceType<typeof CrashAIExplanationModal>>()
const modChangesModal = ref<InstanceType<typeof CrashModChangesModal>>()
const payload = ref<Partial<CrashModalPayload>>({})
const sharing = ref(false)
let lastAnalysis: CrashAnalysisResult | null = null
const modChangesAvailable = ref(false)
const activeRuns = new Map<string, string>()
const lastShownAt = new Map<string, number>()
let unlistenProcess: Unlisten | undefined
let mounted = false
let analysisVersion = 0
const aiAvailable = ref(false)
const messages = defineMessages({
title: {
id: 'app.minecraft-crash.title',
defaultMessage: '{instanceName} crashed',
},
body: {
id: 'app.minecraft-crash.body',
defaultMessage:
'Do not send a screenshot of this window when asking for help. Export the error report instead so the crash report, game logs, debug log, and JVM details can be checked together.',
},
summary: {
id: 'app.minecraft-crash.summary',
defaultMessage: 'Minecraft stopped unexpectedly.',
},
supportHint: {
id: 'app.minecraft-crash.support-hint',
defaultMessage:
'When asking for help, send the exported ZIP. Do not send only a screenshot of this window because it does not contain the diagnostic evidence.',
},
previewInstance: {
id: 'app.minecraft-crash.preview-instance',
defaultMessage: 'Minecraft test instance',
},
launchFailedTitle: {
id: 'app.minecraft-crash.launch-failed-title',
defaultMessage: '{instanceName} could not start',
},
launchFailedSummary: {
id: 'app.minecraft-crash.launch-failed-summary',
defaultMessage: 'Minecraft failed during launch preparation.',
},
exitedBeforeInitialization: {
id: 'app.minecraft-crash.exited-before-initialization',
defaultMessage:
'The Java process exited before it could connect to the launcher. The selected Java version is probably incompatible with this Minecraft or Mod loader version. Select the Java version required by the instance, then try again.',
},
initializationTimedOut: {
id: 'app.minecraft-crash.initialization-timed-out',
defaultMessage:
'The Java process started but did not connect to the launcher within 15 seconds. Check the selected Java version and any wrapper command, then try again.',
},
preparationTimedOut: {
id: 'app.minecraft-crash.preparation-timed-out',
defaultMessage:
'Launch preparation did not finish within 60 seconds and was cancelled. Check the Java path, launch hooks, wrapper command, and network connection, then try again.',
},
launchFailureHint: {
id: 'app.minecraft-crash.launch-failure-hint',
defaultMessage:
'Open the Minecraft logs to view the captured Java output. When asking for help, export and send the complete Minecraft diagnostic package.',
},
analyzing: {
id: 'app.minecraft-crash.analyzing',
defaultMessage: 'Analyzing the logs from this launch...',
},
evidence: {
id: 'app.minecraft-crash.evidence',
defaultMessage: 'Reference evidence: {evidence}',
},
viewModChanges: {
id: 'app.minecraft-crash.view-mod-changes',
defaultMessage: 'View Mod changes',
},
modChangesTitle: {
id: 'app.minecraft-crash.mod-changes-title',
defaultMessage: 'Possible issue: Mod files changed since the last successful launch',
},
modChangesAction: {
id: 'app.minecraft-crash.mod-changes-action',
defaultMessage:
'Review the changed Mod files and restore the previous setup manually if the crash started after those changes.',
},
jvmArgumentsTitle: {
id: 'app.minecraft-crash.diagnosis.jvm-arguments.title',
defaultMessage: 'Possible issue: the JVM arguments are invalid',
},
jvmArgumentsAction: {
id: 'app.minecraft-crash.diagnosis.jvm-arguments.action',
defaultMessage:
'You can try removing the JVM argument shown below from the instance settings, then launch again.',
},
javaTooNewTitle: {
id: 'app.minecraft-crash.diagnosis.java-too-new.title',
defaultMessage: 'Possible issue: the selected Java version is too new',
},
javaTooNewAction: {
id: 'app.minecraft-crash.diagnosis.java-too-new.action',
defaultMessage:
'You can try selecting the Java major version required by this Minecraft and Mod loader version, then launch again.',
},
javaIncompatibleTitle: {
id: 'app.minecraft-crash.diagnosis.java-incompatible.title',
defaultMessage: 'Possible issue: the Java version is incompatible',
},
javaIncompatibleAction: {
id: 'app.minecraft-crash.diagnosis.java-incompatible.action',
defaultMessage:
'You can try selecting the Java version requested in the error below, or using a compatible build of the affected Mod.',
},
java32BitTitle: {
id: 'app.minecraft-crash.diagnosis.java-32bit.title',
defaultMessage: 'Possible issue: 32-bit Java cannot allocate enough memory',
},
java32BitAction: {
id: 'app.minecraft-crash.diagnosis.java-32bit.action',
defaultMessage:
'You can try installing and selecting a 64-bit Java runtime, then launch again.',
},
java11RequiredTitle: {
id: 'app.minecraft-crash.diagnosis.java-11-required.title',
defaultMessage: 'Possible issue: a Mod requires Java 11',
},
java11RequiredAction: {
id: 'app.minecraft-crash.diagnosis.java-11-required.action',
defaultMessage:
'You can try selecting Java 11, or installing a build of the affected Mod that supports the current Java version.',
},
openJ9Title: {
id: 'app.minecraft-crash.diagnosis.openj9.title',
defaultMessage: 'Possible issue: OpenJ9 is not compatible with this instance',
},
openJ9Action: {
id: 'app.minecraft-crash.diagnosis.openj9.action',
defaultMessage:
'You can try selecting a HotSpot-based Java runtime, such as the bundled Minecraft runtime or Eclipse Temurin.',
},
jdkRuntimeTitle: {
id: 'app.minecraft-crash.diagnosis.jdk-runtime.title',
defaultMessage: 'Possible issue: the selected JDK is not compatible',
},
jdkRuntimeAction: {
id: 'app.minecraft-crash.diagnosis.jdk-runtime.action',
defaultMessage:
'You can try selecting a standard HotSpot Java runtime for this Minecraft version.',
},
forgeJavaTitle: {
id: 'app.minecraft-crash.diagnosis.forge-java.title',
defaultMessage: 'Possible issue: Forge is not compatible with the selected Java version',
},
forgeJavaAction: {
id: 'app.minecraft-crash.diagnosis.forge-java.action',
defaultMessage:
'You can try using the Java version expected by this Forge release, or updating Forge.',
},
outOfMemoryTitle: {
id: 'app.minecraft-crash.diagnosis.out-of-memory.title',
defaultMessage: 'Possible issue: Minecraft ran out of memory',
},
outOfMemoryAction: {
id: 'app.minecraft-crash.diagnosis.out-of-memory.action',
defaultMessage:
'You can try increasing the instance memory allocation, or removing memory-heavy Mods and resource packs.',
},
diskSpaceTitle: {
id: 'app.minecraft-crash.diagnosis.disk-space.title',
defaultMessage: 'Possible issue: the disk ran out of free space',
},
diskSpaceAction: {
id: 'app.minecraft-crash.diagnosis.disk-space.action',
defaultMessage:
'Free space on the drive containing this instance, then launch Minecraft again.',
},
fileInUseTitle: {
id: 'app.minecraft-crash.diagnosis.file-in-use.title',
defaultMessage: 'Possible issue: another process is using a required file',
},
fileInUseAction: {
id: 'app.minecraft-crash.diagnosis.file-in-use.action',
defaultMessage:
'Close the program named in the log, including other launchers, backup tools, or antivirus scans, then launch again.',
},
knownFailureTitle: {
id: 'app.minecraft-crash.diagnosis.known-failure.title',
defaultMessage: 'Possible issue: a specific launch problem was detected',
},
knownFailureAction: {
id: 'app.minecraft-crash.diagnosis.known-failure.action',
defaultMessage:
'This is an automatic guess, not a guaranteed diagnosis. Open the log analysis for the full context before applying the suggested fix.',
},
shareDiagnostic: {
id: 'app.minecraft-crash.share-diagnostic',
defaultMessage: 'Share diagnostic',
},
sharingDiagnostic: {
id: 'app.minecraft-crash.sharing-diagnostic',
defaultMessage: 'Sharing diagnostic...',
},
shareFailed: {
id: 'app.minecraft-crash.share-failed',
defaultMessage: 'Failed to share the diagnostic',
},
shareTruncated: {
id: 'app.minecraft-crash.share-truncated',
defaultMessage: 'The diagnostic log is too large, so only the last 9 MB was uploaded.',
},
shareCopied: {
id: 'app.minecraft-crash.share-copied',
defaultMessage: 'Diagnostic link copied to your clipboard',
},
shareReady: {
id: 'app.minecraft-crash.share-ready',
defaultMessage: 'Diagnostic link is ready to share',
},
copyLink: {
id: 'app.minecraft-crash.copy-link',
defaultMessage: 'Copy link',
},
aiAnalyze: {
id: 'app.crash-analysis.ai.action',
defaultMessage: 'Use AI to explain',
},
noLogContent: {
id: 'app.minecraft-crash.no-log-content',
defaultMessage:
'No log content was found to share or analyze. Make sure the instance has logs generated in the last few minutes.',
},
})
const diagnosisMessages = {
jvm_arguments: [messages.jvmArgumentsTitle, messages.jvmArgumentsAction],
java_too_new: [messages.javaTooNewTitle, messages.javaTooNewAction],
java_incompatible: [messages.javaIncompatibleTitle, messages.javaIncompatibleAction],
java_32bit: [messages.java32BitTitle, messages.java32BitAction],
java_11_required: [messages.java11RequiredTitle, messages.java11RequiredAction],
openj9: [messages.openJ9Title, messages.openJ9Action],
jdk_runtime: [messages.jdkRuntimeTitle, messages.jdkRuntimeAction],
forge_java_incompatible: [messages.forgeJavaTitle, messages.forgeJavaAction],
out_of_memory: [messages.outOfMemoryTitle, messages.outOfMemoryAction],
disk_space: [messages.diskSpaceTitle, messages.diskSpaceAction],
file_in_use: [messages.fileInUseTitle, messages.fileInUseAction],
} as const
const title = computed(
() =>
payload.value.title ||
formatMessage(messages.title, {
instanceName: payload.value.instance_name || 'Minecraft',
}),
)
const summary = computed(() => payload.value.summary || formatMessage(messages.summary))
const body = computed(() => payload.value.body || formatMessage(messages.body))
const hint = computed(() => payload.value.hint || formatMessage(messages.supportHint))
const showSupportHint = computed(() => hint.value !== formatMessage(messages.supportHint))
function applyAnalysis(
modalPayload: CrashModalPayload,
analysis: CrashAnalysisResult | null,
): CrashModalPayload {
const finding = analysis?.findings[0]
const modChanges = analysis?.mod_changes ?? []
if (!finding && modChanges.length === 0) return modalPayload
const diagnosis = finding
? diagnosisMessages[finding.id as keyof typeof diagnosisMessages]
: undefined
const [titleMessage, actionMessage] = diagnosis ?? [
messages.knownFailureTitle,
messages.knownFailureAction,
]
const resolvedTitleMessage = finding ? titleMessage : messages.modChangesTitle
const resolvedActionMessage = finding ? actionMessage : messages.modChangesAction
const evidence = finding?.evidence[0]
return {
...modalPayload,
summary: formatMessage(resolvedTitleMessage),
body: formatMessage(resolvedActionMessage),
hint: evidence
? formatMessage(messages.evidence, {
evidence: `${evidence.filename}:${evidence.line} - ${evidence.text}`,
})
: modalPayload.hint,
/*
...(false
? {
hint: `${formatMessage(messages.modChanges, {
changes: modChanges.map((change) => `${change.kind}: ${change.filename}`).join('; '),
})}${
evidence
? ` ${formatMessage(messages.evidence, {
evidence: `${evidence.filename}:${evidence.line} - ${evidence.text}`,
})}`
: ''
}`,
}
: {}),
*/
}
}
function show(modalPayload: CrashModalPayload, isPreview = false): boolean {
if (!isPreview) {
const now = Date.now()
const lastShown = lastShownAt.get(modalPayload.instance_id) ?? 0
if (now - lastShown < 5000) return false
lastShownAt.set(modalPayload.instance_id, now)
}
analysisVersion += 1
payload.value = modalPayload
modal.value?.show()
return true
}
function openModChanges(): void {
if (lastAnalysis?.mod_changes.length) modChangesModal.value?.show(lastAnalysis)
}
function launchErrorText(error: unknown): string {
if (typeof error === 'string') return error
if (error && typeof error === 'object') {
const record = error as Record<string, unknown>
const values = [record.message, record.error, record.cause]
.filter((value): value is string => typeof value === 'string')
.join('\n')
if (values) return values
try {
return JSON.stringify(error)
} catch {
return ''
}
}
return String(error)
}
function launchFailureBody(error: unknown): string | null {
const errorText = launchErrorText(error)
if (errorText.includes('Minecraft exited before launcher initialization completed')) {
return formatMessage(messages.exitedBeforeInitialization)
}
if (errorText.includes('Minecraft launcher initialization did not respond')) {
return formatMessage(messages.initializationTimedOut)
}
if (errorText.includes('Minecraft launch preparation timed out')) {
return formatMessage(messages.preparationTimedOut)
}
return null
}
function isLaunchFailure(error: unknown): boolean {
return launchFailureBody(error) !== null
}
async function analyzeAndUpdate(
modalPayload: CrashModalPayload,
fallbackHint?: string,
): Promise<CrashAnalysisResult | null> {
const version = analysisVersion
const analysis = await refreshCrashAnalysis(modalPayload.instance_id).catch((error) => {
console.error('Failed to analyze Minecraft crash', error)
return null
})
lastAnalysis = analysis
modChangesAvailable.value = !!analysis?.mod_changes.length
if (mounted && version === analysisVersion) {
payload.value = applyAnalysis(modalPayload, analysis)
if (!analysis?.findings.length && fallbackHint) payload.value.hint = fallbackHint
}
return analysis
}
async function handleLaunchError(
error: unknown,
launchPayload: MinecraftLaunchErrorPayload,
): Promise<boolean> {
const failureBody = launchFailureBody(error)
if (!failureBody) return false
const instanceName = launchPayload.instance_name || 'Minecraft'
const modalPayload: CrashModalPayload = {
...launchPayload,
title: formatMessage(messages.launchFailedTitle, { instanceName }),
summary: formatMessage(messages.launchFailedSummary),
body: failureBody,
hint: formatMessage(messages.analyzing),
}
if (!show(modalPayload)) return true
await analyzeAndUpdate(modalPayload, formatMessage(messages.launchFailureHint))
return true
}
async function handleWarning(warning: CrashWarningPayload): Promise<void> {
const modalPayload = { ...warning, hint: formatMessage(messages.analyzing) }
if (!show(modalPayload)) return
await analyzeAndUpdate(modalPayload)
}
function showPreview(): void {
show(
{
instance_id: 'preview',
instance_name: formatMessage(messages.previewInstance),
},
true,
)
}
const shareUrl = ref('')
function notifyNoLogContent(): void {
addNotification({
title: formatMessage(messages.noLogContent),
type: 'warning',
})
}
async function shareDiagnostic(): Promise<void> {
if (sharing.value) return
if (!lastAnalysis?.combined_log) {
notifyNoLogContent()
return
}
sharing.value = true
shareUrl.value = ''
try {
const result = await shareLogs(client, lastAnalysis.combined_log)
if (result.truncated) {
addNotification({
title: formatMessage(messages.shareTruncated),
type: 'warning',
})
}
shareUrl.value = result.url
try {
await navigator.clipboard.writeText(result.url)
addNotification({
title: formatMessage(messages.shareCopied),
type: 'success',
})
} catch (error) {
console.error('Failed to copy shared diagnostic URL', error)
addNotification({
title: formatMessage(messages.shareReady),
type: 'success',
})
}
} catch (error) {
console.error('Failed to share crash diagnostic', error)
addNotification({
title: formatMessage(messages.shareFailed),
type: 'error',
})
} finally {
sharing.value = false
}
}
async function copyShareUrl(): Promise<void> {
if (!shareUrl.value) return
try {
await navigator.clipboard.writeText(shareUrl.value)
addNotification({
title: formatMessage(messages.shareCopied),
type: 'success',
})
} catch (error) {
console.error('Failed to copy share URL', error)
}
}
function _openAIAnalysis(): void {
if (!lastAnalysis?.combined_log) {
notifyNoLogContent()
return
}
aiModal.value?.show(payload.value.instance_id!)
}
async function refreshAIAvailability(): Promise<void> {
try {
const [settings, state] = await Promise.all([get_crash_analysis_ai_settings(), getAIState()])
const provider = state.providers.find((item) => item.provider_id === settings.provider_id)
aiAvailable.value =
settings.enabled &&
state.settings.enabled &&
!!provider?.enabled &&
provider.models.some((model) => model.id === settings.model_id && model.enabled)
} catch {
aiAvailable.value = false
}
}
async function handleProcessEvent(event: ProcessEvent): Promise<void> {
if (event.event === 'launched') {
activeRuns.set(event.instance_id, event.uuid)
clearCrashAnalysis(event.instance_id)
modChangesAvailable.value = false
return
}
if (event.event !== 'finished' || activeRuns.get(event.instance_id) !== event.uuid) return
if (!shouldShowMinecraftCrash(event.crashed)) {
activeRuns.delete(event.instance_id)
return
}
await new Promise((resolve) => setTimeout(resolve, 2000))
if (!mounted || activeRuns.get(event.instance_id) !== event.uuid) return
try {
const analysis = await refreshCrashAnalysis(event.instance_id).catch((error) => {
console.error('Failed to analyze finished Minecraft process', error)
return null
})
lastAnalysis = analysis
modChangesAvailable.value = !!analysis?.mod_changes.length
if (!mounted) return
const instance = await getInstance(event.instance_id).catch(() => null)
if (!mounted) return
show(
applyAnalysis(
{
instance_id: event.instance_id,
instance_name: instance?.name || 'Minecraft',
},
analysis,
),
)
} finally {
if (activeRuns.get(event.instance_id) === event.uuid) activeRuns.delete(event.instance_id)
}
}
onMounted(async () => {
mounted = true
void refreshAIAvailability()
const unlisten = await process_listener((event: ProcessEvent) => void handleProcessEvent(event))
if (!mounted) {
unlisten()
return
}
unlistenProcess = unlisten
})
onUnmounted(() => {
mounted = false
analysisVersion += 1
activeRuns.clear()
unlistenProcess?.()
})
defineExpose({ handleLaunchError, handleWarning, isLaunchFailure, showPreview, openAIAnalysis: _openAIAnalysis })
</script>
<template>
<NewModal ref="modal" :header="title" fade="danger" max-width="560px">
<div class="flex flex-col gap-4">
<Admonition type="critical" :header="summary">
{{ body }}
</Admonition>
<p class="m-0 text-secondary">
{{ hint }}
</p>
<p v-if="showSupportHint" class="m-0 text-secondary">
{{ formatMessage(messages.supportHint) }}
</p>
<div v-if="shareUrl" class="flex items-center gap-2 rounded-lg bg-surface-2 p-3">
<ExternalIcon class="h-4 w-4 shrink-0 text-secondary" />
<a
:href="shareUrl"
target="_blank"
rel="noopener noreferrer"
class="min-w-0 flex-1 truncate text-primary underline"
>
{{ shareUrl }}
</a>
<ButtonStyled type="outlined">
<button @click="copyShareUrl">
{{ formatMessage(messages.copyLink) }}
</button>
</ButtonStyled>
</div>
</div>
<template #actions>
<div class="flex flex-wrap justify-end gap-2">
<ButtonStyled type="outlined">
<button :disabled="sharing" @click="shareDiagnostic">
{{
sharing
? formatMessage(messages.sharingDiagnostic)
: formatMessage(messages.shareDiagnostic)
}}
</button>
</ButtonStyled>
<ButtonStyled v-if="aiAvailable" color="brand">
<button @click="openAIAnalysis">
{{ formatMessage(messages.aiAnalyze) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="modChangesAvailable" type="outlined">
<button @click="openModChanges">
{{ formatMessage(messages.viewModChanges) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
<CrashAIExplanationModal ref="aiModal" />
<CrashModChangesModal ref="modChangesModal" />
</template>

View File

@ -0,0 +1,164 @@
<script setup lang="ts">
import { ExternalIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, onUnmounted, ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { begin_device_login, poll_device_login } from '@/helpers/auth'
type MinecraftCredential = {
account_type: 'microsoft'
profile: {
id: string
name: string
}
}
type DeviceLoginFlow = {
device_code: string
user_code: string
verification_uri: string
expires_in: number
interval: number
}
type DeviceLoginPoll =
| { status: 'pending'; slow_down: boolean }
| { status: 'complete'; credentials: MinecraftCredential }
const emit = defineEmits<{
complete: [credentials: MinecraftCredential]
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof ModalWrapper> | null>(null)
const busy = ref(false)
const deviceFlow = ref<DeviceLoginFlow | null>(null)
const deviceError = ref<string | null>(null)
let devicePollTimer: ReturnType<typeof setTimeout> | undefined
let deviceExpiresAt = 0
const messages = defineMessages({
title: { id: 'minecraft-login.title', defaultMessage: 'Sign in to Minecraft' },
deviceStarting: {
id: 'minecraft-login.device-starting',
defaultMessage: 'Requesting a device code...',
},
deviceDescription: {
id: 'minecraft-login.device-description',
defaultMessage: 'Open the page below on any device, then enter this code.',
},
deviceExpired: {
id: 'minecraft-login.device-expired',
defaultMessage: 'This device code expired. Start again to receive a new code.',
},
openVerification: {
id: 'minecraft-login.open-verification',
defaultMessage: 'Open verification page',
},
})
const verificationUrl = computed(() => deviceFlow.value?.verification_uri ?? '')
function clearDevicePolling() {
if (devicePollTimer !== undefined) {
clearTimeout(devicePollTimer)
devicePollTimer = undefined
}
}
function resetDeviceLogin() {
clearDevicePolling()
deviceFlow.value = null
deviceError.value = null
deviceExpiresAt = 0
}
function hide() {
resetDeviceLogin()
modal.value?.hide()
}
function showDeviceLogin() {
resetDeviceLogin()
modal.value?.show()
void startDeviceLogin()
}
async function finishLogin(credentials: MinecraftCredential) {
emit('complete', credentials)
hide()
}
async function pollDeviceLogin(delay: number) {
const deviceCode = deviceFlow.value?.device_code
if (!deviceCode || Date.now() >= deviceExpiresAt) {
deviceError.value = formatMessage(messages.deviceExpired)
return
}
try {
const result = (await poll_device_login(deviceCode)) as DeviceLoginPoll
if (deviceFlow.value?.device_code !== deviceCode) return
if (result.status === 'complete') {
await finishLogin(result.credentials)
return
}
const nextDelay = result.slow_down ? delay + 5000 : delay
devicePollTimer = setTimeout(() => void pollDeviceLogin(nextDelay), nextDelay)
} catch (error) {
deviceError.value = error instanceof Error ? error.message : String(error)
}
}
async function openDeviceVerification() {
if (!verificationUrl.value) return
await openUrl(verificationUrl.value)
}
async function startDeviceLogin() {
if (busy.value) return
busy.value = true
deviceError.value = null
try {
const flow = (await begin_device_login()) as DeviceLoginFlow
deviceFlow.value = flow
deviceExpiresAt = Date.now() + flow.expires_in * 1000
void pollDeviceLogin(Math.max(flow.interval, 1) * 1000)
} catch (error) {
deviceError.value = error instanceof Error ? error.message : String(error)
} finally {
busy.value = false
}
}
onUnmounted(clearDevicePolling)
defineExpose({ showDeviceLogin, hide })
</script>
<template>
<ModalWrapper ref="modal" :header="formatMessage(messages.title)" :on-hide="resetDeviceLogin">
<div class="flex min-w-[24rem] flex-col gap-4">
<template v-if="deviceFlow">
<p class="m-0 text-secondary">{{ formatMessage(messages.deviceDescription) }}</p>
<ButtonStyled>
<button @click="openDeviceVerification">
<ExternalIcon /> {{ formatMessage(messages.openVerification) }}
</button>
</ButtonStyled>
<code
class="rounded-xl bg-surface-3 px-4 py-3 text-center text-xl font-bold tracking-[0.18em] text-contrast"
>
{{ deviceFlow.user_code }}
</code>
<p v-if="deviceError" class="m-0 text-sm text-red">{{ deviceError }}</p>
</template>
<template v-else>
<p v-if="busy" class="m-0 text-secondary">{{ formatMessage(messages.deviceStarting) }}</p>
<p v-if="deviceError" class="m-0 text-sm text-red">{{ deviceError }}</p>
</template>
</div>
</ModalWrapper>
</template>

View File

@ -0,0 +1,179 @@
<script setup>
import { CheckIcon } from '@modrinth/assets'
import { Badge, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { SwapIcon } from '@/assets/icons/index.js'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
import { update_managed_modrinth_version } from '@/helpers/instance'
import { releaseColor } from '@/helpers/utils'
const props = defineProps({
versions: {
type: Array,
required: true,
},
instance: {
type: Object,
default: null,
},
})
const { formatMessage } = useVIntl()
const messages = defineMessages({
changeVersion: {
id: 'app.modpack.change-version',
defaultMessage: 'Change modpack version',
},
name: { id: 'app.modpack.version-name', defaultMessage: 'Name' },
supports: { id: 'app.modpack.version-supports', defaultMessage: 'Supports' },
})
defineExpose({
show: () => {
modpackVersionModal.value.show()
},
})
const emit = defineEmits(['finish-install'])
const filteredVersions = computed(() => {
return props.versions
})
const modpackVersionModal = ref(null)
const installedVersion = computed(() => props.instance?.link?.version_id)
const installing = computed(() => props.instance.install_stage !== 'installed')
const inProgress = ref(false)
const switchVersion = async (versionId) => {
modpackVersionModal.value.hide()
inProgress.value = true
await update_managed_modrinth_version(props.instance.id, versionId)
inProgress.value = false
emit('finish-install')
}
const onHide = () => {
if (!inProgress.value) {
emit('finish-install')
}
}
</script>
<template>
<ModalWrapper
ref="modpackVersionModal"
class="modpack-version-modal"
:header="formatMessage(messages.changeVersion)"
:on-hide="onHide"
>
<div class="modal-body flex flex-col gap-3">
<SymlinkInstanceWarning
v-if="instance?.symlink_target"
:symlink-target="instance.symlink_target"
/>
<div v-if="instance.link" class="mod-card">
<div class="table border border-bg">
<div class="table-row grid-cols-[min-content_1fr_1fr] table-head">
<div class="table-cell table-text w-16 p-4" />
<div class="name-cell table-cell table-text">
{{ formatMessage(messages.name) }}
</div>
<div class="table-cell table-text">{{ formatMessage(messages.supports) }}</div>
</div>
<div class="overflow-y-auto max-h-[25rem]">
<div
v-for="version in filteredVersions"
:key="version.id"
class="table-row grid-cols-[min-content_1fr_1fr] selectable"
@click="$router.push(`/project/${version.project_id}/version/${version.id}`)"
>
<div class="table-cell table-text">
<ButtonStyled
circular
:color="version.id === installedVersion ? 'standard' : 'brand'"
>
<button
:disabled="inProgress || installing || version.id === installedVersion"
@click.stop="() => switchVersion(version.id)"
>
<SwapIcon v-if="version.id !== installedVersion" />
<CheckIcon v-else />
</button>
</ButtonStyled>
</div>
<div class="name-cell table-cell table-text">
<div class="version-link">
{{ version.name.charAt(0).toUpperCase() + version.name.slice(1) }}
<div class="version-badge">
<div class="channel-indicator mr-2">
<Badge
:color="releaseColor(version.version_type)"
:type="
version.version_type.charAt(0).toUpperCase() +
version.version_type.slice(1)
"
/>
</div>
<div>
{{ version.version_number }}
</div>
</div>
</div>
</div>
<div class="table-cell table-text stacked-text">
<span>
{{
version.loaders
.map((str) => str.charAt(0).toUpperCase() + str.slice(1))
.join(', ')
}}
</span>
<span>
{{ version.game_versions.join(', ') }}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
</ModalWrapper>
</template>
<style scoped lang="scss">
.card-row {
display: flex;
align-items: center;
justify-content: space-between;
background-color: var(--color-raised-bg);
}
.mod-card {
display: flex;
flex-direction: column;
gap: 1rem;
overflow: hidden;
margin-top: 0.5rem;
}
.version-link {
display: flex;
flex-direction: column;
gap: 0.25rem;
.version-badge {
display: flex;
flex-wrap: wrap;
}
}
.stacked-text {
display: flex;
flex-direction: column;
gap: 0.25rem;
align-items: flex-start;
}
</style>

View File

@ -0,0 +1,88 @@
<template>
<RouterLink
v-if="typeof to === 'string' && !disabled"
:to="to"
v-bind="$attrs"
:active-class="isSubpage ? '' : undefined"
:class="{
'router-link-active': isPrimary && isPrimary(route),
'subpage-active': isSubpage && isSubpage(route),
}"
class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
>
<slot />
</RouterLink>
<button
v-else-if="typeof to === 'string'"
v-bind="$attrs"
type="button"
aria-disabled="true"
tabindex="-1"
:class="{
'router-link-active': isPrimary && isPrimary(route),
'subpage-active': isSubpage && isSubpage(route),
}"
class="w-12 h-12 text-primary rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
@click.prevent
@keydown.enter.prevent
@keyup.enter.prevent
@keydown.space.prevent
@keyup.space.prevent
>
<slot />
</button>
<button
v-else
v-bind="$attrs"
class="button-animation border-none text-primary cursor-pointer w-12 h-12 rounded-full flex items-center justify-center text-2xl transition-all bg-transparent hover:bg-button-bg hover:text-contrast"
:disabled="disabled"
@click="to"
>
<slot />
</button>
</template>
<script setup lang="ts">
import type { RouteLocationNormalizedLoaded } from 'vue-router'
import { RouterLink, useRoute } from 'vue-router'
const route = useRoute()
type RouteFunction = (route: RouteLocationNormalizedLoaded) => boolean
withDefaults(
defineProps<{
to: (() => void) | string
isPrimary?: RouteFunction
isSubpage?: RouteFunction
highlightOverride?: boolean
disabled?: boolean
}>(),
{
disabled: false,
isPrimary: undefined,
isSubpage: undefined,
},
)
defineOptions({
inheritAttrs: false,
})
</script>
<style lang="scss" scoped>
.router-link-active,
.subpage-active {
svg {
filter: drop-shadow(0 0 0.5rem black);
}
}
.router-link-active {
@apply text-[--color-button-text-selected] bg-[--color-button-bg-selected];
}
.subpage-active {
@apply text-contrast bg-button-bg;
}
</style>

View File

@ -0,0 +1,116 @@
<template>
<div ref="rail" class="nav-rail relative flex flex-col gap-[0.5rem]">
<slot />
<div
class="nav-rail-slider pointer-events-none absolute rounded-full"
:class="[
subpageSelected ? 'bg-button-bg' : 'bg-button-bgSelected',
transitionsEnabled ? 'nav-rail-slider-transition' : '',
]"
:style="sliderStyle"
aria-hidden="true"
/>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
const rail = ref<HTMLElement | null>(null)
const hasActive = ref(false)
const subpageSelected = ref(false)
const sliderReady = ref(false)
const transitionsEnabled = ref(false)
const top = ref(0)
const bottom = ref(0)
const left = ref(0)
const width = ref(0)
const topDelay = ref('0ms')
const bottomDelay = ref('0ms')
const STAGGER_DELAY = '120ms'
const sliderStyle = computed(() => ({
top: `${top.value}px`,
bottom: `${bottom.value}px`,
left: `${left.value}px`,
width: `${width.value}px`,
opacity: sliderReady.value && hasActive.value ? 1 : 0,
}))
function positionSlider() {
const container = rail.value
if (!container) return
const el = container.querySelector<HTMLElement>('.router-link-active, .subpage-active')
if (!el?.offsetParent || container.offsetHeight === 0) {
hasActive.value = false
return
}
subpageSelected.value = el.classList.contains('subpage-active')
const newTop = el.offsetTop
const newBottom = container.offsetHeight - el.offsetTop - el.offsetHeight
const movingDown = newTop > top.value
topDelay.value = movingDown ? STAGGER_DELAY : '0ms'
bottomDelay.value = movingDown ? '0ms' : STAGGER_DELAY
top.value = newTop
bottom.value = newBottom
left.value = el.offsetLeft
width.value = el.offsetWidth
hasActive.value = true
if (!sliderReady.value) {
sliderReady.value = true
requestAnimationFrame(() => {
transitionsEnabled.value = true
})
}
}
async function updateSlider() {
await nextTick()
positionSlider()
}
onMounted(updateSlider)
watch(() => [route.path, route.query], updateSlider)
</script>
<style scoped>
.nav-rail :deep(a),
.nav-rail :deep(button) {
position: relative;
z-index: 1;
}
.nav-rail :deep(a.router-link-active),
.nav-rail :deep(a.subpage-active),
.nav-rail :deep(button.router-link-active),
.nav-rail :deep(button.subpage-active) {
background-color: transparent;
}
.nav-rail-slider {
z-index: 0;
}
.nav-rail-slider-transition {
transition:
top 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(topDelay),
bottom 150ms cubic-bezier(0.4, 0, 0.2, 1) v-bind(bottomDelay),
left 150ms cubic-bezier(0.4, 0, 0.2, 1),
width 150ms cubic-bezier(0.4, 0, 0.2, 1),
opacity 250ms cubic-bezier(0.5, 0, 0.2, 1) 50ms;
}
</style>

View File

@ -0,0 +1,33 @@
<template>
<div class="w-full h-2 bg-button-bg rounded-[var(--radius-lg)] overflow-hidden">
<div
class="progress-bar__fill h-full"
:style="{
width: `${progress}%`,
'background-color': error ? 'var(--color-red)' : 'var(--color-brand)',
}"
></div>
</div>
</template>
<script setup>
defineProps({
progress: {
type: Number,
required: true,
validator(value) {
return value >= 0 && value <= 100
},
},
error: {
type: Boolean,
default: false,
},
})
</script>
<style scoped>
.progress-bar__fill {
transition: width 0.3s ease-out;
}
</style>

View File

@ -0,0 +1,85 @@
<script setup>
import { SpinnerIcon } from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui'
import dayjs from 'dayjs'
import { computed, onUnmounted, ref } from 'vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import NavButton from '@/components/ui/NavButton.vue'
import { instance_listener } from '@/helpers/events.js'
import { list } from '@/helpers/instance'
import { useTheming } from '@/store/state'
const { handleError } = injectNotificationManager()
const themeStore = useTheming()
const fullInstanceList = ref([])
const instanceCount = computed(() => themeStore.sidebarInstanceCount)
const recentInstances = computed(() => {
if (instanceCount.value > 0) {
return fullInstanceList.value.slice(0, instanceCount.value)
}
return fullInstanceList.value
})
const getInstances = async () => {
const instances = await list().catch((error) => {
handleError(error)
return []
})
fullInstanceList.value = instances.sort((a, b) => {
const dateACreated = dayjs(a.created)
const dateAPlayed = a.last_played ? dayjs(a.last_played) : dayjs(0)
const dateBCreated = dayjs(b.created)
const dateBPlayed = b.last_played ? dayjs(b.last_played) : dayjs(0)
const dateA = dateACreated.isAfter(dateAPlayed) ? dateACreated : dateAPlayed
const dateB = dateBCreated.isAfter(dateBPlayed) ? dateBCreated : dateBPlayed
if (dateA.isSame(dateB)) {
return a.name.localeCompare(b.name)
}
return dateB - dateA
})
}
await getInstances()
const unlistenInstance = await instance_listener(async (event) => {
if (event.event !== 'synced') {
await getInstances()
}
})
onUnmounted(() => {
unlistenInstance()
})
</script>
<template>
<div v-for="instance in recentInstances" :key="instance.id" v-tooltip.right="instance.name">
<NavButton :to="`/instance/${encodeURIComponent(instance.id)}`" class="relative">
<InstanceIcon
:icon-path="instance.icon_path"
:instance-id="instance.id"
:loader="instance.loader"
size="28px"
:class="`transition-all ${instance.install_stage !== 'installed' ? `brightness-[0.25] scale-[0.85]` : `group-hover:brightness-75`}`"
/>
<div
v-if="instance.install_stage !== 'installed'"
class="absolute inset-0 flex items-center justify-center z-10 pointer-events-none"
>
<SpinnerIcon class="animate-spin w-4 h-4" />
</div>
</NavButton>
</div>
<div v-if="recentInstances.length > 0" class="h-px w-6 mx-auto my-2 bg-divider"></div>
</template>
<style scoped lang="scss"></style>

View File

@ -0,0 +1,136 @@
<template>
<Transition name="splash-fade" @after-leave="onAfterLeave">
<div v-if="!doneLoading" class="fixed inset-0 z-[10000] dark">
<div class="absolute h-screen w-full flex flex-col justify-center items-center gap-4 z-[9998]" data-tauri-drag-region>
<img class="app-logo" src="@/assets/axolotl.png" alt="Axolotl Launcher" />
<ProgressBar class="max-w-xs" :progress="Math.min(loadingProgress, 100)" />
<span v-if="message">{{ message }}</span>
</div>
<div class="gradient-bg" data-tauri-drag-region></div>
<div class="cube-bg"></div>
<div class="absolute top-0 left-0 w-full h-full bg-bg z-[9995]"></div>
</div>
</Transition>
</template>
<script setup>
import { defineMessages, injectLoadingState, useVIntl } from '@modrinth/ui'
import { ref, watch } from 'vue'
import ProgressBar from '@/components/ui/ProgressBar.vue'
import { loading_listener } from '@/helpers/events.js'
const doneLoading = ref(false)
const loadingProgress = ref(0)
const message = ref()
const MIN_DISPLAY_MS = 500
const mountedAt = Date.now()
const loading = injectLoadingState()
const { formatMessage } = useVIntl()
const messages = defineMessages({
updatingAppDirectory: {
id: 'app.splash.updating-app-directory',
defaultMessage: 'Updating app directory...',
},
checkingForUpdates: {
id: 'app.splash.checking-for-updates',
defaultMessage: 'Checking for updates...',
},
})
function onAfterLeave() {
loading.setEnabled(true)
}
watch(
[loading.barEnabled, loading.pending],
([barEnabled, pending]) => {
if (barEnabled) {
return
}
if (pending) {
loadingProgress.value = 0
fakeLoadingIncrease()
return
}
const elapsed = Date.now() - mountedAt
const delay = Math.max(0, MIN_DISPLAY_MS - elapsed)
setTimeout(() => {
if (loading.pending.value) {
return
}
doneLoading.value = true
}, delay)
},
{ immediate: true },
)
function fakeLoadingIncrease() {
if (loadingProgress.value < 95) {
setTimeout(() => {
loadingProgress.value += 2
fakeLoadingIncrease()
}, 5)
}
}
loading_listener(async (e) => {
if (e.event.type === 'directory_move') {
loadingProgress.value = 100 * (e.fraction ?? 1)
message.value = formatMessage(messages.updatingAppDirectory)
} else if (e.event.type === 'checking_for_updates') {
loadingProgress.value = 100 * (e.fraction ?? 1)
message.value = formatMessage(messages.checkingForUpdates)
}
})
</script>
<style scoped lang="scss">
.splash-fade-leave-active {
transition: opacity 0.3s ease-in-out;
}
.splash-fade-leave-to {
opacity: 0;
}
.app-logo {
height: min(18rem, 45vh);
width: min(18rem, 45vw);
object-fit: contain;
filter: drop-shadow(0 0 2rem rgba(255, 77, 157, 0.35));
}
.gradient-bg {
position: absolute;
height: 100vh;
width: 100vw;
background:
linear-gradient(180deg, rgba(255, 77, 157, 0.24) 0%, rgba(48, 16, 40, 0.56) 97.29%),
linear-gradient(0deg, rgba(22, 18, 28, 0.68), rgba(22, 18, 28, 0.68));
z-index: 9997;
}
.cube-bg {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
width: 180vw;
height: 180vh;
opacity: 0.8;
background: #16181c url('@/assets/loading/cube.png') center no-repeat;
background-size: contain;
z-index: 9996;
}
</style>

View File

@ -0,0 +1,134 @@
<script setup lang="ts">
import { LinkIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import { injectSymlinkWarningDismiss } from '@/composables/useSymlinkWarningDismiss'
const props = withDefaults(
defineProps<{
symlinkTarget: string
variant?: 'write' | 'delete'
dismissible?: boolean
badgeOnly?: boolean
}>(),
{
variant: 'write',
dismissible: false,
badgeOnly: false,
},
)
const emit = defineEmits<{
dismiss: []
dismissPermanently: []
}>()
const { formatMessage } = useVIntl()
const dismissState = injectSymlinkWarningDismiss()
const effectivelyHidden = computed(() => {
if (props.variant === 'delete') return false
return dismissState?.isHidden.value ?? false
})
const showBadge = computed(() => props.badgeOnly || effectivelyHidden.value)
const effectivelyDismissible = computed(() => {
if (props.variant === 'delete') return false
return props.dismissible || !!dismissState
})
function handleDismiss() {
if (dismissState) {
dismissState.dismissTemp()
}
emit('dismiss')
}
function handleDismissPermanently() {
if (dismissState) {
dismissState.dismissPermanently()
}
emit('dismissPermanently')
}
const messages = defineMessages({
writeHeader: {
id: 'app.symlink-warning.write.header',
defaultMessage: 'Shared instance',
},
writeBody: {
id: 'app.symlink-warning.write.body',
defaultMessage:
'This instance is linked to "{path}". Changes will also affect the original files.',
},
deleteHeader: {
id: 'app.symlink-warning.delete.header',
defaultMessage: 'Delete shared instance',
},
deleteBody: {
id: 'app.symlink-warning.delete.body',
defaultMessage:
'Deleting this instance removes only the launcher entry. The original files at "{path}" will remain.',
},
dismissPermanently: {
id: 'app.symlink-warning.dismiss-permanently',
defaultMessage: "Don't show again for this instance",
},
sharedBadge: {
id: 'app.symlink-warning.shared-badge',
defaultMessage: 'Shared',
},
})
</script>
<template>
<div :class="$attrs.class">
<!-- Collapsed: small badge -->
<div v-if="showBadge" class="flex">
<span
v-tooltip="formatMessage(messages.writeBody, { path: props.symlinkTarget })"
class="inline-flex items-center gap-1 rounded-full bg-bg-orange px-2 py-0.5 text-xs font-medium text-brand-orange"
>
<LinkIcon class="size-3" />
{{ formatMessage(messages.sharedBadge) }}
</span>
</div>
<!-- Expanded: full warning -->
<template v-else>
<Admonition
v-if="props.variant === 'delete'"
type="warning"
:header="formatMessage(messages.deleteHeader)"
>
{{ formatMessage(messages.deleteBody, { path: props.symlinkTarget }) }}
</Admonition>
<Admonition
v-else
type="warning"
:header="formatMessage(messages.writeHeader)"
:dismissible="effectivelyDismissible"
@dismiss="handleDismiss"
>
<span>
{{ formatMessage(messages.writeBody, { path: props.symlinkTarget }) }}
<template v-if="effectivelyDismissible">
{{ ' ' }}
<ButtonStyled
size="small"
type="transparent"
color="orange"
hover-color-fill="background"
>
<button type="button" @click="handleDismissPermanently">
{{ formatMessage(messages.dismissPermanently) }}
</button>
</ButtonStyled>
</template>
</span>
</Admonition>
</template>
</div>
</template>

View File

@ -0,0 +1,157 @@
<script setup lang="ts">
import { configuredXss, renderHighlightedString } from '@modrinth/utils'
import { computed } from 'vue'
import {
prepareDescription,
renderTranslatedDescription,
type TranslationMode,
type TranslationStyle,
} from '@/helpers/translation'
const props = defineProps<{
description: string
active: boolean
translations: Record<string, string>
mode: TranslationMode
style: TranslationStyle
format?: 'markdown' | 'html'
}>()
const renderedDescription = computed(() => {
if (!props.active) {
return props.format === 'html'
? configuredXss.process(props.description ?? '')
: renderHighlightedString(props.description ?? '')
}
return renderTranslatedDescription(
prepareDescription(props.description, props.format),
props.translations,
props.mode,
props.style,
)
})
const translationOnlyClass = computed(() =>
props.active && props.mode === 'translation-only'
? ['ax-translation-only', `ax-translation-style-${props.style}`]
: [],
)
</script>
<template>
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="markdown-body" :class="translationOnlyClass" v-html="renderedDescription" />
</template>
<style scoped>
:deep(.ax-translation-block) {
margin-block: 0.5rem 1rem;
animation: translation-float-in 0.5s ease-out both;
}
:deep(.ax-translation-block > :first-child) {
margin-top: 0;
}
:deep(.ax-translation-block > :last-child) {
margin-bottom: 0;
}
:deep(.ax-translation-style-weakened) {
color: var(--color-secondary) !important;
}
:deep(.ax-translation-style-blur) {
filter: blur(4px);
opacity: 0.75;
transition:
filter 0.1s ease-in-out,
opacity 0.1s ease-in-out;
}
:deep(.ax-translation-style-blur:hover) {
filter: blur(0);
opacity: 1;
}
:deep(.ax-translation-style-blockquote) {
padding: 4px 0 4px 8px;
border-left: 4px solid var(--color-brand);
}
:deep(.ax-translation-style-dashed-line) {
text-decoration: underline dashed var(--color-brand) !important;
text-underline-offset: 5px;
}
:deep(.ax-translation-style-border) {
padding: 2px 4px;
border: 1px solid var(--color-brand);
border-radius: 4px;
}
:deep(.ax-translation-style-text-color) {
color: oklch(0.693 0.17 162.48) !important;
}
:deep(.ax-translation-style-background) {
padding: 2px 4px;
border-radius: 4px;
background-color: color-mix(in srgb, var(--color-brand) 15%, transparent);
}
.ax-translation-only.ax-translation-style-weakened {
color: var(--color-secondary) !important;
}
.ax-translation-only.ax-translation-style-blur {
filter: blur(4px);
opacity: 0.75;
transition:
filter 0.1s ease-in-out,
opacity 0.1s ease-in-out;
}
.ax-translation-only.ax-translation-style-blur:hover {
filter: blur(0);
opacity: 1;
}
.ax-translation-only.ax-translation-style-blockquote {
padding: 4px 0 4px 8px;
border-left: 4px solid var(--color-brand);
}
.ax-translation-only.ax-translation-style-dashed-line {
text-decoration: underline dashed var(--color-brand) !important;
text-underline-offset: 5px;
}
.ax-translation-only.ax-translation-style-border {
padding: 2px 4px;
border: 1px solid var(--color-brand);
border-radius: 4px;
}
.ax-translation-only.ax-translation-style-text-color {
color: oklch(0.693 0.17 162.48) !important;
}
.ax-translation-only.ax-translation-style-background {
padding: 2px 4px;
border-radius: 4px;
background-color: color-mix(in srgb, var(--color-brand) 15%, transparent);
}
@keyframes translation-float-in {
from {
opacity: 0;
transform: translateY(12px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@ -0,0 +1,105 @@
<script setup>
import {
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
ProjectCard,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { get_project_v3, get_version } from '@/helpers/cache.js'
import { injectContentInstall } from '@/providers/content-install'
const { handleError } = injectNotificationManager()
const { install: installVersion } = injectContentInstall()
const { formatMessage } = useVIntl()
const messages = defineMessages({
installProject: { id: 'app.url-install.title', defaultMessage: 'Install {project}' },
installingVersion: {
id: 'app.url-install.version',
defaultMessage: 'Installing version {version}',
},
})
const confirmModal = ref(null)
const project = ref(null)
const version = ref(null)
defineExpose({
async show(event) {
if (event.event === 'InstallVersion') {
version.value = await get_version(event.id, 'must_revalidate').catch(handleError)
project.value = await get_project_v3(version.value.project_id, 'must_revalidate').catch(
handleError,
)
} else {
project.value = await get_project_v3(event.id, 'must_revalidate').catch(handleError)
version.value = await get_version(
project.value.versions[project.value.versions.length - 1],
'must_revalidate',
).catch(handleError)
}
confirmModal.value.show()
},
})
async function install() {
confirmModal.value.hide()
await installVersion(
project.value.id,
version.value.id,
null,
'URLConfirmModal',
() => {},
() => {},
).catch(handleError)
}
</script>
<template>
<ModalWrapper
ref="confirmModal"
:header="formatMessage(messages.installProject, { project: project?.name })"
>
<div class="modal-body flex flex-col items-center justify-center gap-3">
<ProjectCard
:title="project.name"
:link="() => confirmModal.hide()"
:icon-url="project.icon_url"
:summary="project.summary"
:tags="project.display_categories"
:all-tags="project.categories"
:downloads="project.downloads"
:date-updated="project.date_modified"
:banner="project.featured_gallery ?? undefined"
:color="project.color ?? undefined"
layout="list"
class="project-card bg-bg w-full"
/>
<div class="flex w-full flex-row justify-between items-center gap-3">
<div class="markdown-body">
<p>
{{ formatMessage(messages.installingVersion, { version: version.id }) }}
</p>
</div>
<div class="flex flex-row gap-2">
<ButtonStyled color="brand">
<button @click="install">{{ formatMessage(commonMessages.installButton) }}</button>
</ButtonStyled>
</div>
</div>
</div>
</ModalWrapper>
</template>
<style scoped lang="scss">
.project-card {
:deep(.badge) {
border: 1px solid var(--color-raised-bg);
background-color: var(--color-accent-contrast);
}
}
</style>

View File

@ -0,0 +1,107 @@
<template>
<section
v-if="showControls"
class="flex items-center gap-2 mr-1.5"
data-tauri-drag-region-exclude
>
<ButtonStyled type="transparent" circular>
<button class="relative expanded-button" @click="() => getCurrentWindow().minimize()">
<MinimizeIcon />
</button>
</ButtonStyled>
<ButtonStyled type="transparent" circular>
<button class="relative expanded-button" @click="() => getCurrentWindow().toggleMaximize()">
<RestoreIcon v-if="isMaximized" />
<MaximizeIcon v-else />
</button>
</ButtonStyled>
<ButtonStyled
type="transparent"
color="red"
color-fill="none"
hover-color-fill="background"
circular
>
<button class="relative expanded-button close-button" @click="handleClose">
<XIcon />
</button>
</ButtonStyled>
</section>
</template>
<script setup>
import { MaximizeIcon, MinimizeIcon, RestoreIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { get as getSettings } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils.js'
import { useTheming } from '@/store/state'
const themeStore = useTheming()
const nativeDecorations = ref(true)
const isMaximized = ref(false)
const os = ref('')
const unlistenResize = ref(null)
let resizeTimer
const alwaysShowAppControls = computed(() => themeStore.getFeatureFlag('always_show_app_controls'))
const showControls = computed(
() =>
alwaysShowAppControls.value ||
(!nativeDecorations.value && (os.value === 'Windows' || os.value === 'Linux')),
)
onMounted(async () => {
os.value = await getOS()
const settings = await getSettings()
nativeDecorations.value = settings.native_decorations
if (os.value !== 'MacOS') {
await getCurrentWindow().setDecorations(nativeDecorations.value)
}
isMaximized.value = await getCurrentWindow().isMaximized()
unlistenResize.value = await getCurrentWindow().onResized(() => {
// Windows emits a burst of resize events while a game changes display mode.
if (resizeTimer) clearTimeout(resizeTimer)
resizeTimer = setTimeout(async () => {
resizeTimer = undefined
try {
isMaximized.value = await getCurrentWindow().isMaximized()
} catch (error) {
console.warn('Failed to refresh maximized state after resize', error)
}
}, 100)
})
})
onUnmounted(() => {
if (resizeTimer) clearTimeout(resizeTimer)
if (unlistenResize.value) {
unlistenResize.value()
}
})
const handleClose = async () => {
await saveWindowState(StateFlags.ALL)
await getCurrentWindow().close()
}
</script>
<style scoped>
.expanded-button::before {
inset: -9px -6px;
content: '';
position: absolute;
}
.expanded-button.close-button::before {
inset: -9px -9px -9px -6px;
}
</style>

View File

@ -0,0 +1,182 @@
<script setup lang="ts">
import { ExternalIcon } from '@modrinth/assets'
import { Admonition, BulletDivider, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed } from 'vue'
import {
ANNOUNCEMENT_CHANGE_TYPES,
type AnnouncementChangeType,
getLocalizedAnnouncementText,
type LauncherAnnouncement,
} from '@/announcements/catalog'
import i18n from '@/i18n.config'
const props = withDefaults(
defineProps<{
announcement?: LauncherAnnouncement
version?: string | null
externalUrl?: string
showHeader?: boolean
}>(),
{
announcement: undefined,
version: null,
externalUrl: undefined,
showHeader: true,
},
)
const { formatMessage } = useVIntl()
const messages = defineMessages({
unknownTitle: {
id: 'app.update-announcement.unknown-title',
defaultMessage: 'Axolotl Launcher was updated',
},
unknownBody: {
id: 'app.update-announcement.unknown-body',
defaultMessage:
'This version does not have a bundled announcement yet. Visit the website for the full changelog.',
},
version: {
id: 'app.update-announcement.version',
defaultMessage: 'Version {version}',
},
openChangelog: {
id: 'app.update-announcement.open-changelog',
defaultMessage: 'Open full changelog',
},
notes: {
id: 'app.update-announcement.notes',
defaultMessage: 'Notes',
},
added: {
id: 'app.update-announcement.category.added',
defaultMessage: 'Added',
},
changed: {
id: 'app.update-announcement.category.changed',
defaultMessage: 'Changed',
},
deprecated: {
id: 'app.update-announcement.category.deprecated',
defaultMessage: 'Deprecated',
},
removed: {
id: 'app.update-announcement.category.removed',
defaultMessage: 'Removed',
},
fixed: {
id: 'app.update-announcement.category.fixed',
defaultMessage: 'Fixed',
},
security: {
id: 'app.update-announcement.category.security',
defaultMessage: 'Security',
},
})
const categoryClasses: Record<AnnouncementChangeType, string> = {
added: 'bg-brand-green',
changed: 'bg-brand-blue',
deprecated: 'bg-brand-orange',
removed: 'bg-brand-red',
fixed: 'bg-brand-purple',
security: 'bg-brand-orange',
}
const locale = computed(() => i18n.global.locale.value)
const title = computed(() =>
props.announcement
? getLocalizedAnnouncementText(props.announcement.title, locale.value)
: formatMessage(messages.unknownTitle),
)
const versionLabel = computed(() =>
formatMessage(messages.version, { version: props.announcement?.version ?? props.version ?? '—' }),
)
const categoryRows = computed(() =>
ANNOUNCEMENT_CHANGE_TYPES.flatMap((type) => {
const changes = props.announcement?.changes[type]
if (!changes?.length) return []
return [
{
type,
label: formatMessage(messages[type]),
className: categoryClasses[type],
changes: changes.map((change) => getLocalizedAnnouncementText(change, locale.value)),
},
]
}),
)
async function openChangelog() {
if (props.externalUrl) await openUrl(props.externalUrl)
}
</script>
<template>
<div class="flex min-w-0 flex-col gap-5 text-primary">
<header v-if="showHeader" class="flex min-w-0 flex-col gap-2">
<h2 class="m-0 break-words text-xl font-semibold text-contrast">{{ title }}</h2>
<div class="flex flex-wrap items-center gap-2 text-sm text-secondary">
<span>{{ versionLabel }}</span>
<BulletDivider v-if="announcement?.publishedAt" />
<time v-if="announcement?.publishedAt" :datetime="announcement.publishedAt">
{{ announcement.publishedAt }}
</time>
</div>
</header>
<div v-if="categoryRows.length" class="flex flex-col">
<section
v-for="(category, index) in categoryRows"
:key="category.type"
class="announcement-category grid grid-cols-1 gap-2 py-4 sm:grid-cols-[7rem_minmax(0,1fr)] sm:gap-5"
:class="{ 'border-t-0 pt-0': index === 0 }"
>
<h3 class="m-0 flex items-center gap-2 text-sm font-semibold text-secondary">
<span
class="size-2 shrink-0 rounded-full"
:class="category.className"
aria-hidden="true"
/>
{{ category.label }}
</h3>
<ul class="m-0 flex list-disc flex-col gap-2 pl-5 leading-relaxed text-primary">
<li v-for="change in category.changes" :key="change">{{ change }}</li>
</ul>
</section>
</div>
<Admonition v-else type="info" :body="formatMessage(messages.unknownBody)" />
<div v-if="announcement?.notes" class="announcement-notes">
<h3 class="m-0 mb-2 text-sm font-semibold text-secondary">
{{ formatMessage(messages.notes) }}
</h3>
<p class="m-0 leading-relaxed text-primary">
{{ getLocalizedAnnouncementText(announcement.notes, locale) }}
</p>
</div>
<ButtonStyled v-if="externalUrl" color="brand" type="outlined" class="self-start">
<button type="button" @click="openChangelog">
<ExternalIcon />
{{ formatMessage(messages.openChangelog) }}
</button>
</ButtonStyled>
</div>
</template>
<style scoped>
.announcement-category {
border-top: 1px solid
var(--settings-divider, color-mix(in srgb, var(--surface-4) 55%, transparent));
}
.announcement-notes {
padding-top: var(--gap-md);
border-top: 1px solid
var(--settings-divider, color-mix(in srgb, var(--surface-4) 55%, transparent));
}
</style>

View File

@ -0,0 +1,142 @@
<script setup lang="ts">
import { CalendarIcon, HistoryIcon } from '@modrinth/assets'
import { Accordion, defineMessages, TagItem, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import {
getAnnouncementByVersion,
getAnnouncements,
getLocalizedAnnouncementText,
} from '@/announcements/catalog'
import { AxolotlBrandConfig } from '@/config'
import i18n from '@/i18n.config'
import UpdateAnnouncementContent from './UpdateAnnouncementContent.vue'
const props = defineProps<{
currentVersion: string
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'app.settings.updates.announcements.title',
defaultMessage: 'Update announcements',
},
description: {
id: 'app.settings.updates.announcements.description',
defaultMessage: 'See what changed in this version and browse previous releases.',
},
history: {
id: 'app.settings.updates.announcements.history',
defaultMessage: 'Version history',
},
empty: {
id: 'app.settings.updates.announcements.empty',
defaultMessage: 'No bundled update announcements are available.',
},
})
const locale = computed(() => i18n.global.locale.value)
const launcherAnnouncements = getAnnouncements()
const currentAnnouncement = computed(() => getAnnouncementByVersion(props.currentVersion))
const historyAnnouncements = computed(() =>
launcherAnnouncements.filter((announcement) => announcement.id !== currentAnnouncement.value?.id),
)
</script>
<template>
<section class="update-announcement-history">
<div class="flex min-w-0 flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.title) }}
</h2>
<p class="m-0 leading-relaxed text-secondary">
{{ formatMessage(messages.description) }}
</p>
</div>
<div class="min-w-0">
<UpdateAnnouncementContent
:announcement="currentAnnouncement"
:version="currentVersion"
:external-url="currentAnnouncement?.externalUrl ?? AxolotlBrandConfig.website"
/>
</div>
<div class="flex min-w-0 flex-col gap-3">
<h3 class="m-0 flex items-center gap-2 text-base font-semibold text-contrast">
<HistoryIcon aria-hidden="true" class="size-4 text-secondary" />
{{ formatMessage(messages.history) }}
</h3>
<p v-if="historyAnnouncements.length === 0" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex min-w-0 flex-col gap-2">
<Accordion
v-for="announcement in historyAnnouncements"
:key="announcement.id"
class="update-announcement-history-item hover:border-surface-4 focus-within:border-surface-4"
button-class="group flex w-full cursor-pointer items-center gap-3 border-0 bg-transparent px-4 py-3 text-left"
>
<template #title>
<div class="flex min-w-0 flex-1 items-center gap-3">
<div class="flex min-w-0 flex-1 flex-col gap-1">
<span
class="truncate font-semibold text-primary transition-colors group-hover:text-contrast"
>
{{ getLocalizedAnnouncementText(announcement.title, locale) }}
</span>
<div class="flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-secondary">
<TagItem class="px-1.5 py-0.5 text-xs">v{{ announcement.version }}</TagItem>
<span class="flex items-center gap-1">
<CalendarIcon aria-hidden="true" class="size-3.5" />
<time :datetime="announcement.publishedAt">{{ announcement.publishedAt }}</time>
</span>
</div>
</div>
</div>
</template>
<div class="update-announcement-history-item-content">
<UpdateAnnouncementContent
:announcement="announcement"
:show-header="false"
:external-url="announcement.externalUrl"
/>
</div>
</Accordion>
</div>
</div>
</section>
</template>
<style scoped>
.update-announcement-history {
display: flex;
min-width: 0;
flex-direction: column;
gap: var(--gap-xl);
padding: var(--gap-xl);
border: 1px solid
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
border-radius: var(--radius-md);
background: var(--surface-2);
}
.update-announcement-history-item {
min-width: 0;
overflow: hidden;
border: 1px solid
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
border-radius: var(--radius-sm);
background: var(--surface-3);
transition: border-color 120ms ease;
}
.update-announcement-history-item-content {
padding: var(--gap-lg);
border-top: 1px solid
var(--settings-divider, color-mix(in srgb, var(--surface-4) 55%, transparent));
}
</style>

View File

@ -0,0 +1,124 @@
<template>
<ButtonStyled color="brand" type="outlined" hover-color-fill="background">
<button
v-if="showUpdatePill"
type="button"
class="!h-[34px] text-sm !transition-[opacity,transform,background-color,color,filter] !duration-200 ease-out"
:class="{
'opacity-0 scale-[0.96]': finishedDownloading && !animateReadyPill,
'opacity-100 scale-100': finishedDownloading && animateReadyPill,
}"
:disabled="isUpdateDownloading"
:aria-busy="isUpdateDownloading"
@click="handleUpdateClick"
>
<RefreshCwIcon v-if="finishedDownloading" :class="{ 'animate-spin': restarting }" />
<DownloadIcon v-else />
<span v-if="isUpdateDownloading">
{{ formatMessage(messages.downloadingUpdate) }}
<span class="inline-block w-[3ch] text-right tabular-nums">{{ downloadPercent }}%</span>
</span>
<span v-else>{{ updateLabel }}</span>
</button>
</ButtonStyled>
</template>
<script setup lang="ts">
import { DownloadIcon, RefreshCwIcon } from '@modrinth/assets'
import { ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import {
appUpdateState,
downloadAvailableAppUpdate,
installAvailableAppUpdate,
} from '@/providers/app-update'
const { formatMessage } = useVIntl()
const messages = defineMessages({
update: {
id: 'app.action-bar.update',
defaultMessage: 'Update',
},
downloadingUpdate: {
id: 'app.action-bar.downloading-update',
defaultMessage: 'Downloading update',
},
reloadToUpdate: {
id: 'app.action-bar.reload-to-update',
defaultMessage: 'Reload to update',
},
})
const {
downloading,
downloadPercent,
downloadProgress,
finishedDownloading,
isVisible: isUpdateVisible,
metered,
restarting,
} = appUpdateState
const isUpdateDownloading = computed(
() =>
downloading.value ||
(downloadProgress.value > 0 && downloadProgress.value < 1 && !finishedDownloading.value),
)
const showUpdatePill = computed(
() => isUpdateVisible.value && (finishedDownloading.value || metered.value),
)
const animateReadyPill = ref(false)
const updateLabel = computed(() => {
if (isUpdateDownloading.value) {
return formatMessage(messages.downloadingUpdate)
}
if (finishedDownloading.value) {
return formatMessage(messages.reloadToUpdate)
}
return formatMessage(messages.update)
})
let readyPillAnimationFrame: number | null = null
watch([showUpdatePill, finishedDownloading], async ([show, ready], [wasShown, wasReady]) => {
if (readyPillAnimationFrame !== null) {
cancelAnimationFrame(readyPillAnimationFrame)
readyPillAnimationFrame = null
}
if (!show || !ready) {
animateReadyPill.value = false
return
}
if (wasShown && wasReady) {
return
}
animateReadyPill.value = false
await nextTick()
readyPillAnimationFrame = requestAnimationFrame(() => {
animateReadyPill.value = true
readyPillAnimationFrame = null
})
})
async function handleUpdateClick() {
if (isUpdateDownloading.value) {
return
}
if (finishedDownloading.value) {
await installAvailableAppUpdate()
} else {
await downloadAvailableAppUpdate()
}
}
onBeforeUnmount(() => {
if (readyPillAnimationFrame !== null) {
cancelAnimationFrame(readyPillAnimationFrame)
}
})
</script>

View File

@ -0,0 +1,70 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { resolveBreadcrumbLabel } from '../../helpers/breadcrumb-label.ts'
const localeFiles = {
'en-US': '../../locales/en-US/index.json',
'zh-CN': '../../locales/zh-CN/index.json',
'zh-TW': '../../locales/zh-TW/index.json',
} as const
const localeMessages = Object.fromEntries(
Object.entries(localeFiles).map(([locale, path]) => [
locale,
JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf8')) as Record<
string,
{ message: string }
>,
]),
) as Record<keyof typeof localeFiles, Record<string, { message: string }>>
test('upgrade breadcrumb reuses localized Upgrade instance message at runtime', () => {
let locale: keyof typeof localeFiles = 'en-US'
const labels = { Upgrade: 'app.instance.upgrade-instance' }
const resolve = () =>
resolveBreadcrumbLabel(
'Upgrade',
() => '',
labels,
(messageId) => localeMessages[locale][messageId].message,
)
assert.equal(resolve(), 'Upgrade instance')
locale = 'zh-CN'
assert.equal(resolve(), '升级实例')
locale = 'zh-TW'
assert.equal(resolve(), '升級實例')
})
test('upgrade route paths, internal names, and breadcrumb depth stay unchanged', () => {
const routes = readFileSync(new URL('../../routes.js', import.meta.url), 'utf8')
assert.match(routes, /useRootContext: true,[\s\S]*?breadcrumb: \[\{ name: 'Upgrade' \}\]/)
assert.doesNotMatch(routes, /breadcrumb: \[\{ name: '\?Instance'[^\]]*\{ name: 'Upgrade' \}\]/)
for (const [path, name] of [
['', 'InstanceUpgrade'],
['compatibility', 'InstanceUpgradeCompatibility'],
['customize', 'InstanceUpgradeCustomize'],
['confirm', 'InstanceUpgradeConfirm'],
['progress', 'InstanceUpgradeProgress'],
['result', 'InstanceUpgradeResult'],
] as const) {
assert.match(routes, new RegExp(`path: '${path}',\\s+name: '${name}'`))
}
})
test('breadcrumb component resolves Upgrade through formatMessage on each render', () => {
const source = readFileSync(new URL('./Breadcrumbs.vue', import.meta.url), 'utf8')
assert.match(source, /Upgrade: messages\.upgradeInstance/)
assert.match(source, /Upgrade: ArrowBigUpDashIcon/)
assert.match(source, /id: 'app\.instance\.upgrade-instance'/)
assert.match(source, /resolveBreadcrumbLabel\([\s\S]*?\(message\) => formatMessage\(message\)/)
})
test('breadcrumb separators render only between visible breadcrumb items', () => {
const source = readFileSync(new URL('./Breadcrumbs.vue', import.meta.url), 'utf8')
assert.match(source, /v-for="\(breadcrumb, index\) in breadcrumbs"/)
assert.match(source, /v-if="index < breadcrumbs\.length - 1"/)
assert.doesNotMatch(source, /ChevronRightIcon v-if="breadcrumb\.link"/)
})

View File

@ -0,0 +1,12 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
test('context menu removes global listeners from their registration targets', () => {
const source = readFileSync(new URL('./ContextMenu.vue', import.meta.url), 'utf8')
assert.match(source, /window\.addEventListener\('click', handleClickOutside\)/)
assert.match(source, /window\.removeEventListener\('click', handleClickOutside\)/)
assert.match(source, /document\.body\.addEventListener\('keyup', onEscKeyRelease\)/)
assert.match(source, /document\.body\.removeEventListener\('keyup', onEscKeyRelease\)/)
})

View File

@ -0,0 +1,74 @@
<script setup lang="ts">
import { Avatar, defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
const emit = defineEmits<{
openGame: []
}>()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'app.settings.about.easteregg.contributors-title',
defaultMessage: '贡献者彩蛋',
},
clickHint: {
id: 'app.settings.about.easteregg.click-hint',
defaultMessage: 'Click to open a hidden Mini Game',
},
})
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const contributors = [
{
name: 'cyf112233',
avatarUrl: `${window.location.origin}/easteregg/avatars/cyf112233.jpg`,
},
]
function show() {
modal.value?.show()
}
function selectContributor() {
modal.value?.hide()
emit('openGame')
}
defineExpose({ show })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
width="min(480px, calc(100vw - 2rem))"
max-width="480px"
>
<ul class="m-0 list-none p-0">
<li>
<button
type="button"
class="flex w-full items-center gap-3 rounded-xl bg-surface-4 p-4 text-left transition-colors hover:bg-surface-5"
@click="selectContributor"
>
<Avatar
:src="contributors[0].avatarUrl"
:alt="contributors[0].name"
size="4rem"
circle
no-shadow
/>
<span class="min-w-0">
<span class="block font-semibold text-contrast">{{ contributors[0].name }}</span>
<span class="block text-sm text-secondary">
{{ formatMessage(messages.clickHint) }}
</span>
</span>
</button>
</li>
</ul>
</NewModal>
</template>

View File

@ -0,0 +1,49 @@
<script setup lang="ts">
import { defineMessages, NewModal, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'app.settings.about.easteregg.game-title',
defaultMessage: 'Mini Game',
},
})
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const gameVisible = ref(false)
const gameUrl = `${window.location.origin}/easteregg/games/game.html`
function show() {
gameVisible.value = true
modal.value?.show()
}
function onHide() {
gameVisible.value = false
}
defineExpose({ show })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
width="min(832px, calc(100vw - 2rem))"
max-width="832px"
noblur
:on-hide="onHide"
>
<div class="flex flex-col items-center py-2">
<iframe
v-if="gameVisible"
:src="gameUrl"
title="Mini Game"
class="h-[600px] w-[800px] max-w-full rounded-xl border-none bg-black"
/>
</div>
</NewModal>
</template>

View File

@ -0,0 +1,422 @@
<script setup lang="ts">
import { MailIcon, SearchIcon, SendIcon, UserIcon, UserPlusIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
ButtonStyled,
defineMessages,
injectNotificationManager,
IntlFormatted,
StyledInput,
useRelativeTime,
useVIntl,
} from '@modrinth/ui'
import { computed, onUnmounted, ref, watch } from 'vue'
import FriendsSection from '@/components/ui/friends/FriendsSection.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { friend_listener } from '@/helpers/events'
import {
add_friend,
friends,
type FriendWithUserData,
remove_friend,
transformFriends,
} from '@/helpers/friends.ts'
import type { ModrinthCredentials } from '@/helpers/mr_auth'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const formatRelativeTime = useRelativeTime()
const props = defineProps<{
credentials: ModrinthCredentials | null
signIn: () => void
}>()
const userCredentials = computed(() => props.credentials)
const search = ref('')
const friendInvitesModal = ref()
const username = ref('')
const addFriendModal = ref()
async function addFriendFromModal() {
addFriendModal.value.hide()
await add_friend(username.value).catch(handleError)
username.value = ''
await loadFriends()
}
async function addFriend(friend: FriendWithUserData) {
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
if (id) {
await add_friend(id).catch(handleError)
await loadFriends()
}
}
async function removeFriend(friend: FriendWithUserData) {
const id = friend.id === userCredentials.value?.user_id ? friend.friend_id : friend.id
if (id) {
await remove_friend(id).catch(handleError)
await loadFriends()
}
}
const userFriends = ref<FriendWithUserData[]>([])
const sortedFriends = computed<FriendWithUserData[]>(() =>
userFriends.value.slice().sort((a, b) => {
if (a.last_updated === null && b.last_updated === null) {
return 0 // Both are null, equal in sorting
}
if (a.last_updated === null) {
return 1 // `a` is null, move it after `b`
}
if (b.last_updated === null) {
return -1 // `b` is null, move it after `a`
}
// Both are non-null, sort by date
return b.last_updated.diff(a.last_updated)
}),
)
const filteredFriends = computed<FriendWithUserData[]>(() =>
sortedFriends.value.filter((x) =>
x.username.trim().toLowerCase().includes(search.value.trim().toLowerCase()),
),
)
const activeFriends = computed<FriendWithUserData[]>(() =>
filteredFriends.value.filter((x) => !!x.status && x.online && x.accepted),
)
const onlineFriends = computed<FriendWithUserData[]>(() =>
filteredFriends.value.filter((x) => x.online && !x.status && x.accepted),
)
const offlineFriends = computed<FriendWithUserData[]>(() =>
filteredFriends.value.filter((x) => !x.online && x.accepted),
)
const pendingFriends = computed(() =>
filteredFriends.value
.filter((x) => !x.accepted && x.id !== userCredentials.value?.user_id)
.slice()
.sort((a, b) => b.created.diff(a.created)),
)
const incomingRequests = computed(() =>
userFriends.value
.filter((x) => !x.accepted && x.id === userCredentials.value?.user_id)
.slice()
.sort((a, b) => b.created.diff(a.created)),
)
const loading = ref(true)
async function loadFriends(timeout = false) {
loading.value = timeout
try {
const friendsList = await friends()
userFriends.value = await transformFriends(friendsList, userCredentials.value)
loading.value = false
} catch (e) {
console.error('Error loading friends', e)
if (timeout) {
setTimeout(() => loadFriends(), 15 * 1000)
}
}
}
watch(
userCredentials,
() => {
if (userCredentials.value === undefined) {
userFriends.value = []
loading.value = false
} else if (userCredentials.value === null) {
userFriends.value = []
loading.value = false
} else {
loadFriends(true)
}
},
{ immediate: true },
)
const unlisten = await friend_listener(() => loadFriends())
onUnmounted(() => {
unlisten()
})
const messages = defineMessages({
addFriend: {
id: 'friends.action.add-friend',
defaultMessage: 'Add a friend',
},
addingAFriend: {
id: 'friends.add-friend.title',
defaultMessage: 'Adding a friend',
},
usernameTitle: {
id: 'friends.add-friend.username.title',
defaultMessage: "What's your friend's Modrinth username?",
},
usernameDescription: {
id: 'friends.add-friend.username.description',
defaultMessage: 'It may be different from their Minecraft username!',
},
usernamePlaceholder: {
id: 'friends.add-friend.username.placeholder',
defaultMessage: 'Enter Modrinth username...',
},
sendFriendRequest: {
id: 'friends.add-friend.submit',
defaultMessage: 'Send friend request',
},
viewFriendRequests: {
id: 'friends.action.view-friend-requests',
defaultMessage: '{count} friend {count, plural, one {request} other {requests}}',
},
searchFriends: {
id: 'friends.search-friends-placeholder',
defaultMessage: 'Search friends...',
},
friends: {
id: 'friends.heading',
defaultMessage: 'Friends',
},
pending: {
id: 'friends.heading.pending',
defaultMessage: 'Pending',
},
active: {
id: 'friends.heading.active',
defaultMessage: 'Active',
},
online: {
id: 'friends.heading.online',
defaultMessage: 'Online',
},
offline: {
id: 'friends.heading.offline',
defaultMessage: 'Offline',
},
noFriendsMatch: {
id: 'friends.no-friends-match',
defaultMessage: `No friends matching ''{query}''`,
},
signInToAddFriends: {
id: 'friends.sign-in-to-add-friends',
defaultMessage:
"<link>Sign in to a Modrinth account</link> to add friends and see what they're playing!",
},
addFriendsToShare: {
id: 'friends.add-friends-to-share',
defaultMessage: "<link>Add friends</link> to see what they're playing!",
},
viewRequests: { id: 'friends.requests.title', defaultMessage: 'View friend requests' },
noPendingRequests: {
id: 'friends.requests.none',
defaultMessage: 'You have no pending friend requests :C',
},
incomingRequest: {
id: 'friends.requests.incoming',
defaultMessage: '{username} sent you a friend request',
},
outgoingRequest: {
id: 'friends.requests.outgoing',
defaultMessage: 'You sent {username} a friend request',
},
accept: { id: 'friends.requests.accept', defaultMessage: 'Accept' },
ignore: { id: 'friends.requests.ignore', defaultMessage: 'Ignore' },
cancel: { id: 'friends.requests.cancel', defaultMessage: 'Cancel' },
})
</script>
<template>
<ModalWrapper ref="friendInvitesModal" :header="formatMessage(messages.viewRequests)">
<p v-if="incomingRequests.length === 0">{{ formatMessage(messages.noPendingRequests) }}</p>
<div v-else class="flex flex-col gap-4 min-w-[40rem]">
<div v-for="friend in incomingRequests" :key="friend.username" class="flex gap-2">
<Avatar :src="friend.avatar" class="w-12 h-12 rounded-full" size="2.25rem" circle />
<div class="grid grid-cols-[1fr_auto] w-full gap-4">
<div>
<p class="m-0">
<template v-if="friend.id === userCredentials?.user_id">
{{ formatMessage(messages.incomingRequest, { username: friend.username }) }}
</template>
<template v-else>
{{ formatMessage(messages.outgoingRequest, { username: friend.username }) }}
</template>
</p>
<p class="m-0 text-sm text-secondary">
{{ formatRelativeTime(friend.created.toISOString()) }}
</p>
</div>
<div class="flex gap-2">
<template v-if="friend.id === userCredentials?.user_id">
<ButtonStyled color="brand">
<button @click="addFriend(friend)">
<UserPlusIcon />
{{ formatMessage(messages.accept) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="removeFriend(friend)">
<XIcon />
{{ formatMessage(messages.ignore) }}
</button>
</ButtonStyled>
</template>
<template v-else>
<ButtonStyled>
<button @click="removeFriend(friend)">
<XIcon />
{{ formatMessage(messages.cancel) }}
</button>
</ButtonStyled>
</template>
</div>
</div>
</div>
</div>
</ModalWrapper>
<ModalWrapper ref="addFriendModal" :header="formatMessage(messages.addingAFriend)">
<div class="min-w-[30rem]">
<h2 class="m-0 text-base font-medium text-primary">
{{ formatMessage(messages.usernameTitle) }}
</h2>
<p class="m-0 mt-1 text-sm text-secondary leading-tight">
{{ formatMessage(messages.usernameDescription) }}
</p>
<div class="flex items-center gap-2 mt-4">
<StyledInput
v-model="username"
:icon="UserIcon"
type="text"
:placeholder="formatMessage(messages.usernamePlaceholder)"
wrapper-class="flex-1"
@keyup.enter="addFriendFromModal"
/>
<ButtonStyled color="brand">
<button :disabled="username.length === 0" @click="addFriendFromModal">
<SendIcon />
{{ formatMessage(messages.sendFriendRequest) }}
</button>
</ButtonStyled>
</div>
</div>
</ModalWrapper>
<div v-if="userCredentials && !loading" class="flex gap-1 items-center mb-3 -ml-1">
<template v-if="sortedFriends.length > 0">
<ButtonStyled circular type="transparent">
<button
v-tooltip="formatMessage(messages.addFriend)"
:aria-label="formatMessage(messages.addFriend)"
@click="addFriendModal.show"
>
<UserPlusIcon />
</button>
</ButtonStyled>
<StyledInput
v-model="search"
:icon="SearchIcon"
type="text"
:placeholder="formatMessage(messages.searchFriends)"
clearable
input-class="!bg-transparent !border !border-solid !border-button-bg !text-primary !placeholder:text-primary"
wrapper-class="flex-1 [&>svg]:!text-primary [&>svg]:!opacity-100"
@keyup.esc="search = ''"
/>
</template>
<h3 v-else class="w-full text-base text-primary font-medium m-0">
{{ formatMessage(messages.friends) }}
</h3>
<ButtonStyled v-if="incomingRequests.length > 0" circular type="transparent">
<button
v-tooltip="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
class="relative"
:aria-label="formatMessage(messages.viewFriendRequests, { count: incomingRequests.length })"
@click="friendInvitesModal.show"
>
<MailIcon />
<span
v-if="incomingRequests.length > 0"
aria-hidden="true"
class="absolute bg-brand text-brand-inverted text-[8px] top-0.5 px-1 right-0.5 min-w-3 h-3 rounded-full flex items-center justify-center font-bold"
>
{{ incomingRequests.length }}
</span>
</button>
</ButtonStyled>
</div>
<div class="flex flex-col gap-3">
<h3 v-if="loading" class="text-base text-primary font-medium m-0">
{{ formatMessage(messages.friends) }}
</h3>
<template v-if="loading">
<div v-for="n in 5" :key="n" class="flex gap-2 items-center animate-pulse">
<div class="min-w-9 min-h-9 bg-button-bg rounded-full"></div>
<div class="flex flex-col w-full">
<div class="h-3 bg-button-bg rounded-full w-1/2 mb-1"></div>
<div class="h-2.5 bg-button-bg rounded-full w-3/4"></div>
</div>
</div>
</template>
<template v-else-if="sortedFriends.length === 0">
<div class="text-sm">
<div v-if="!userCredentials">
<IntlFormatted :message-id="messages.signInToAddFriends">
<template #link="{ children }">
<span class="font-semibold text-brand cursor-pointer" @click="signIn">
<component :is="() => children" />
</span>
</template>
</IntlFormatted>
</div>
<div v-else>
<IntlFormatted :message-id="messages.addFriendsToShare">
<template #link="{ children }">
<span class="font-semibold text-brand cursor-pointer" @click="addFriendModal.show">
<component :is="() => children" />
</span>
</template>
</IntlFormatted>
</div>
</div>
</template>
<template v-else>
<FriendsSection
v-if="activeFriends.length > 0"
:is-searching="!!search"
open-by-default
:friends="activeFriends"
:heading="formatMessage(messages.active)"
:remove-friend="removeFriend"
/>
<FriendsSection
v-if="onlineFriends.length > 0"
:is-searching="!!search"
open-by-default
:friends="onlineFriends"
:heading="formatMessage(messages.online)"
:remove-friend="removeFriend"
/>
<FriendsSection
v-if="offlineFriends.length > 0"
:is-searching="!!search"
:open-by-default="activeFriends.length + onlineFriends.length < 3"
:friends="offlineFriends"
:heading="formatMessage(messages.offline)"
:remove-friend="removeFriend"
/>
<FriendsSection
v-if="pendingFriends.length > 0"
:is-searching="!!search"
:friends="pendingFriends"
:heading="formatMessage(messages.pending)"
:remove-friend="removeFriend"
/>
<p v-if="filteredFriends.length === 0 && search" class="text-sm text-secondary my-1 mx-4">
{{ formatMessage(messages.noFriendsMatch, { query: search }) }}
</p>
</template>
</div>
</template>

View File

@ -0,0 +1,191 @@
<script setup lang="ts">
import { MoreVerticalIcon, TrashIcon, UserIcon, XIcon } from '@modrinth/assets'
import {
Accordion,
Avatar,
ButtonStyled,
defineMessages,
OverflowMenu,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { useTemplateRef } from 'vue'
import ContextMenu from '@/components/ui/ContextMenu.vue'
import type { FriendWithUserData } from '@/helpers/friends.ts'
const { formatMessage } = useVIntl()
const props = withDefaults(
defineProps<{
friends: FriendWithUserData[]
heading: string
removeFriend: (friend: FriendWithUserData) => Promise<void>
isSearching?: boolean
openByDefault?: boolean
}>(),
{
isSearching: false,
openByDefault: false,
},
)
function createContextMenuOptions(friend: FriendWithUserData) {
if (friend.accepted) {
return [
{
name: 'view-profile',
},
{
name: 'remove-friend',
color: 'danger',
},
]
} else {
return [
{
name: 'view-profile',
},
{
name: 'cancel-request',
},
]
}
}
function openProfile(username: string) {
openUrl('https://modrinth.com/user/' + username)
}
const friendOptions = useTemplateRef('friendOptions')
async function handleFriendOptions(args: { item: FriendWithUserData; option: string }) {
switch (args.option) {
case 'remove-friend':
case 'cancel-request':
await props.removeFriend(args.item)
break
case 'view-profile':
openProfile(args.item.username)
}
}
const messages = defineMessages({
removeFriend: {
id: 'friends.friend.remove-friend',
defaultMessage: 'Remove friend',
},
heading: {
id: 'friends.section.heading',
defaultMessage: '{title} - {count}',
},
friendRequestSent: {
id: 'friends.friend.request-sent',
defaultMessage: 'Friend request sent',
},
cancelRequest: {
id: 'friends.friend.cancel-request',
defaultMessage: 'Cancel request',
},
viewProfile: {
id: 'friends.friend.view-profile',
defaultMessage: 'View profile',
},
})
</script>
<template>
<ContextMenu ref="friendOptions" @option-clicked="handleFriendOptions">
<template #view-profile>
<UserIcon />
{{ formatMessage(messages.viewProfile) }}
</template>
<template #remove-friend> <TrashIcon /> {{ formatMessage(messages.removeFriend) }} </template>
<template #cancel-request> <XIcon /> {{ formatMessage(messages.cancelRequest) }} </template>
</ContextMenu>
<Accordion
:open-by-default="openByDefault"
:force-open="isSearching"
:button-class="
'flex w-full items-center bg-transparent border-0 p-0' +
(isSearching
? ''
: ' cursor-pointer hover:brightness-[--hover-brightness] active:scale-[0.98] transition-all')
"
>
<template #title>
<h3 class="text-base text-primary font-medium m-0">
{{ formatMessage(messages.heading, { title: heading, count: friends.length }) }}
</h3>
</template>
<template #default>
<div class="pt-3 flex flex-col gap-1">
<div
v-for="friend in friends"
:key="friend.username"
class="group grid items-center grid-cols-[auto_1fr_auto] gap-2 hover:bg-button-bg transition-colors rounded-full mr-1"
@contextmenu.prevent.stop="
(event) => friendOptions?.showMenu(event, friend, createContextMenuOptions(friend))
"
>
<div class="relative">
<Avatar
:src="friend.avatar"
:class="{ grayscale: !friend.online && friend.accepted }"
class="w-12 h-12 rounded-full"
size="32px"
circle
/>
<span
v-if="friend.online"
aria-hidden="true"
class="bottom-[2px] right-[-2px] absolute w-3 h-3 bg-brand border-2 border-black border-solid rounded-full"
/>
</div>
<div class="flex flex-col">
<span
class="text-sm m-0"
:class="friend.online || !friend.accepted ? 'text-contrast' : 'text-primary'"
>
{{ friend.username }}
</span>
<span v-if="!friend.accepted" class="m-0 text-xs">
{{ formatMessage(messages.friendRequestSent) }}
</span>
<span v-else-if="friend.status" class="m-0 text-xs">{{ friend.status }}</span>
</div>
<ButtonStyled v-if="friend.accepted" circular type="transparent">
<OverflowMenu
class="opacity-0 group-hover:opacity-100 transition-opacity"
:options="[
{
id: 'view-profile',
action: () => openProfile(friend.username),
},
{
id: 'remove-friend',
action: () => removeFriend(friend),
color: 'red',
},
]"
>
<MoreVerticalIcon />
<template #view-profile>
<UserIcon />
{{ formatMessage(messages.viewProfile) }}
</template>
<template #remove-friend>
<TrashIcon />
{{ formatMessage(messages.removeFriend) }}
</template>
</OverflowMenu>
</ButtonStyled>
<ButtonStyled v-else type="transparent" circular>
<button v-tooltip="formatMessage(messages.cancelRequest)" @click="removeFriend(friend)">
<XIcon />
</button>
</ButtonStyled>
</div>
</div>
</template>
</Accordion>
</template>

View File

@ -0,0 +1,176 @@
<script setup>
import { CheckIcon, PlusIcon, SearchIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
import { trackEvent } from '@/helpers/analytics'
import { list } from '@/helpers/instance'
import { add_server_to_instance, get_instance_worlds } from '@/helpers/worlds.ts'
const { handleError } = injectNotificationManager()
const queryClient = useQueryClient()
const { formatMessage } = useVIntl()
const messages = defineMessages({
addServer: { id: 'app.server.add-to-instance', defaultMessage: 'Add server to instance' },
compatibilityWarning: {
id: 'app.server.compatibility-warning',
defaultMessage: 'This server may not be compatible with all instances.',
},
searchInstance: {
id: 'app.server.search-instance',
defaultMessage: 'Search for an instance',
},
adding: { id: 'app.server.adding', defaultMessage: 'Adding...' },
added: { id: 'app.server.added', defaultMessage: 'Added' },
add: { id: 'app.server.add', defaultMessage: 'Add' },
symlinkWarningHeader: {
id: 'app.symlink-warning.write.header',
defaultMessage: 'Shared instance',
},
symlinkWarningBody: {
id: 'app.symlink-warning.write.body',
defaultMessage:
'This instance is linked to "{path}". Changes will also affect the original files.',
},
})
defineProps({
symlinkTarget: {
type: String,
default: null,
},
})
const modal = ref()
const searchFilter = ref('')
const instances = ref([])
const serverName = ref('')
const serverAddress = ref('')
const shownInstances = computed(() =>
instances.value.filter((instance) => {
return instance.name.toLowerCase().includes(searchFilter.value.toLowerCase())
}),
)
defineExpose({
show: async (name, address) => {
serverName.value = name
serverAddress.value = address
searchFilter.value = ''
const instanceValues = await list().catch(handleError)
await Promise.allSettled(
instanceValues.map(async (instance) => {
instance.adding = false
instance.added = false
try {
const worlds = await get_instance_worlds(instance.id)
instance.added = worlds.some(
(w) => w.type === 'server' && w.address === serverAddress.value,
)
} catch {
// Ignore - will show as not added
}
}),
)
instances.value = instanceValues
modal.value.show()
trackEvent('AddServerToInstanceStart', { source: 'AddServerToInstanceModal' })
},
})
async function addServer(instance) {
instance.adding = true
try {
await add_server_to_instance(instance.id, serverName.value, serverAddress.value, 'prompt')
instance.added = true
await queryClient.invalidateQueries({ queryKey: ['worlds', instance.id] })
trackEvent('AddServerToInstance', {
server_name: serverName.value,
instance_name: instance.name,
source: 'AddServerToInstanceModal',
})
} catch (err) {
handleError(err)
}
instance.adding = false
}
</script>
<template>
<ModalWrapper ref="modal" :header="formatMessage(messages.addServer)">
<div class="flex flex-col gap-4 min-w-[350px]">
<Admonition
v-if="symlinkTarget"
type="warning"
:header="formatMessage(messages.symlinkWarningHeader)"
>
{{ formatMessage(messages.symlinkWarningBody, { path: symlinkTarget }) }}
</Admonition>
<Admonition type="warning" :body="formatMessage(messages.compatibilityWarning)" />
<StyledInput
v-model="searchFilter"
:icon="SearchIcon"
type="search"
:placeholder="formatMessage(messages.searchInstance)"
autocomplete="off"
/>
<div class="max-h-[21rem] overflow-y-auto">
<div
v-for="instance in shownInstances"
:key="instance.id"
class="flex w-full items-center justify-between gap-2 bg-bg-raised text-icon shadow-none"
>
<router-link
class="btn btn-transparent p-2 text-left"
:to="`/instance/${encodeURIComponent(instance.id)}`"
@click="modal.hide()"
>
<InstanceIcon
:icon-path="instance.icon_path"
:instance-id="instance.id"
:loader="instance.loader"
class="mr-2 [--size:2rem]"
/>
{{ instance.name }}
</router-link>
<ButtonStyled>
<button :disabled="instance.added || instance.adding" @click="addServer(instance)">
<PlusIcon v-if="!instance.added && !instance.adding" />
<CheckIcon v-else-if="instance.added" />
{{
instance.adding
? formatMessage(messages.adding)
: instance.added
? formatMessage(messages.added)
: formatMessage(messages.add)
}}
</button>
</ButtonStyled>
</div>
</div>
<div class="input-group push-right">
<ButtonStyled>
<button @click="modal.hide()">{{ formatMessage(commonMessages.cancelButton) }}</button>
</ButtonStyled>
</div>
</div>
</ModalWrapper>
</template>

View File

@ -0,0 +1,140 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" :on-hide="reset">
<div class="max-w-[31rem] flex flex-col gap-6">
<Admonition
type="warning"
:header="formatMessage(messages.warningTitle)"
:body="formatMessage(messages.warningBody)"
/>
<div v-if="fileName" class="overflow-x-auto whitespace-nowrap text-sm text-secondary">
{{ fileName }}
</div>
<div>
<p class="mt-0 leading-tight">
{{ formatMessage(messages.body) }}
</p>
<p class="text-orange font-semibold mb-0 leading-tight">
{{ formatMessage(messages.malwareStatement) }}
</p>
</div>
<Checkbox v-model="dontShowAgain" :label="formatMessage(messages.dontShowAgain)" />
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="cancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button :disabled="isProceeding" @click="proceed">
<SpinnerIcon v-if="isProceeding" class="animate-spin" />
<CircleArrowRightIcon v-else />
{{ formatMessage(messages.installAnyway) }}
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { CircleArrowRightIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
Checkbox,
commonMessages,
defineMessages,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref, useTemplateRef } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings'
import { useTheming } from '@/store/state'
import type { FeatureFlag } from '@/store/theme.ts'
const { formatMessage } = useVIntl()
const themeStore = useTheming()
const skipUnknownPackWarningFeatureFlag = 'skip_unknown_pack_warning' as FeatureFlag
const dontShowAgain = ref(false)
const modal = useTemplateRef('modal')
const onProceed = ref<() => Promise<void>>()
const isProceeding = ref(false)
const fileName = ref('')
const messages = defineMessages({
header: {
id: 'unknown-pack-warning-modal.header',
defaultMessage: 'Confirm installation',
},
warningTitle: {
id: 'unknown-pack-warning-modal.warning.title',
defaultMessage: 'Unknown file warning',
},
warningBody: {
id: 'unknown-pack-warning-modal.warning.body',
defaultMessage: `We couldn't find this file on Modrinth. We strongly recommend only installing files from sources you trust.`,
},
body: {
id: 'unknown-pack-warning-modal.body',
defaultMessage: `A file is only reviewed if its uploaded to Modrinth, regardless of its file format (including .mrpack).`,
},
malwareStatement: {
id: 'unknown-pack-warning-modal.malware-statement',
defaultMessage: `Malware is often distributed through modpack files by sharing them on platforms like Discord.`,
},
dontShowAgain: {
id: 'unknown-pack-warning-modal.dont-show-again',
defaultMessage: `Don't show this warning again`,
},
installAnyway: {
id: 'unknown-pack-warning-modal.install-anyway',
defaultMessage: `Install anyway`,
},
})
function show(createInstance: () => Promise<void>, selectedFileName = '') {
onProceed.value = createInstance
fileName.value = selectedFileName
dontShowAgain.value = false
if (themeStore.getFeatureFlag(skipUnknownPackWarningFeatureFlag)) {
// noinspection ES6MissingAwait
createInstance()
return
}
modal.value?.show()
}
function reset() {
onProceed.value = undefined
fileName.value = ''
}
function cancel() {
modal.value?.hide()
}
async function proceed() {
if (!onProceed.value) {
return
}
if (dontShowAgain.value) {
themeStore.featureFlags[skipUnknownPackWarningFeatureFlag] = true
const settings = await getSettings()
settings.feature_flags[skipUnknownPackWarningFeatureFlag] = true
await setSettings(settings)
}
const createInstance = onProceed.value
modal.value?.hide()
// noinspection ES6MissingAwait
createInstance()
}
defineExpose({ show })
</script>

View File

@ -0,0 +1,89 @@
<script setup lang="ts">
import { SearchIcon } from '@modrinth/assets'
import { EmptyState, StyledInput, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import type { GameInstance } from '@/helpers/types'
const props = defineProps<{
instances: GameInstance[]
searchPlaceholder: string
noInstancesMessage: string
noMatchesMessage: string
selectLabel: (instance: GameInstance) => string
}>()
const emit = defineEmits<{
select: [instance: GameInstance]
}>()
const { locale } = useVIntl()
const searchInput = ref<InstanceType<typeof StyledInput>>()
const searchQuery = ref('')
const visibleInstances = computed(() => {
const query = searchQuery.value.trim().toLocaleLowerCase(locale.value)
return props.instances
.filter((instance) => {
if (!query) return true
return [instance.name, instance.loader, instance.game_version].some((value) =>
value.toLocaleLowerCase(locale.value).includes(query),
)
})
.slice()
.sort((a, b) => a.name.localeCompare(b.name, locale.value, { sensitivity: 'base' }))
})
function reset() {
searchQuery.value = ''
}
function focus() {
searchInput.value?.focus()
}
defineExpose({ reset, focus })
</script>
<template>
<StyledInput
v-if="instances.length > 0"
ref="searchInput"
v-model="searchQuery"
type="search"
:icon="SearchIcon"
:placeholder="searchPlaceholder"
wrapper-class="w-full"
clearable
/>
<ul v-if="visibleInstances.length > 0" class="m-0 flex list-none flex-col gap-1 p-0">
<li v-for="instance in visibleInstances" :key="instance.id" class="min-w-0">
<button
type="button"
class="flex min-h-16 w-full cursor-pointer items-center gap-3 rounded-lg border-0 bg-transparent px-3 py-2 text-left text-primary transition-colors hover:bg-button-bg focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-brand-shadow"
:aria-label="selectLabel(instance)"
@click="emit('select', instance)"
>
<InstanceIcon
class="size-10 shrink-0"
:icon-path="instance.icon_path"
:instance-id="instance.id"
:loader="instance.loader"
/>
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
<span class="truncate font-semibold text-contrast">{{ instance.name }}</span>
<span class="truncate text-sm capitalize text-secondary">
{{ instance.loader }} {{ instance.game_version }}
</span>
</span>
<slot name="action" :instance="instance" />
</button>
</li>
</ul>
<EmptyState
v-else
type="empty-inbox"
:heading="instances.length === 0 ? noInstancesMessage : noMatchesMessage"
/>
</template>

View File

@ -0,0 +1,434 @@
<script setup lang="ts">
import {
ArrowDownIcon,
ArrowUpIcon,
DownloadIcon,
ExternalIcon,
EyeIcon,
FileArchiveIcon,
PlusIcon,
RestoreIcon,
TrashIcon,
} from '@modrinth/assets'
import {
ButtonStyled,
Checkbox,
defineMessages,
injectNotificationManager,
useVIntl,
} from '@modrinth/ui'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { open } from '@tauri-apps/plugin-dialog'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, ref } from 'vue'
import {
add_core_jar_mod,
import_mcarchive_modloader,
install_mcarchive_modloader,
list_core_components,
move_core_component,
preview_core_jar,
remove_core_component,
replace_core_jar,
restore_core_component,
set_core_component_enabled,
} from '@/helpers/instance'
import { injectInstanceSettings } from '@/providers/instance-settings'
const { instance } = injectInstanceSettings()
const { addNotification, handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const busy = ref(false)
const query = useQuery({
queryKey: computed(() => ['core-components', instance.value.id]),
queryFn: () => list_core_components(instance.value.id),
})
const activeComponents = computed(() =>
(query.data.value ?? []).filter((component) => !component.removed),
)
const deletedComponents = computed(() =>
(query.data.value ?? []).filter((component) => component.removed),
)
const canInstallModLoader = computed(() => {
const version = instance.value.game_version.trim().replace(/^v/, '')
if (/^(?:a|b|inf-)/.test(version)) return true
const match = /^1\.(\d+)(?:\.(\d+))?$/.exec(version)
if (!match) return false
const minor = Number(match[1])
const patch = Number(match[2] ?? 0)
return minor <= 6 && (minor < 6 || patch <= 2)
})
const manualModLoader = ref<{
fileName: string
pageUrl: string | null
expectedSha256: string | null
} | null>(null)
const messages = defineMessages({
title: {
id: 'instance.settings.tabs.core-components.title',
defaultMessage: 'Core components',
},
add: {
id: 'instance.settings.tabs.core-components.add',
defaultMessage: 'Add to Minecraft.jar',
},
replace: {
id: 'instance.settings.tabs.core-components.replace',
defaultMessage: 'Replace Minecraft.jar',
},
pickJar: {
id: 'instance.settings.tabs.core-components.pick-jar',
defaultMessage: 'Choose core archive',
},
jarFilter: {
id: 'instance.settings.tabs.core-components.jar-filter',
defaultMessage: 'Minecraft archives',
},
moveUp: {
id: 'instance.settings.tabs.core-components.move-up',
defaultMessage: 'Move up',
},
moveDown: {
id: 'instance.settings.tabs.core-components.move-down',
defaultMessage: 'Move down',
},
remove: {
id: 'instance.settings.tabs.core-components.remove',
defaultMessage: 'Remove',
},
restore: {
id: 'instance.settings.tabs.core-components.restore',
defaultMessage: 'Restore',
},
jarMod: {
id: 'instance.settings.tabs.core-components.jar-mod',
defaultMessage: 'JAR mod',
},
replacement: {
id: 'instance.settings.tabs.core-components.replacement',
defaultMessage: 'Replacement JAR',
},
sha256: {
id: 'instance.settings.tabs.core-components.sha256',
defaultMessage: 'SHA-256',
},
failure: {
id: 'instance.settings.tabs.core-components.failure',
defaultMessage: 'Failure',
},
sha1: {
id: 'instance.settings.tabs.core-components.sha1',
defaultMessage: 'SHA-1',
},
source: {
id: 'instance.settings.tabs.core-components.source',
defaultMessage: 'Source',
},
targetVersion: {
id: 'instance.settings.tabs.core-components.target-version',
defaultMessage: 'Target Minecraft version',
},
preview: {
id: 'instance.settings.tabs.core-components.preview',
defaultMessage: 'Preview assembled JAR',
},
previewed: {
id: 'instance.settings.tabs.core-components.previewed',
defaultMessage: 'Assembled {components} components into {entries} entries',
},
modLoader: {
id: 'instance.settings.tabs.core-components.modloader',
defaultMessage: 'Install ModLoader',
},
modLoaderInstalled: {
id: 'instance.settings.tabs.core-components.modloader-installed',
defaultMessage: 'Installed ModLoader {fileName}',
},
modLoaderManual: {
id: 'instance.settings.tabs.core-components.modloader-manual',
defaultMessage:
'{fileName} needs a manual download. Download it from the source page, then import the verified archive here.',
},
openSource: {
id: 'instance.settings.tabs.core-components.open-source',
defaultMessage: 'Open source page',
},
importModLoader: {
id: 'instance.settings.tabs.core-components.import-modloader',
defaultMessage: 'Import verified ModLoader archive',
},
pickModLoader: {
id: 'instance.settings.tabs.core-components.pick-modloader',
defaultMessage: 'Choose downloaded ModLoader archive',
},
})
async function refresh() {
await queryClient.invalidateQueries({ queryKey: ['core-components', instance.value.id] })
}
async function pick(kind: 'jar_mod' | 'replacement_jar') {
const path = await open({
multiple: false,
title: formatMessage(messages.pickJar),
filters: [{ name: formatMessage(messages.jarFilter), extensions: ['jar', 'zip'] }],
})
if (!path || Array.isArray(path)) return
busy.value = true
try {
if (kind === 'jar_mod') {
await add_core_jar_mod(instance.value.id, path, instance.value.game_version)
} else {
await replace_core_jar(instance.value.id, path, instance.value.game_version)
}
await refresh()
} catch (error) {
handleError(error)
} finally {
busy.value = false
}
}
async function run(action: () => Promise<unknown>) {
busy.value = true
try {
await action()
await refresh()
} catch (error) {
handleError(error)
} finally {
busy.value = false
}
}
async function preview() {
busy.value = true
try {
const result = await preview_core_jar(instance.value.id)
if (result) {
addNotification({
type: 'success',
title: formatMessage(messages.previewed, {
components: result.componentCount,
entries: result.entries,
}),
})
}
await refresh()
} catch (error) {
handleError(error)
} finally {
busy.value = false
}
}
async function installModLoader() {
busy.value = true
manualModLoader.value = null
try {
const result = await install_mcarchive_modloader(instance.value.id, instance.value.game_version)
if (result.state === 'manual_download') {
manualModLoader.value = result
return
}
addNotification({
type: 'success',
title: formatMessage(messages.modLoaderInstalled, {
fileName: result.component.fileName,
}),
})
await refresh()
} catch (error) {
handleError(error)
} finally {
busy.value = false
}
}
async function importModLoader() {
if (!manualModLoader.value) return
const path = await open({
multiple: false,
title: formatMessage(messages.pickModLoader),
filters: [{ name: formatMessage(messages.jarFilter), extensions: ['jar', 'zip'] }],
})
if (!path || Array.isArray(path)) return
busy.value = true
try {
const result = await import_mcarchive_modloader(
instance.value.id,
instance.value.game_version,
path,
)
if (result.state === 'manual_download') {
manualModLoader.value = result
return
}
addNotification({
type: 'success',
title: formatMessage(messages.modLoaderInstalled, {
fileName: result.component.fileName,
}),
})
manualModLoader.value = null
await refresh()
} catch (error) {
handleError(error)
} finally {
busy.value = false
}
}
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap gap-2">
<ButtonStyled>
<button :disabled="busy" @click="pick('jar_mod')">
<PlusIcon />
{{ formatMessage(messages.add) }}
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button :disabled="busy" @click="pick('replacement_jar')">
<FileArchiveIcon />
{{ formatMessage(messages.replace) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="canInstallModLoader" type="outlined">
<button :disabled="busy" @click="installModLoader">
<DownloadIcon />
{{ formatMessage(messages.modLoader) }}
</button>
</ButtonStyled>
<ButtonStyled type="transparent">
<button :disabled="busy" @click="preview">
<EyeIcon />
{{ formatMessage(messages.preview) }}
</button>
</ButtonStyled>
</div>
<div
v-if="manualModLoader"
class="flex flex-wrap items-center justify-between gap-3 border-y border-surface-4 py-3"
>
<p class="m-0 min-w-0 flex-1 text-sm text-secondary">
{{ formatMessage(messages.modLoaderManual, { fileName: manualModLoader.fileName }) }}
</p>
<ButtonStyled v-if="manualModLoader.pageUrl" type="outlined">
<button @click="openUrl(manualModLoader!.pageUrl!)">
<ExternalIcon />
{{ formatMessage(messages.openSource) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="busy" @click="importModLoader">
<FileArchiveIcon />
{{ formatMessage(messages.importModLoader) }}
</button>
</ButtonStyled>
</div>
<div class="overflow-hidden rounded-lg border border-surface-4">
<div
v-for="(component, index) in activeComponents"
:key="component.id"
class="flex items-center gap-3 border-b border-surface-4 px-3 py-3 last:border-b-0"
>
<Checkbox
:model-value="component.enabled"
:disabled="busy"
@update:model-value="
(enabled) => run(() => set_core_component_enabled(instance.id, component.id, enabled))
"
/>
<div class="min-w-0 flex-1">
<div class="truncate font-medium text-contrast">{{ component.fileName }}</div>
<div class="text-xs text-secondary">
{{
formatMessage(
component.kind === 'replacement_jar' ? messages.replacement : messages.jarMod,
)
}}
<span v-if="component.targetGameVersion">
· {{ formatMessage(messages.targetVersion) }} {{ component.targetGameVersion }}
</span>
<span v-if="component.source">
· {{ formatMessage(messages.source) }} {{ component.source.provider }}</span
>
</div>
<div v-if="component.sha256 || component.sha1" class="truncate text-xs text-secondary">
<span v-if="component.sha256"
>{{ formatMessage(messages.sha256) }} {{ component.sha256 }}</span
>
<span v-if="component.sha256 && component.sha1"> · </span>
<span v-if="component.sha1"
>{{ formatMessage(messages.sha1) }} {{ component.sha1 }}</span
>
</div>
<div v-if="component.failureReason" class="text-xs text-red">
{{ formatMessage(messages.failure) }}: {{ component.failureReason }}
</div>
</div>
<div class="flex shrink-0 items-center gap-1">
<ButtonStyled circular size="small" type="transparent">
<button
v-tooltip="formatMessage(messages.moveUp)"
:aria-label="formatMessage(messages.moveUp)"
:disabled="busy || index === 0"
@click="run(() => move_core_component(instance.id, component.id, -1))"
>
<ArrowUpIcon />
</button>
</ButtonStyled>
<ButtonStyled circular size="small" type="transparent">
<button
v-tooltip="formatMessage(messages.moveDown)"
:aria-label="formatMessage(messages.moveDown)"
:disabled="busy || index === activeComponents.length - 1"
@click="run(() => move_core_component(instance.id, component.id, 1))"
>
<ArrowDownIcon />
</button>
</ButtonStyled>
<ButtonStyled circular color="red" size="small" type="transparent">
<button
v-tooltip="formatMessage(messages.remove)"
:aria-label="formatMessage(messages.remove)"
:disabled="busy"
@click="run(() => remove_core_component(instance.id, component.id))"
>
<TrashIcon />
</button>
</ButtonStyled>
</div>
</div>
</div>
<div v-if="deletedComponents.length" class="overflow-hidden rounded-lg border border-surface-4">
<div
v-for="component in deletedComponents"
:key="component.id"
class="flex items-center gap-3 px-3 py-3"
>
<div class="min-w-0 flex-1 truncate text-secondary">{{ component.fileName }}</div>
<ButtonStyled circular size="small" type="transparent">
<button
v-tooltip="formatMessage(messages.restore)"
:aria-label="formatMessage(messages.restore)"
:disabled="busy"
@click="run(() => restore_core_component(instance.id, component.id))"
>
<RestoreIcon />
</button>
</ButtonStyled>
</div>
</div>
</div>
</template>

View File

@ -0,0 +1,553 @@
<script setup lang="ts">
import { CopyIcon, EditIcon, SpinnerIcon, TrashIcon, UploadIcon } from '@modrinth/assets'
import {
ButtonStyled,
Chips,
defineMessages,
injectFilePicker,
injectNotificationManager,
OverflowMenu,
RadioButtons,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { basename, dirname, join } from '@tauri-apps/api/path'
import { computed, type Ref, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { trackEvent } from '@/helpers/analytics'
import { install_duplicate_instance } from '@/helpers/install'
import { edit, edit_icon, get_full_path, remove } from '@/helpers/instance'
import { injectInstanceSettings } from '@/providers/instance-settings'
import type { GameInstance } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const filePicker = injectFilePicker()
const { formatMessage } = useVIntl()
const router = useRouter()
const queryClient = useQueryClient()
const deleteConfirmModal = ref()
const { instance } = injectInstanceSettings()
type ReleaseChannel = GameInstance['update_channel']
const releaseChannelOptions: ReleaseChannel[] = ['release', 'beta', 'alpha']
const title = ref(instance.value.name)
const icon: Ref<string | undefined> = ref(instance.value.icon_path)
const savingReleaseChannel = ref(false)
const selectedReleaseChannel = ref<ReleaseChannel>(instance.value.update_channel)
const releaseChannelDisabledItems = computed<ReleaseChannel[]>(() =>
savingReleaseChannel.value ? [...releaseChannelOptions] : [],
)
const installing = computed(() => instance.value.install_stage !== 'installed')
async function duplicateInstance() {
await install_duplicate_instance(instance.value.id).catch(handleError)
trackEvent('InstanceDuplicate', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
}
function formatReleaseChannelLabel(channel: ReleaseChannel) {
switch (channel) {
case 'release':
return formatMessage(messages.updateChannelRelease)
case 'beta':
return formatMessage(messages.updateChannelBeta)
case 'alpha':
return formatMessage(messages.updateChannelAlpha)
}
}
function formatReleaseChannelDescription(channel: ReleaseChannel) {
switch (channel) {
case 'release':
return formatMessage(messages.updateChannelReleaseDescription)
case 'beta':
return formatMessage(messages.updateChannelBetaDescription)
case 'alpha':
return formatMessage(messages.updateChannelAlphaDescription)
}
}
watch(
() => [instance.value.id, instance.value.update_channel] as const,
() => {
if (!savingReleaseChannel.value) {
selectedReleaseChannel.value = instance.value.update_channel
}
},
)
watch(selectedReleaseChannel, async (channel, previousChannel) => {
const previousReleaseChannel = previousChannel ?? instance.value.update_channel
if (channel === instance.value.update_channel) return
savingReleaseChannel.value = true
const instanceId = instance.value.id
await edit(instanceId, { update_channel: channel })
.then(() => queryClient.invalidateQueries({ queryKey: ['linkedModpackInfo', instanceId] }))
.catch((error) => {
selectedReleaseChannel.value = previousReleaseChannel
handleError(error)
})
savingReleaseChannel.value = false
})
async function resetIcon() {
icon.value = undefined
await edit_icon(instance.value.id, null).catch(handleError)
trackEvent('InstanceRemoveIcon')
}
async function setIcon() {
try {
const picked = await (filePicker.pickInstanceIcon?.() ?? filePicker.pickImage())
if (!picked?.path) return
const previousIcon = icon.value
icon.value = picked.path
try {
await edit_icon(instance.value.id, picked.path)
trackEvent('InstanceSetIcon')
} catch (error) {
icon.value = previousIcon
handleError(error)
}
} catch (error) {
handleError(error)
}
}
const gameDirOverride = ref(instance.value.game_dir_override)
const savingGameDir = ref(false)
const isDirectLinked = computed(() => !!instance.value.linked_dot_minecraft)
const configuredExternalGameDir = computed(
() => gameDirOverride.value ?? instance.value.linked_dot_minecraft ?? null,
)
const resolvedExternalGameDir = ref<string | null>(null)
const externalGameDir = computed(
() => resolvedExternalGameDir.value ?? configuredExternalGameDir.value,
)
let pathResolutionRevision = 0
async function refreshExternalGameDir() {
const revision = ++pathResolutionRevision
if (!configuredExternalGameDir.value) {
resolvedExternalGameDir.value = null
return
}
try {
const path = await get_full_path(instance.value.id)
if (revision === pathResolutionRevision) resolvedExternalGameDir.value = path
} catch {
// Keep the configured path visible while the external chain is unavailable.
if (revision === pathResolutionRevision) resolvedExternalGameDir.value = null
}
}
watch(
() => instance.value.game_dir_override,
(path) => {
gameDirOverride.value = path
void refreshExternalGameDir()
},
)
watch(
() => [instance.value.id, instance.value.linked_dot_minecraft] as const,
() => void refreshExternalGameDir(),
{ immediate: true },
)
// An external game dir is stored as a single path. Whether it is version
// isolated is encoded in the path: `<root>/versions/<name>` vs the `.minecraft`
// root itself. `isExternal` is false for built-in (managed) instances, which
// expose no isolation option.
const isExternal = computed(() => !!externalGameDir.value)
const gameDirInfo = ref<{ isolated: boolean; baseRoot: string | null }>({
isolated: false,
baseRoot: null,
})
async function refreshGameDirInfo() {
const path = configuredExternalGameDir.value
if (!path || isDirectLinked.value) {
gameDirInfo.value = { isolated: false, baseRoot: null }
return
}
try {
const parent = await dirname(path)
if ((await basename(parent)).toLowerCase() === 'versions') {
gameDirInfo.value = { isolated: true, baseRoot: await dirname(parent) }
} else {
gameDirInfo.value = { isolated: false, baseRoot: path }
}
} catch {
gameDirInfo.value = { isolated: false, baseRoot: path }
}
}
watch(
() => [configuredExternalGameDir.value, isDirectLinked.value] as const,
() => void refreshGameDirInfo(),
{ immediate: true },
)
type GameDirMode = 'isolated' | 'not-isolated'
const gameDirMode = computed<GameDirMode>({
get: () => (gameDirInfo.value.isolated ? 'isolated' : 'not-isolated'),
set: (mode) => void setGameDirMode(mode),
})
const gameDirModeItems: GameDirMode[] = ['isolated', 'not-isolated']
function gameDirModeLabel(mode: GameDirMode) {
return mode === 'isolated' ? messages.gameDirIsolated : messages.gameDirNotIsolated
}
async function setGameDirMode(mode: GameDirMode) {
const baseRoot = gameDirInfo.value.baseRoot
if (!baseRoot) return
const nextPath =
mode === 'isolated' ? await join(baseRoot, 'versions', instance.value.name) : baseRoot
if (nextPath === gameDirOverride.value) return
// The launcher only records the new override path; the user is responsible
// for actually moving the mods/saves/config folders to match.
const previous = gameDirOverride.value
gameDirOverride.value = nextPath
savingGameDir.value = true
try {
await edit(instance.value.id, { game_dir_override: nextPath })
} catch (error) {
gameDirOverride.value = previous
handleError(error)
} finally {
savingGameDir.value = false
}
}
const editInstanceObject = computed(() => ({
name: title.value.trim().substring(0, 32) ?? 'Instance',
}))
watch(
title,
async () => {
if (removing.value) return
await edit(instance.value.id, editInstanceObject.value).catch(handleError)
},
{ deep: true },
)
const removing = ref(false)
async function removeInstance() {
removing.value = true
const path = instance.value.id
trackEvent('InstanceRemove', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
await router.push({ path: '/' })
await remove(path).catch(handleError)
}
const messages = defineMessages({
icon: {
id: 'instance.settings.tabs.general.icon',
defaultMessage: 'Icon',
},
name: {
id: 'instance.settings.tabs.general.name',
defaultMessage: 'Name',
},
editIcon: {
id: 'instance.settings.tabs.general.edit-icon',
defaultMessage: 'Edit icon',
},
selectIcon: {
id: 'instance.settings.tabs.general.edit-icon.select',
defaultMessage: 'Select icon',
},
replaceIcon: {
id: 'instance.settings.tabs.general.edit-icon.replace',
defaultMessage: 'Replace icon',
},
removeIcon: {
id: 'instance.settings.tabs.general.edit-icon.remove',
defaultMessage: 'Remove icon',
},
duplicateInstance: {
id: 'instance.settings.tabs.general.duplicate-instance',
defaultMessage: 'Duplicate instance',
},
duplicateInstanceDescription: {
id: 'instance.settings.tabs.general.duplicate-instance.description',
defaultMessage: 'Creates a copy of this instance, including worlds, configs, mods, etc.',
},
duplicateButtonTooltipInstalling: {
id: 'instance.settings.tabs.general.duplicate-button.tooltip.installing',
defaultMessage: 'Cannot duplicate while installing.',
},
duplicateButton: {
id: 'instance.settings.tabs.general.duplicate-button',
defaultMessage: 'Duplicate',
},
gameDir: {
id: 'instance.settings.tabs.general.game-dir',
defaultMessage: 'Game directory',
},
gameDirDescription: {
id: 'instance.settings.tabs.general.game-dir.description',
defaultMessage:
'Uses a separate folder as the working directory for this instance. The game reads mods, saves, configs, and resource packs from that folder instead of the managed instance folder.',
},
gameDirCurrent: {
id: 'instance.settings.tabs.general.game-dir.current',
defaultMessage: 'Current directory',
},
gameDirIsolated: {
id: 'instance.settings.tabs.general.game-dir.isolated',
defaultMessage: 'Version isolated (stored in versions/)',
},
gameDirNotIsolated: {
id: 'instance.settings.tabs.general.game-dir.not-isolated',
defaultMessage: 'Version shared (.minecraft/)',
},
gameDirMoveNote: {
id: 'instance.settings.tabs.general.game-dir.move-note',
defaultMessage:
'Switching isolation only updates the launcher path. Move the mods, saves, and config folders yourself to match.',
},
gameDirManagedNote: {
id: 'instance.settings.tabs.general.game-dir.managed-note',
defaultMessage: 'This instance uses the Axolotl-managed folder.',
},
gameDirExternalNote: {
id: 'instance.settings.tabs.general.game-dir.external-note',
defaultMessage: 'This instance uses an external .minecraft folder managed in place.',
},
updateChannel: {
id: 'instance.settings.tabs.general.update-channel',
defaultMessage: 'Update channel',
},
updateChannelReleaseDescription: {
id: 'instance.settings.tabs.general.update-channel.release.description',
defaultMessage: 'Only release versions will be shown as available updates.',
},
updateChannelBetaDescription: {
id: 'instance.settings.tabs.general.update-channel.beta.description',
defaultMessage: 'Release and beta versions will be shown as available updates.',
},
updateChannelAlphaDescription: {
id: 'instance.settings.tabs.general.update-channel.alpha.description',
defaultMessage: 'Release, beta, and alpha versions will be shown as available updates.',
},
updateChannelRelease: {
id: 'instance.settings.tabs.general.update-channel.release',
defaultMessage: 'Release',
},
updateChannelBeta: {
id: 'instance.settings.tabs.general.update-channel.beta',
defaultMessage: 'Beta',
},
updateChannelAlpha: {
id: 'instance.settings.tabs.general.update-channel.alpha',
defaultMessage: 'Alpha',
},
selectUpdateChannelAriaLabel: {
id: 'instance.settings.tabs.general.update-channel.select',
defaultMessage: 'Select update channel',
},
deleteInstance: {
id: 'instance.settings.tabs.general.delete',
defaultMessage: 'Delete instance',
},
deleteInstanceDescription: {
id: 'instance.settings.tabs.general.delete.description',
defaultMessage:
'Permanently deletes an instance from your device, including your worlds, configs, and all installed content. Be careful, as once you delete a instance there is no way to recover it.',
},
deleteInstanceButton: {
id: 'instance.settings.tabs.general.delete.button',
defaultMessage: 'Delete instance',
},
deletingInstanceButton: {
id: 'instance.settings.tabs.general.deleting.button',
defaultMessage: 'Deleting...',
},
})
</script>
<template>
<ConfirmDeleteInstanceModal
ref="deleteConfirmModal"
:symlink-target="instance.symlink_target"
@delete="removeInstance"
/>
<div class="block">
<div class="float-end ml-10 relative group w-fit">
<div class="flex flex-col gap-1">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.icon) }}
</span>
<div class="group relative w-fit">
<OverflowMenu
v-tooltip="formatMessage(messages.editIcon)"
class="bg-transparent border-none appearance-none p-0 m-0 cursor-pointer group-active:scale-95 transition-transform"
:options="[
{
id: 'select',
action: () => setIcon(),
},
{
id: 'remove',
color: 'danger',
action: () => resetIcon(),
shown: !!icon,
},
]"
>
<InstanceIcon
:icon-path="icon"
:instance-id="instance.id"
:loader="instance.loader"
size="108px"
class="transition-[filter] group-hover:brightness-75"
no-shadow
/>
<div
class="absolute top-0 h-full w-full flex items-center justify-center opacity-0 transition-all group-hover:opacity-100"
>
<EditIcon aria-hidden="true" class="h-10 w-10 text-primary" />
</div>
<template #select>
<UploadIcon />
{{ icon ? formatMessage(messages.replaceIcon) : formatMessage(messages.selectIcon) }}
</template>
<template #remove> <TrashIcon /> {{ formatMessage(messages.removeIcon) }} </template>
</OverflowMenu>
</div>
</div>
</div>
<label for="instance-name" class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.name) }}
</label>
<div class="flex">
<StyledInput
id="instance-name"
v-model="title"
autocomplete="off"
:maxlength="80"
wrapper-class="flex-grow"
/>
</div>
<template v-if="instance.install_stage == 'installed'">
<div class="flex flex-col gap-2.5 mt-6">
<h2 id="duplicate-instance-label" class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.duplicateInstance) }}
</h2>
<ButtonStyled>
<button
v-tooltip="installing ? formatMessage(messages.duplicateButtonTooltipInstalling) : null"
aria-labelledby="duplicate-instance-label"
:disabled="installing"
class="w-max !shadow-none"
@click="duplicateInstance"
>
<CopyIcon /> {{ formatMessage(messages.duplicateButton) }}
</button>
</ButtonStyled>
<p class="m-0">
{{ formatMessage(messages.duplicateInstanceDescription) }}
</p>
</div>
</template>
<div class="flex flex-col gap-2.5 mt-6">
<h2 class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.gameDir) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.gameDirDescription) }}
</p>
<template v-if="isExternal">
<div v-if="!isDirectLinked" class="flex flex-col gap-1.5">
<RadioButtons v-model="gameDirMode" :items="gameDirModeItems" force-selection>
<template #default="{ item }">
{{ formatMessage(gameDirModeLabel(item)) }}
</template>
</RadioButtons>
</div>
<p v-if="externalGameDir" class="m-0 text-secondary break-all">
{{ formatMessage(messages.gameDirCurrent) }}:
<code>{{ externalGameDir }}</code>
</p>
<p v-if="!isDirectLinked" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.gameDirMoveNote) }}
</p>
<p v-else class="m-0 text-sm text-secondary">
{{ formatMessage(messages.gameDirExternalNote) }}
</p>
</template>
<p v-else class="m-0 text-sm text-secondary">
{{ formatMessage(messages.gameDirManagedNote) }}
</p>
</div>
<div class="flex flex-col gap-2.5 mt-6">
<h2 class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.updateChannel) }}
</h2>
<Chips
v-model="selectedReleaseChannel"
:items="releaseChannelOptions"
:format-label="formatReleaseChannelLabel"
:capitalize="false"
:disabled-items="releaseChannelDisabledItems"
:aria-label="formatMessage(messages.selectUpdateChannelAriaLabel)"
/>
<p class="m-0">
{{ formatReleaseChannelDescription(selectedReleaseChannel) }}
</p>
</div>
<div class="flex flex-col gap-2.5 mt-6">
<h2 id="delete-instance-label" class="m-0 text-lg font-semibold text-contrast block">
{{ formatMessage(messages.deleteInstance) }}
</h2>
<ButtonStyled color="red">
<button
aria-labelledby="delete-instance-label"
:disabled="removing"
class="w-fit !shadow-none"
@click="deleteConfirmModal.show()"
>
<SpinnerIcon v-if="removing" class="animate-spin" />
<TrashIcon v-else />
{{
removing
? formatMessage(messages.deletingInstanceButton)
: formatMessage(messages.deleteInstanceButton)
}}
</button>
</ButtonStyled>
<p class="m-0">
{{ formatMessage(messages.deleteInstanceDescription) }}
</p>
</div>
</div>
</template>
<style scoped lang="scss">
.hovering-icon-shadow {
box-shadow: var(--shadow-inset-sm), var(--shadow-raised);
}
</style>

View File

@ -0,0 +1,208 @@
<script setup lang="ts">
import {
Checkbox,
defineMessages,
injectNotificationManager,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, ref, watch } from 'vue'
import { edit } from '@/helpers/instance'
import { get } from '@/helpers/settings.ts'
import { injectInstanceSettings } from '@/providers/instance-settings'
import type { AppSettings, Hooks } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const { instance } = injectInstanceSettings()
const globalSettings = (await get().catch(handleError)) as AppSettings
const overrideHooks = ref(
!!instance.value.hooks.pre_launch ||
!!instance.value.hooks.wrapper ||
!!instance.value.hooks.post_exit,
)
const hooks = ref(instance.value.hooks ?? globalSettings.hooks)
const overrideLaunchPreparationTimeout = ref(instance.value.launch_preparation_timeout != null)
const launchPreparationTimeout = ref(
Math.min(600, Math.max(30, instance.value.launch_preparation_timeout ?? 60)),
)
const editInstanceObject = computed(() => {
const editInstancePatch: {
hooks?: Hooks
launch_preparation_timeout?: number | null
} = {}
// When hooks are not overridden per-instance, we want to clear them
editInstancePatch.hooks = overrideHooks.value ? hooks.value : {}
editInstancePatch.launch_preparation_timeout = overrideLaunchPreparationTimeout.value
? Math.min(600, Math.max(30, Math.round(launchPreparationTimeout.value || 60)))
: null
return editInstancePatch
})
watch(
[overrideHooks, hooks, overrideLaunchPreparationTimeout, launchPreparationTimeout],
async () => {
await edit(instance.value.id, editInstanceObject.value)
},
{ deep: true },
)
const messages = defineMessages({
hooks: {
id: 'instance.settings.tabs.hooks.title',
defaultMessage: 'Launch preparation',
},
hooksDescription: {
id: 'instance.settings.tabs.hooks.description',
defaultMessage:
'Configure the time allowed for launch preparation and optional commands that run before and after the game.',
},
launchPreparationTimeout: {
id: 'instance.settings.tabs.hooks.launch-preparation-timeout',
defaultMessage: 'Launch preparation timeout',
},
launchPreparationTimeoutDescription: {
id: 'instance.settings.tabs.hooks.launch-preparation-timeout.description',
defaultMessage: 'Maximum time to wait for launch preparation to finish, in seconds (30600).',
},
customLaunchPreparationTimeout: {
id: 'instance.settings.tabs.hooks.custom-launch-preparation-timeout',
defaultMessage: 'Use a custom launch preparation timeout',
},
customHooks: {
id: 'instance.settings.tabs.hooks.custom-hooks',
defaultMessage: 'Custom launch hooks',
},
preLaunch: {
id: 'instance.settings.tabs.hooks.pre-launch',
defaultMessage: 'Pre-launch',
},
preLaunchDescription: {
id: 'instance.settings.tabs.hooks.pre-launch.description',
defaultMessage: 'Ran before the instance is launched.',
},
preLaunchEnter: {
id: 'instance.settings.tabs.hooks.pre-launch.enter',
defaultMessage: 'Enter pre-launch command...',
},
wrapper: {
id: 'instance.settings.tabs.hooks.wrapper',
defaultMessage: 'Wrapper',
},
wrapperDescription: {
id: 'instance.settings.tabs.hooks.wrapper.description',
defaultMessage: 'Wrapper command for launching Minecraft.',
},
wrapperEnter: {
id: 'instance.settings.tabs.hooks.wrapper.enter',
defaultMessage: 'Enter wrapper command...',
},
postExit: {
id: 'instance.settings.tabs.hooks.post-exit',
defaultMessage: 'Post-exit',
},
postExitDescription: {
id: 'instance.settings.tabs.hooks.post-exit.description',
defaultMessage: 'Ran after the game closes.',
},
postExitEnter: {
id: 'instance.settings.tabs.hooks.post-exit.enter',
defaultMessage: 'Enter post-exit command...',
},
})
function normalizeLaunchPreparationTimeout() {
launchPreparationTimeout.value = Math.min(
600,
Math.max(30, Math.round(Number(launchPreparationTimeout.value) || 60)),
)
}
</script>
<template>
<div>
<h2 class="m-0 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.hooks) }}
</h2>
<Checkbox v-model="overrideHooks" :label="formatMessage(messages.customHooks)" class="my-2.5" />
<p class="m-0">
{{ formatMessage(messages.hooksDescription) }}
</p>
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.launchPreparationTimeout) }}
</h2>
<Checkbox
v-model="overrideLaunchPreparationTimeout"
:label="formatMessage(messages.customLaunchPreparationTimeout)"
class="my-2.5"
/>
<StyledInput
id="launch-preparation-timeout"
v-model="launchPreparationTimeout"
autocomplete="off"
:disabled="!overrideLaunchPreparationTimeout"
type="number"
min="30"
max="600"
step="1"
wrapper-class="w-full my-2.5"
@blur="normalizeLaunchPreparationTimeout"
/>
<p class="m-0">
{{ formatMessage(messages.launchPreparationTimeoutDescription) }}
</p>
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.preLaunch) }}
</h2>
<StyledInput
id="pre-launch"
v-model="hooks.pre_launch"
autocomplete="off"
:disabled="!overrideHooks"
:placeholder="formatMessage(messages.preLaunchEnter)"
wrapper-class="w-full my-2.5"
/>
<p class="m-0">
{{ formatMessage(messages.preLaunchDescription) }}
</p>
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.wrapper) }}
</h2>
<StyledInput
id="wrapper"
v-model="hooks.wrapper"
autocomplete="off"
:disabled="!overrideHooks"
:placeholder="formatMessage(messages.wrapperEnter)"
wrapper-class="w-full my-2.5"
/>
<p class="m-0">
{{ formatMessage(messages.wrapperDescription) }}
</p>
<h2 class="mt-6 m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.postExit) }}
</h2>
<StyledInput
id="post-exit"
v-model="hooks.post_exit"
autocomplete="off"
:disabled="!overrideHooks"
:placeholder="formatMessage(messages.postExitEnter)"
wrapper-class="w-full my-2.5"
/>
<p class="m-0">
{{ formatMessage(messages.postExitDescription) }}
</p>
</div>
</template>

View File

@ -0,0 +1,589 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
commonMessages,
defineMessages,
formatLoaderLabel,
injectFilePicker,
injectNotificationManager,
InstallationSettingsLayout,
instanceInstallablePlatforms,
type LoaderMetadataStatus,
loaderSupportState,
loaderVersionsForGameVersion,
provideAppBackup,
provideInstallationSettings,
scopedLoaderMetadataQueryKey,
useDebugLogger,
useVIntl,
} from '@modrinth/ui'
import type { GameVersionTag } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
import { trackEvent } from '@/helpers/analytics'
import { get_project_versions, get_version } from '@/helpers/cache'
import { type CurseForgeFile, updateManagedCurseForgeModpack } from '@/helpers/curseforge'
import {
install_duplicate_instance,
install_existing_instance,
install_pack_to_existing_instance,
installJobInstanceId,
wait_for_install_job,
} from '@/helpers/install'
import {
edit,
get_linked_modpack_info,
list,
update_managed_modrinth_version,
update_repair_modrinth,
} from '@/helpers/instance'
import { get_loader_versions } from '@/helpers/metadata'
import { get_game_versions } from '@/helpers/tags'
import { injectInstanceSettings } from '@/providers/instance-settings'
import { useTheming } from '@/store/state'
import type { Manifest } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const filePicker = injectFilePicker()
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const debug = useDebugLogger('AppInstallationSettings')
const themeStore = useTheming()
const { instance, offline, isMinecraftServer, onUnlinked, closeModal } = injectInstanceSettings()
const skipNonEssentialWarnings = computed(() =>
themeStore.getFeatureFlag('skip_non_essential_warnings'),
)
debug('metadata load: start', {
instanceId: instance.value.id,
loader: instance.value.loader,
gameVersion: instance.value.game_version,
installStage: instance.value.install_stage,
})
const gameVersionsQuery = useQuery({
queryKey: ['instance-settings', 'game-versions'],
queryFn: () => get_game_versions() as Promise<GameVersionTag[]>,
})
const editingPlatform = ref(instance.value.loader)
const editingGameVersion = ref(instance.value.game_version)
const scopedLoader = computed(() =>
editingPlatform.value === 'neoforge' ? 'neo' : editingPlatform.value,
)
const scopedLoaderQueryEnabled = computed(
() => editingPlatform.value !== 'vanilla' && !!editingGameVersion.value,
)
const scopedLoaderVersionsQuery = useQuery({
queryKey: computed(() =>
scopedLoaderMetadataQueryKey('instance-settings', scopedLoader.value, editingGameVersion.value),
),
queryFn: ({ queryKey }) => get_loader_versions(queryKey[2], queryKey[3]) as Promise<Manifest>,
enabled: scopedLoaderQueryEnabled,
})
const scopedLoaderMetadataStatus = computed<LoaderMetadataStatus>(() => {
if (!scopedLoaderQueryEnabled.value) return 'unknown'
if (scopedLoaderVersionsQuery.isPending.value || scopedLoaderVersionsQuery.isFetching.value) {
return 'loading'
}
if (scopedLoaderVersionsQuery.isError.value) return 'error'
return 'success'
})
const scopedLoaderVersionState = computed(() =>
loaderSupportState(
scopedLoaderMetadataStatus.value,
scopedLoaderVersionsQuery.data.value,
editingGameVersion.value,
),
)
const metadataLoading = computed(() => gameVersionsQuery.isLoading.value)
debug('metadata queries configured', {
instanceId: instance.value.id,
loader: instance.value.loader,
gameVersion: instance.value.game_version,
})
const isModrinthLinkedModpack = computed(
() =>
instance.value.link?.type === 'modrinth_modpack' ||
instance.value.link?.type === 'server_project_modpack',
)
const isCurseForgeLinkedModpack = computed(() => instance.value.link?.type === 'curseforge_modpack')
const isLinkedManagedModpack = computed(
() => isModrinthLinkedModpack.value || isCurseForgeLinkedModpack.value,
)
const isImportedModpack = computed(() => instance.value.link?.type === 'imported_modpack')
const modpackInfoQuery = useQuery({
queryKey: computed(() => ['linkedModpackInfo', instance.value.id]),
queryFn: () => get_linked_modpack_info(instance.value.id, 'must_revalidate'),
enabled: computed(
() => instance.value.install_stage === 'installed' && isLinkedManagedModpack.value && !offline,
),
})
const modpackInfo = modpackInfoQuery.data
const repairing = ref(false)
const reinstalling = ref(false)
const messages = defineMessages({
loaderVersion: {
id: 'instance.settings.tabs.installation.loader-version',
defaultMessage: '{loader} version',
},
})
async function installLocalModpackFromPicker() {
const picked = await filePicker.pickModpackFile({ readFile: false })
if (!picked?.path) return false
const job = await install_pack_to_existing_instance(instance.value.id, {
type: 'fromFile',
path: picked.path,
}).catch(handleError)
if (!job) return false
const completed = await wait_for_install_job(job.job_id).catch(handleError)
return !!completed
}
provideAppBackup({
async createBackup() {
debug('createBackup: start', {
instanceId: instance.value.id,
instanceName: instance.value.name,
})
const allInstances = await list()
const prefix = `${instance.value.name} - Backup #`
const existingNums = allInstances
.filter((p) => p.name.startsWith(prefix))
.map((p) => parseInt(p.name.slice(prefix.length), 10))
.filter((n) => !isNaN(n))
const nextNum = existingNums.length > 0 ? Math.max(...existingNums) + 1 : 1
const job = await install_duplicate_instance(instance.value.id)
const newInstanceId = installJobInstanceId(job)
if (newInstanceId) {
await edit(newInstanceId, { name: `${prefix}${nextNum}` })
}
debug('createBackup: done', { newInstanceId, backupName: `${prefix}${nextNum}` })
},
})
provideInstallationSettings({
closeSettings: closeModal,
loading: computed(() => metadataLoading.value || modpackInfoQuery.isLoading.value),
installationInfo: computed(() => {
const rows = [
{
label: formatMessage(commonMessages.platformLabel),
value: formatLoaderLabel(instance.value.loader),
},
{
label: formatMessage(commonMessages.gameVersionLabel),
value: instance.value.game_version,
},
]
if ((instance.value.loader_components ?? []).length > 1) {
for (const component of instance.value.loader_components ?? []) {
rows.push({
label: formatMessage(messages.loaderVersion, {
loader: formatLoaderLabel(component.kind),
}),
value: component.version ?? formatLoaderLabel(component.kind),
})
}
} else if (instance.value.loader !== 'vanilla' && instance.value.loader_version) {
rows.push({
label: formatMessage(messages.loaderVersion, {
loader: formatLoaderLabel(instance.value.loader),
}),
value: instance.value.loader_version,
})
}
return rows
}),
isLinked: computed(() => isLinkedManagedModpack.value || isImportedModpack.value),
isBusy: computed(
() =>
instance.value.install_stage !== 'installed' ||
repairing.value ||
reinstalling.value ||
!!offline,
),
skipNonEssentialWarnings,
modpack: computed(() => {
if (isImportedModpack.value && instance.value.link?.type === 'imported_modpack') {
return {
iconUrl: instance.value.icon_path,
title: instance.value.link.name ?? instance.value.name,
versionNumber: instance.value.link.version_number ?? undefined,
filename: instance.value.link.filename ?? undefined,
}
}
if (modpackInfo.value) {
return {
iconUrl: modpackInfo.value.project.icon_url,
title: modpackInfo.value.project.title,
link: isCurseForgeLinkedModpack.value
? `/project/curseforge/${String(modpackInfo.value.project.id).replace(/^curseforge:/, '')}`
: `/project/${modpackInfo.value.project.slug ?? modpackInfo.value.project.id}`,
versionNumber: modpackInfo.value.version?.version_number,
}
}
// Fallback when linked metadata is temporarily unavailable so the
// association controls still match Modrinth-linked packs.
if (isCurseForgeLinkedModpack.value && instance.value.link?.type === 'curseforge_modpack') {
return {
iconUrl: instance.value.icon_path,
title: instance.value.name,
link: `/project/curseforge/${instance.value.link.project_id}`,
versionNumber: instance.value.link.version_id,
}
}
if (isModrinthLinkedModpack.value && instance.value.link) {
const projectId =
instance.value.link.type === 'server_project_modpack'
? (instance.value.link.content_project_id ?? instance.value.link.project_id)
: instance.value.link.project_id
const versionId =
instance.value.link.type === 'server_project_modpack'
? instance.value.link.content_version_id
: instance.value.link.version_id
if (!projectId) return null
return {
iconUrl: instance.value.icon_path,
title: instance.value.name,
link: `/project/${projectId}`,
versionNumber: versionId ?? undefined,
}
}
return null
}),
currentPlatform: computed(() => instance.value.loader),
currentGameVersion: computed(() => instance.value.game_version),
currentLoaderVersion: computed(() => instance.value.loader_version ?? ''),
availablePlatforms: computed(() => [...instanceInstallablePlatforms]),
editingPlatformRef: editingPlatform,
editingGameVersionRef: editingGameVersion,
loaderVersionState: scopedLoaderVersionState,
resolveGameVersions(loader, showSnapshots) {
const versions = gameVersionsQuery.data.value ?? []
const result = (
showSnapshots ? versions : versions.filter((x) => x.version_type === 'release')
).map((x) => ({ value: x.version, label: x.version }))
debug('resolveGameVersions:', {
loader,
showSnapshots,
totalVersions: versions.length,
resultVersions: result.length,
})
return result
},
resolveLoaderVersions(loader, gameVersion) {
if (loader === 'vanilla' || !gameVersion) {
debug('resolveLoaderVersions: skipped', { loader, gameVersion })
return []
}
if (loader !== editingPlatform.value || gameVersion !== editingGameVersion.value) {
debug('resolveLoaderVersions: stale selection', { loader, gameVersion })
return []
}
if (scopedLoaderVersionState.value !== 'supported') return []
const result = loaderVersionsForGameVersion(scopedLoaderVersionsQuery.data.value, gameVersion)
debug('resolveLoaderVersions: result', { loader, gameVersion, count: result.length })
return result
},
resolveHasSnapshots(loader) {
const versions = gameVersionsQuery.data.value ?? []
const result = versions.some((x) => x.version_type !== 'release')
debug('resolveHasSnapshots:', {
loader,
totalVersions: versions.length,
result,
})
return result
},
async save(platform, gameVersion, loaderVersionId) {
debug('save: called', {
instanceId: instance.value.id,
platform,
gameVersion,
loaderVersionId,
})
const editInstancePatch: Record<string, string | undefined> = {
loader: platform,
game_version: gameVersion,
}
if (platform !== 'vanilla' && loaderVersionId) {
editInstancePatch.loader_version = loaderVersionId
}
await edit(instance.value.id, editInstancePatch).catch(handleError)
debug('save: edit complete', { editInstancePatch })
},
afterSave: async () => {
debug('afterSave: installing', { instanceId: instance.value.id })
await install_existing_instance(instance.value.id, false).catch(handleError)
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
debug('afterSave: done')
},
async repair() {
debug('repair: called', { instanceId: instance.value.id })
repairing.value = true
await install_existing_instance(instance.value.id, true).catch(handleError)
repairing.value = false
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
debug('repair: done')
},
async reinstallModpack() {
debug('reinstallModpack: called', { instanceId: instance.value.id })
reinstalling.value = true
let shouldTrack = false
try {
if (isImportedModpack.value) {
shouldTrack = await installLocalModpackFromPicker()
} else if (isCurseForgeLinkedModpack.value) {
const fileId = Number(instance.value.link?.version_id)
if (!Number.isFinite(fileId)) {
throw new Error('Invalid CurseForge file ID')
}
await updateManagedCurseForgeModpack(instance.value.id, fileId).catch(handleError)
shouldTrack = true
} else {
await update_repair_modrinth(instance.value.id).catch(handleError)
shouldTrack = true
}
} finally {
reinstalling.value = false
}
if (shouldTrack) {
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
}
debug('reinstallModpack: done')
},
async swapModpack() {
debug('swapModpack: called', { instanceId: instance.value.id })
reinstalling.value = true
try {
const installed = await installLocalModpackFromPicker()
if (installed) {
trackEvent('InstanceRepair', {
loader: instance.value.loader,
game_version: instance.value.game_version,
})
}
} finally {
reinstalling.value = false
}
debug('swapModpack: done')
},
async unlinkModpack() {
debug('unlinkModpack: called', { instanceId: instance.value.id })
await edit(instance.value.id, {
link: null as unknown as undefined,
})
await queryClient.invalidateQueries({
queryKey: ['linkedModpackInfo', instance.value.id],
})
onUnlinked()
debug('unlinkModpack: done')
},
getCachedModpackVersions: () => null,
async fetchModpackVersions() {
debug('fetchModpackVersions: called', {
projectId: instance.value.link?.project_id,
})
if (isCurseForgeLinkedModpack.value) {
const rawProjectId = instance.value.link?.project_id
if (!rawProjectId) return []
const projectId = Number(
rawProjectId.startsWith('curseforge:')
? rawProjectId.slice('curseforge:'.length)
: rawProjectId,
)
if (!Number.isFinite(projectId)) return []
const { getCurseForgeFile, getCurseForgeFiles } = await import('@/helpers/curseforge')
const files: CurseForgeFile[] = []
let index = 0
while (true) {
const response = await getCurseForgeFiles(projectId, {
index,
pageSize: 50,
}).catch(handleError)
if (!response) break
files.push(...response.files)
index += response.files.length
if (
response.files.length === 0 ||
index >= (response.pagination?.totalCount ?? response.files.length)
) {
break
}
}
const installedFileId = Number(instance.value.link?.version_id)
if (Number.isFinite(installedFileId) && !files.some((file) => file.id === installedFileId)) {
const installedFile = await getCurseForgeFile(projectId, installedFileId).catch(() => null)
if (installedFile?.isAvailable) {
files.push(installedFile)
}
}
const versions = files
.filter((file) => file.isAvailable)
.map((file) => {
const loaders = [
...new Set(
file.gameVersions
.map((value) => {
switch (value.toLowerCase().replaceAll(' ', '')) {
case 'forge':
return 'forge'
case 'fabric':
case 'fabricloader':
return 'fabric'
case 'quilt':
return 'quilt'
case 'neoforge':
return 'neoforge'
default:
return null
}
})
.filter(Boolean),
),
] as string[]
const gameVersions = file.gameVersions.filter((value) => {
const normalized = value.toLowerCase().replaceAll(' ', '')
return !['forge', 'fabric', 'fabricloader', 'quilt', 'neoforge'].includes(normalized)
})
return {
id: file.id.toString(),
project_id: `curseforge:${projectId}`,
name: file.displayName,
version_number: file.displayName,
game_versions: gameVersions,
loaders: loaders.length > 0 ? loaders : ['minecraft'],
date_published: file.fileDate,
version_type:
file.releaseType === 1 ? 'release' : file.releaseType === 2 ? 'beta' : 'alpha',
files: [
{
filename: file.fileName,
url: file.downloadUrl ?? '',
primary: true,
size: file.fileLength,
hashes: {},
},
],
} as unknown as Labrinth.Versions.v2.Version
})
debug('fetchModpackVersions: done', { count: versions.length })
return versions
}
const versions = await get_project_versions(instance.value.link!.project_id!).catch(handleError)
debug('fetchModpackVersions: done', { count: versions?.length ?? 0 })
return (versions ?? []) as Labrinth.Versions.v2.Version[]
},
async getVersionChangelog(versionId: string) {
debug('getVersionChangelog: called', { versionId })
if (isCurseForgeLinkedModpack.value) {
const rawProjectId = instance.value.link?.project_id
const fileId = Number(versionId)
const projectId = rawProjectId
? Number(
rawProjectId.startsWith('curseforge:')
? rawProjectId.slice('curseforge:'.length)
: rawProjectId,
)
: NaN
if (!Number.isFinite(projectId) || !Number.isFinite(fileId)) return null
const { getCurseForgeChangelog } = await import('@/helpers/curseforge')
const changelog = await getCurseForgeChangelog(projectId, fileId).catch(() => null)
if (changelog == null) return null
return { id: versionId, changelog } as unknown as Labrinth.Versions.v2.Version
}
return (await get_version(versionId, 'must_revalidate').catch(
() => null,
)) as Labrinth.Versions.v2.Version | null
},
async onModpackVersionConfirm(version) {
debug('onModpackVersionConfirm: called', {
versionId: version.id,
instanceId: instance.value.id,
})
try {
if (isCurseForgeLinkedModpack.value) {
const fileId = Number(version.id)
if (!Number.isFinite(fileId)) {
throw new Error('Invalid CurseForge file ID')
}
await updateManagedCurseForgeModpack(instance.value.id, fileId)
} else {
await update_managed_modrinth_version(instance.value.id, version.id)
}
await queryClient.invalidateQueries({
queryKey: ['linkedModpackInfo', instance.value.id],
})
} catch (error) {
handleError(error as Error)
}
debug('onModpackVersionConfirm: done')
},
updaterModalProps: computed(() => ({
isApp: true,
currentVersionId:
modpackInfo.value?.update?.provider === 'modrinth'
? modpackInfo.value.update.target_version_id
: modpackInfo.value?.update?.provider === 'curseforge'
? String(modpackInfo.value.update.target_file_id)
: (instance.value.link?.version_id ?? ''),
projectIconUrl: modpackInfo.value?.project?.icon_url,
projectName: modpackInfo.value?.project?.title ?? 'Modpack',
currentGameVersion: instance.value.game_version,
currentLoader: instance.value.loader,
})),
isServer: false,
isApp: true,
symlinkTarget: computed(() => instance.value.symlink_target),
showModpackVersionActions: computed(
() => isLinkedManagedModpack.value && !isMinecraftServer.value,
),
isLocalFile: isImportedModpack,
repairing,
reinstalling,
})
</script>
<template>
<SymlinkInstanceWarning
v-if="instance?.symlink_target"
:symlink-target="instance.symlink_target"
/>
<InstallationSettingsLayout />
</template>

View File

@ -0,0 +1,291 @@
<script setup lang="ts">
import {
Checkbox,
defineMessages,
injectNotificationManager,
Slider,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { platform } from '@tauri-apps/plugin-os'
import { computed, readonly, ref, watch } from 'vue'
import JavaArgumentsInput from '@/components/ui/JavaArgumentsInput.vue'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import MemoryAllocationDisplay from '@/components/ui/MemoryAllocationDisplay.vue'
import useMemorySlider from '@/composables/useMemorySlider'
import { collectGcContext, extractJavaMajorVersion } from '@/helpers/gc/context'
import type { GcContext } from '@/helpers/gc/types'
import { edit, get_content_snapshot, get_optimal_jre_key } from '@/helpers/instance'
import { get } from '@/helpers/settings'
import { injectInstanceSettings } from '@/providers/instance-settings'
import type { AppSettings } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
javaInstallation: {
id: 'instance.settings.tabs.java.java-installation',
defaultMessage: 'Java installation',
},
customJavaInstallation: {
id: 'instance.settings.tabs.java.custom-java-installation',
defaultMessage: 'Use a custom Java installation for this instance',
},
javaPathPlaceholder: {
id: 'instance.settings.tabs.java.java-path-placeholder',
defaultMessage: '/path/to/java',
},
javaMemory: {
id: 'instance.settings.tabs.java.java-memory',
defaultMessage: 'Memory allocated',
},
customMemoryAllocation: {
id: 'instance.settings.tabs.java.custom-memory-allocation',
defaultMessage: 'Custom memory allocation',
},
automaticMemory: {
id: 'instance.settings.tabs.java.automatic-memory',
defaultMessage: 'Automatically allocate memory at launch',
},
optimizeMemoryBeforeLaunch: {
id: 'instance.settings.tabs.java.optimize-memory-before-launch',
defaultMessage: 'Optimize memory before launching the game',
},
optimizeMemoryBeforeLaunchDescription: {
id: 'instance.settings.tabs.java.optimize-memory-before-launch-description',
defaultMessage: 'Waits for Windows memory optimization to finish before starting the game.',
},
javaArguments: {
id: 'instance.settings.tabs.java.java-arguments',
defaultMessage: 'Java arguments',
},
customJavaArguments: {
id: 'instance.settings.tabs.java.custom-java-arguments',
defaultMessage: 'Custom Java arguments',
},
enterJavaArguments: {
id: 'instance.settings.tabs.java.enter-java-arguments',
defaultMessage: 'Enter Java arguments...',
},
javaEnvironmentVariables: {
id: 'instance.settings.tabs.java.environment-variables',
defaultMessage: 'Environment variables',
},
customEnvironmentVariables: {
id: 'instance.settings.tabs.java.custom-environment-variables',
defaultMessage: 'Custom environment variables',
},
enterEnvironmentVariables: {
id: 'instance.settings.tabs.java.enter-environment-variables',
defaultMessage: 'Enter environmental variables...',
},
})
const { instance } = injectInstanceSettings()
const supportsMemoryOptimization = (await platform()) === 'windows'
const globalSettings = (await get().catch(handleError)) as unknown as AppSettings
const optimalJava = readonly(await get_optimal_jre_key(instance.value.id).catch(handleError))
const requiredJavaVersion = optimalJava?.parsed_version ?? null
const overrideJavaInstall = ref(!!instance.value.java_path)
const overrideJava = ref({
...(optimalJava ?? {}),
path: instance.value.java_path ?? optimalJava?.path ?? '',
})
const displayedJava = computed({
get: () => (overrideJavaInstall.value ? overrideJava.value : (optimalJava ?? overrideJava.value)),
set: (value) => {
overrideJava.value = value
},
})
watch(overrideJavaInstall, (enabled) => {
if (enabled && !overrideJava.value.path) {
overrideJava.value = { ...(optimalJava ?? {}), path: optimalJava?.path ?? '' }
}
})
const overrideJavaArgs = ref((instance.value.extra_launch_args?.length ?? 0) > 0)
const javaArgs = ref(
(instance.value.extra_launch_args ?? globalSettings?.extra_launch_args ?? []).join(' '),
)
const overrideEnvVars = ref((instance.value.custom_env_vars?.length ?? 0) > 0)
const envVars = ref(
(instance.value.custom_env_vars ?? globalSettings?.custom_env_vars ?? [])
.map((x: string[]) => x.join('='))
.join(' '),
)
const defaultMemory = { maximum: 2048, automatic: true, optimize_before_launch: false }
const overrideMemorySettings = ref(!!instance.value.memory)
const memory = ref({
...defaultMemory,
...(instance.value.memory ?? globalSettings?.memory),
})
const effectiveMemory = computed(() =>
overrideMemorySettings.value ? memory.value : { ...defaultMemory, ...globalSettings?.memory },
)
const memData = await useMemorySlider().catch(() => ({
maxMemory: ref(4096),
snapPoints: computed(() => []),
}))
const maxMemory = memData.maxMemory
const snapPoints = memData.snapPoints
const gcContext = ref<GcContext | null>(null)
async function updateGcContext() {
const javaMajorVersion = extractJavaMajorVersion(displayedJava.value?.parsed_version)
let modCount = 0
try {
const snapshot = await get_content_snapshot(instance.value.id)
modCount = snapshot.items.filter(
(item) => item.projectType === 'mod' && item.materializationState === 'present',
).length
} catch {
modCount = 0
}
gcContext.value = await collectGcContext(
memory.value.maximum,
instance.value.loader,
javaMajorVersion,
modCount,
)
}
await updateGcContext()
watch([memory, displayedJava, () => instance.value.loader], updateGcContext)
const editInstanceObject = computed(() => ({
java_path:
overrideJavaInstall.value && overrideJava.value.path
? overrideJava.value.path.replace('java.exe', 'javaw.exe')
: null,
extra_launch_args: overrideJavaArgs.value
? javaArgs.value.trim().split(/\s+/).filter(Boolean)
: null,
custom_env_vars: overrideEnvVars.value
? envVars.value
.trim()
.split(/\s+/)
.filter(Boolean)
.map((x: string) => x.split('=').filter(Boolean))
: null,
memory: overrideMemorySettings.value ? memory.value : null,
}))
watch(
[
overrideJavaInstall,
overrideJava,
overrideJavaArgs,
javaArgs,
overrideEnvVars,
envVars,
overrideMemorySettings,
memory,
],
async () => {
await edit(instance.value.id, editInstanceObject.value).catch(handleError)
},
{ deep: true },
)
</script>
<template>
<div>
<h2 class="m-0 mb-2 block text-base font-extrabold text-contrast">
{{ formatMessage(messages.javaInstallation) }}
</h2>
<Checkbox
v-model="overrideJavaInstall"
:label="formatMessage(messages.customJavaInstallation)"
class="mb-2"
/>
<JavaSelector
v-model="displayedJava"
:disabled="!overrideJavaInstall"
:placeholder="formatMessage(messages.javaPathPlaceholder)"
:version="requiredJavaVersion"
select-all-versions
/>
<h2 class="mb-1 mt-4 block text-base font-extrabold text-contrast">
{{ formatMessage(messages.javaMemory) }}
</h2>
<Checkbox
v-model="overrideMemorySettings"
:label="formatMessage(messages.customMemoryAllocation)"
class="mb-2"
/>
<Checkbox
v-if="overrideMemorySettings"
v-model="memory.automatic"
:label="formatMessage(messages.automaticMemory)"
class="mb-2"
/>
<div
v-if="supportsMemoryOptimization && overrideMemorySettings"
class="mb-2 flex flex-col gap-1"
>
<Checkbox
v-model="memory.optimize_before_launch"
:label="formatMessage(messages.optimizeMemoryBeforeLaunch)"
/>
<p class="m-0 text-xs leading-tight text-secondary">
{{ formatMessage(messages.optimizeMemoryBeforeLaunchDescription) }}
</p>
</div>
<Slider
id="max-memory"
v-model="memory.maximum"
:disabled="!overrideMemorySettings || memory.automatic"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<MemoryAllocationDisplay :instance-id="instance.id" :memory="effectiveMemory" />
<h2 class="mb-1 mt-4 block text-base font-extrabold text-contrast">
{{ formatMessage(messages.javaArguments) }}
</h2>
<Checkbox
v-model="overrideJavaArgs"
:label="formatMessage(messages.customJavaArguments)"
class="my-1"
/>
<JavaArgumentsInput
id="java-args"
v-model="javaArgs"
:disabled="!overrideJavaArgs"
:gc-context="gcContext"
:show-auto-details="true"
:placeholder="formatMessage(messages.enterJavaArguments)"
/>
<h2 class="mb-1 mt-4 block text-base font-extrabold text-contrast">
{{ formatMessage(messages.javaEnvironmentVariables) }}
</h2>
<Checkbox
v-model="overrideEnvVars"
:label="formatMessage(messages.customEnvironmentVariables)"
class="mb-2"
/>
<StyledInput
id="env-vars"
v-model="envVars"
autocomplete="off"
:disabled="!overrideEnvVars"
:placeholder="formatMessage(messages.enterEnvironmentVariables)"
wrapper-class="w-full"
/>
</div>
</template>

View File

@ -0,0 +1,204 @@
<script setup lang="ts">
import {
Checkbox,
defineMessages,
injectNotificationManager,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { platform } from '@tauri-apps/plugin-os'
import { computed, type Ref, ref, watch } from 'vue'
import { edit } from '@/helpers/instance'
import { get } from '@/helpers/settings.ts'
import { injectInstanceSettings } from '@/providers/instance-settings'
import type { AppSettings } from '../../../helpers/types'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const { instance } = injectInstanceSettings()
const supportsMaximizeWindow = (await platform()) === 'windows'
const globalSettings = (await get().catch(handleError)) as AppSettings
const overrideWindowSettings = ref(
!!instance.value.game_resolution ||
!!instance.value.force_fullscreen ||
!!instance.value.maximize_window,
)
const resolution: Ref<[number, number]> = ref(
instance.value.game_resolution ?? (globalSettings.game_resolution.slice() as [number, number]),
)
const fullscreenSetting: Ref<boolean> = ref(
instance.value.force_fullscreen ?? globalSettings.force_fullscreen,
)
const maximizeWindowSetting = ref(instance.value.maximize_window ?? globalSettings.maximize_window)
const editInstanceObject = computed(() => {
if (!overrideWindowSettings.value) {
return {
force_fullscreen: null,
maximize_window: null,
game_resolution: null,
}
}
return {
force_fullscreen: fullscreenSetting.value,
maximize_window: maximizeWindowSetting.value,
game_resolution: fullscreenSetting.value ? null : resolution.value,
}
})
watch(
[overrideWindowSettings, resolution, fullscreenSetting, maximizeWindowSetting],
async () => {
await edit(instance.value.id, editInstanceObject.value)
},
{ deep: true },
)
const messages = defineMessages({
customWindowSettings: {
id: 'instance.settings.tabs.window.custom-window-settings',
defaultMessage: 'Custom window settings',
},
fullscreen: {
id: 'instance.settings.tabs.window.fullscreen',
defaultMessage: 'Fullscreen',
},
fullscreenDescription: {
id: 'instance.settings.tabs.window.fullscreen.description',
defaultMessage: 'Make the game start in full screen when launched (using options.txt).',
},
maximizeWindow: {
id: 'instance.settings.tabs.window.maximize-window',
defaultMessage: 'Maximize window',
},
maximizeWindowDescription: {
id: 'instance.settings.tabs.window.maximize-window.description',
defaultMessage: 'Maximize the Minecraft window when launched.',
},
maximizeWindowUnsupported: {
id: 'instance.settings.tabs.window.maximize-window.unsupported',
defaultMessage: 'Not supported on this operating system.',
},
width: {
id: 'instance.settings.tabs.window.width',
defaultMessage: 'Width',
},
widthDescription: {
id: 'instance.settings.tabs.window.width.description',
defaultMessage: 'The width of the game window when launched.',
},
enterWidth: {
id: 'instance.settings.tabs.window.width.enter',
defaultMessage: 'Enter width...',
},
height: {
id: 'instance.settings.tabs.window.height',
defaultMessage: 'Height',
},
heightDescription: {
id: 'instance.settings.tabs.window.height.description',
defaultMessage: 'The height of the game window when launched.',
},
enterHeight: {
id: 'instance.settings.tabs.window.height.enter',
defaultMessage: 'Enter height...',
},
})
</script>
<template>
<div class="flex flex-col gap-6">
<Checkbox
v-model="overrideWindowSettings"
:label="formatMessage(messages.customWindowSettings)"
/>
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.fullscreen) }}
</h2>
<p class="m-0" :class="{ 'text-secondary': !supportsMaximizeWindow }">
{{ formatMessage(messages.fullscreenDescription) }}
</p>
</div>
<Toggle
id="fullscreen"
:model-value="overrideWindowSettings ? fullscreenSetting : globalSettings.force_fullscreen"
:disabled="!overrideWindowSettings"
@update:model-value="
(e) => {
fullscreenSetting = e
}
"
/>
</div>
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.maximizeWindow) }}
</h2>
<p class="m-0">
{{
formatMessage(
supportsMaximizeWindow
? messages.maximizeWindowDescription
: messages.maximizeWindowUnsupported,
)
}}
</p>
</div>
<Toggle
id="maximize-window"
:model-value="
overrideWindowSettings ? maximizeWindowSetting : globalSettings.maximize_window
"
:disabled="!overrideWindowSettings || fullscreenSetting || !supportsMaximizeWindow"
@update:model-value="(value) => (maximizeWindowSetting = value)"
/>
</div>
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.width) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.widthDescription) }}
</p>
</div>
<StyledInput
id="width"
v-model="resolution[0]"
autocomplete="off"
:disabled="!overrideWindowSettings || fullscreenSetting"
type="number"
:placeholder="formatMessage(messages.enterWidth)"
/>
</div>
<div class="flex items-center gap-4 justify-between">
<div class="flex flex-col gap-1">
<h2 class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(messages.height) }}
</h2>
<p class="m-0">
{{ formatMessage(messages.heightDescription) }}
</p>
</div>
<StyledInput
id="height"
v-model="resolution[1]"
autocomplete="off"
:disabled="!overrideWindowSettings || fullscreenSetting"
type="number"
:placeholder="formatMessage(messages.enterHeight)"
/>
</div>
</div>
</template>

View File

@ -0,0 +1,256 @@
<script setup lang="ts">
import {
CheckIcon,
CopyIcon,
DropdownIcon,
LogInIcon,
MessagesSquareIcon,
WrenchIcon,
} from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
Collapsible,
defineMessages,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { AxolotlBrandConfig } from '@/config'
import { login as login_flow, set_default_user } from '@/helpers/auth.js'
import i18n from '@/i18n.config'
import { handleSevereError } from '@/store/error.js'
import { findMinecraftAuthError, type MinecraftAuthError } from './minecraft-auth-errors'
import { translateMinecraftAuthErrorText } from './minecraft-auth-errors-zh'
const modal = ref<InstanceType<typeof NewModal>>()
const rawError = ref<string>('')
const matchedError = ref<MinecraftAuthError | null>(null)
const debugCollapsed = ref(true)
const copied = ref(false)
const loadingSignIn = ref(false)
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: { id: 'app.minecraft-auth.title', defaultMessage: 'Sign in failed' },
warning: {
id: 'app.minecraft-auth.warning',
defaultMessage:
"We couldn't sign you in to your Microsoft account. This may be due to account restrictions or regional limitations.",
},
whatHappened: {
id: 'app.minecraft-auth.what-happened',
defaultMessage: 'What we think happened',
},
howToFix: { id: 'app.minecraft-auth.how-to-fix', defaultMessage: 'How to fix it' },
unknownError: { id: 'app.minecraft-auth.unknown-error', defaultMessage: 'Unknown error' },
unknownDescription: {
id: 'app.minecraft-auth.unknown-description',
defaultMessage:
"We don't recognize this error and can't recommend specific steps to resolve it.",
},
tryMinecraftLoginBefore: {
id: 'app.minecraft-auth.try-login-before',
defaultMessage: 'Try visiting',
},
minecraftLogin: { id: 'app.minecraft-auth.minecraft-login', defaultMessage: 'Minecraft Login' },
tryMinecraftLoginAfter: {
id: 'app.minecraft-auth.try-login-after',
defaultMessage:
'and signing in, as it may prompt you with the necessary steps. You can also contact support and we can investigate further.',
},
contactSupport: {
id: 'app.minecraft-auth.contact-support',
defaultMessage: 'Contact support',
},
signInAgain: { id: 'app.minecraft-auth.sign-in-again', defaultMessage: 'Sign in again' },
debugInformation: {
id: 'app.minecraft-auth.debug-information',
defaultMessage: 'Debug information',
},
copyDebugInfo: {
id: 'app.minecraft-auth.copy-debug-info',
defaultMessage: 'Copy debug information',
},
noErrorMessage: {
id: 'app.minecraft-auth.no-error-message',
defaultMessage: 'No error message.',
},
})
const localizedMatchedError = computed(() => {
if (!matchedError.value) return null
const locale = i18n.global.locale.value
return {
whatHappened: translateMinecraftAuthErrorText(matchedError.value.whatHappened, locale),
stepsToFix: matchedError.value.stepsToFix.map((step) =>
translateMinecraftAuthErrorText(step, locale),
),
}
})
function show(errorVal: { message?: string }) {
rawError.value = errorVal?.message ?? String(errorVal)
matchedError.value = findMinecraftAuthError(rawError.value)
debugCollapsed.value = true
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
defineExpose({
show,
hide,
})
async function signInAgain() {
try {
loadingSignIn.value = true
const loggedIn = await login_flow()
if (loggedIn) {
await set_default_user(loggedIn.profile.id)
}
loadingSignIn.value = false
modal.value?.hide()
} catch (err) {
loadingSignIn.value = false
handleSevereError(err)
}
}
const debugInfo = computed(() => rawError.value || formatMessage(messages.noErrorMessage))
async function copyToClipboard(text: string) {
await navigator.clipboard.writeText(text)
copied.value = true
setTimeout(() => {
copied.value = false
}, 3000)
}
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" :max-width="'548px'">
<div class="flex flex-col gap-6">
<Admonition type="warning" :body="formatMessage(messages.warning)"> </Admonition>
<!-- Matched error details -->
<div class="bg-surface-2 rounded-2xl p-4 px-5 flex flex-col gap-3">
<template v-if="localizedMatchedError">
<div class="flex flex-col gap-1.5">
<h3 class="text-base font-bold m-0">
{{ formatMessage(messages.whatHappened) }}
</h3>
<p class="text-sm text-secondary m-0">
{{ localizedMatchedError.whatHappened }}
</p>
</div>
<div class="flex flex-col gap-1.5">
<h3 class="text-base font-bold m-0">
{{ formatMessage(messages.howToFix) }}
</h3>
<ol class="list-none flex flex-col gap-2 m-0 pl-0">
<li
v-for="(step, index) in localizedMatchedError.stepsToFix"
:key="index"
class="flex items-baseline gap-2"
>
<span
class="inline-flex items-center justify-center shrink-0 w-5 h-5 rounded-full bg-surface-4 border border-solid border-surface-5 text-xs font-medium"
>
{{ index + 1 }}
</span>
<span
class="text-sm [&_a]:text-info [&_a]:font-medium [&_a]:underline"
v-html="step"
/>
</li>
</ol>
</div>
</template>
<template v-else>
<div class="flex flex-col gap-1.5">
<h3 class="text-base font-bold m-0">
{{ formatMessage(messages.unknownError) }}
</h3>
<p class="text-sm text-secondary m-0">
{{ formatMessage(messages.unknownDescription) }}
</p>
<p class="text-sm text-secondary m-0">
{{ formatMessage(messages.tryMinecraftLoginBefore) }}
<a
class="text-info font-medium underline hover:underline"
href="https://www.minecraft.net/en-us/login"
>{{ formatMessage(messages.minecraftLogin) }}</a
>
{{ formatMessage(messages.tryMinecraftLoginAfter) }}
</p>
</div>
</template>
</div>
<!-- Action buttons -->
<div class="flex items-center gap-2">
<ButtonStyled>
<a :href="AxolotlBrandConfig.supportUrl" class="!w-full" @click="modal?.hide()">
<MessagesSquareIcon /> {{ formatMessage(messages.contactSupport) }}
</a>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="loadingSignIn" class="!w-full" @click="signInAgain">
<LogInIcon /> {{ formatMessage(messages.signInAgain) }}
</button>
</ButtonStyled>
</div>
<div class="flex flex-col gap-2">
<div class="w-full h-[1px] bg-surface-5"></div>
<!-- Debug info -->
<div class="overflow-clip">
<button
class="flex items-center justify-between w-full bg-transparent border-0 py-4 cursor-pointer"
@click="debugCollapsed = !debugCollapsed"
>
<span class="flex items-center gap-2 text-contrast font-extrabold m-0">
<WrenchIcon class="h-4 w-4" />
{{ formatMessage(messages.debugInformation) }}
</span>
<DropdownIcon
class="h-5 w-5 text-secondary transition-transform"
:class="{ 'rotate-180': !debugCollapsed }"
/>
</button>
<Collapsible :collapsed="debugCollapsed">
<div
class="p-3 bg-surface-2 rounded-2xl text-xs grid grid-cols-[1fr_auto] max-w-full items-start"
>
<div
class="m-0 p-0 rounded-none bg-transparent text-sm font-mono break-words overflow-auto"
>
{{ debugInfo }}
</div>
<ButtonStyled circular>
<button
v-tooltip="formatMessage(messages.copyDebugInfo)"
:disabled="copied"
@click="copyToClipboard(debugInfo)"
>
<template v-if="copied"> <CheckIcon class="text-green" /> </template>
<template v-else> <CopyIcon /> </template>
</button>
</ButtonStyled>
</div>
</Collapsible>
</div>
</div>
</div>
</NewModal>
</template>

View File

@ -0,0 +1,108 @@
const translations: Record<string, string> = {
'Your saved Microsoft sign-in token has expired or was revoked, so Axolotl Launcher cannot refresh your Minecraft session.':
'已保存的 Microsoft 登录令牌已过期或被撤销,因此 Axolotl Launcher 无法刷新你的 Minecraft 会话。',
'Sign out of the affected Minecraft account in Axolotl Launcher':
'在 Axolotl Launcher 中退出受影响的 Minecraft 账号',
'Sign in to the account again': '重新登录该账号',
'Once the new sign-in finishes, try launching Minecraft again':
'完成重新登录后,再次尝试启动 Minecraft',
'Xbox services rejected the first sign-in response. This is most often caused by your system clock or time zone being out of sync.':
'Xbox 服务拒绝了首次登录响应。这通常是因为系统时间或时区不同步。',
'Open your system date and time settings': '打开系统的日期和时间设置',
'Turn on automatic time zone and automatic time, if available':
'如果系统支持,请开启自动设置时区和自动设置时间',
'Use the sync option in your system settings to synchronize the clock':
'使用系统设置中的同步选项校准时钟',
'Restart Axolotl Launcher': '重启 Axolotl Launcher',
'Try signing in again': '再次尝试登录',
'Microsoft or Minecraft temporarily blocked the sign-in request because there were too many recent attempts.':
'由于短时间内尝试次数过多Microsoft 或 Minecraft 暂时限制了此次登录请求。',
'Wait about an hour before trying again': '等待约一小时后再试',
'Restart Axolotl Launcher after waiting': '等待后重启 Axolotl Launcher',
'Try signing in once more': '再次尝试登录',
'If the same message appears, wait longer before retrying so the temporary limit can clear':
'如果仍出现相同提示,请延长等待时间后再试,以便临时限制解除',
"Minecraft's authentication service is returning a server error, so Axolotl Launcher cannot finish signing you in right now.":
'Minecraft 身份验证服务返回了服务器错误Axolotl Launcher 目前无法完成登录。',
'Wait a few minutes and try signing in again': '等待几分钟后再次尝试登录',
'Check <a href="https://support.xbox.com/xbox-live-status">Xbox Status</a> for current service issues':
'在 <a href="https://support.xbox.com/xbox-live-status">Xbox 服务状态</a>中查看当前是否存在服务故障',
'Try signing in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a> to confirm whether Minecraft sign-in is also affected there':
'尝试使用<a href="https://www.minecraft.net/en-us/download">官方 Minecraft 启动器</a>登录,确认官方启动器是否也受到影响',
'If the service is healthy and this keeps happening, contact support with the debug information below':
'如果服务状态正常但问题持续存在,请携带下方调试信息联系支持',
'Minecraft services could not return a Java Edition profile for this account. This most often happens when the game was purchased recently, the Java profile has not finished being created, or the wrong Microsoft account is being used.':
'Minecraft 服务无法返回此账号的 Java 版档案。常见原因是刚购买游戏、Java 版档案尚未创建完成,或登录了错误的 Microsoft 账号。',
'Sign in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a>':
'使用<a href="https://www.minecraft.net/en-us/download">官方 Minecraft 启动器</a>登录',
'Launch Minecraft: Java Edition once from the official launcher':
'通过官方启动器至少启动一次 MinecraftJava 版',
'Wait up to an hour if the purchase or profile setup was recent':
'如果刚购买游戏或刚设置档案,请等待最多一小时',
'Make sure you are using the Microsoft account that owns Minecraft. Visit <a href="https://github.com/Mystic-Stars/Axolotl/issues">Axolotl support</a> for help':
'确认当前使用的是拥有 Minecraft 的 Microsoft 账号。如需帮助,请访问 <a href="https://github.com/Mystic-Stars/Axolotl/issues">Axolotl 支持</a>',
'Try signing in to Axolotl Launcher again': '再次尝试登录 Axolotl Launcher',
'Axolotl Launcher could not connect to a Microsoft, Xbox, or Minecraft service needed for sign-in. This is usually caused by a local network, DNS, proxy, firewall, hosts file, VPN, or antivirus issue.':
'Axolotl Launcher 无法连接登录所需的 Microsoft、Xbox 或 Minecraft 服务。通常是本地网络、DNS、代理、防火墙、hosts 文件、VPN 或杀毒软件导致的。',
'Restart Axolotl Launcher and try signing in again': '重启 Axolotl Launcher然后再次尝试登录',
'Check that your internet connection is working': '检查网络连接是否正常',
'Allow Axolotl Launcher through your firewall, antivirus, proxy, VPN, and hosts file rules':
'在防火墙、杀毒软件、代理、VPN 和 hosts 文件规则中允许 Axolotl Launcher 通行',
'Try a different network or temporarily disable VPN/proxy software if you use one':
'尝试更换网络;如果正在使用 VPN 或代理软件,请暂时关闭后再试',
'If routing or DNS is the issue, a service like Cloudflare WARP can sometimes help':
'如果问题来自路由或 DNS可以尝试使用 Cloudflare WARP 等服务',
'Your Minecraft/Xbox Live account requires age verification to comply with UK regulations. You must complete this before signing in.':
'根据英国法规,你的 Minecraft/Xbox Live 账号需要完成年龄验证,验证完成后才能登录。',
'Go to the <a href="https://www.minecraft.net/en-us/login">Minecraft Login</a> page and sign in':
'前往 <a href="https://www.minecraft.net/en-us/login">Minecraft 登录</a>页面并登录',
'Follow the instructions to verify your age': '按照页面提示完成年龄验证',
'Once verified, try signing in again': '验证完成后再次尝试登录',
'For additional help, visit <a href="https://support.xbox.com/en-GB/help/family-online-safety/online-safety/UK-age-verification">UK age verification on Xbox</a>':
'如需更多帮助,请参阅 <a href="https://support.xbox.com/en-GB/help/family-online-safety/online-safety/UK-age-verification">Xbox 英国年龄验证</a>',
"This account doesn't have an Xbox profile set up or doesn't own Minecraft.":
'此账号尚未设置 Xbox 档案,或未拥有 Minecraft。',
'Make sure Minecraft is purchased on this account': '确认此账号已经购买 Minecraft',
'Visit <a href="https://www.minecraft.net/en-us/login">Minecraft Login</a> and sign in':
'访问 <a href="https://www.minecraft.net/en-us/login">Minecraft 登录</a>页面并登录',
'Complete Xbox profile setup if prompted': '如果出现提示,请完成 Xbox 档案设置',
'Once finished, try signing in again': '完成后再次尝试登录',
"Xbox Live isn't available in your region, so sign-in is blocked.":
'你所在的地区不支持 Xbox Live因此登录被阻止。',
'Xbox services must be supported in your country before you can sign in':
'只有所在国家或地区支持 Xbox 服务时才能登录',
'Check <a href="https://www.xbox.com/en-US/regions">Xbox Availability</a> for supported regions':
'在 <a href="https://www.xbox.com/en-US/regions">Xbox 可用地区</a>中查看支持范围',
'This account requires adult verification under South Korean regulations.':
'根据韩国法规,此账号需要完成成年人验证。',
'Visit <a href="https://www.xbox.com">Xbox</a> and sign in':
'访问 <a href="https://www.xbox.com">Xbox</a> 并登录',
'Complete the identity verification process': '完成身份验证流程',
'This account is underage and not linked to a Microsoft family group.':
'此账号为未成年账号,且尚未加入 Microsoft 家庭组。',
'Review the <a href="https://help.minecraft.net/hc/en-us/articles/4408968616077">Family Setup Guide</a>':
'查看<a href="https://help.minecraft.net/hc/en-us/articles/4408968616077">家庭组设置指南</a>',
'Join or create a family group as instructed': '按照指南加入或创建家庭组',
'This account was suspended for violating Xbox Community Standards.':
'此账号因违反 Xbox 社区准则而被暂停。',
'Visit <a href="https://support.xbox.com">Xbox Support</a> and review the enforcement details':
'访问 <a href="https://support.xbox.com">Xbox 支持</a>并查看处罚详情',
'Submit an appeal if one is available': '如果可以申诉,请提交申诉',
"This account is restricted and doesn't have permission to play online.":
'此账号受到限制,没有进行在线游戏的权限。',
'Have a guardian sign in to <a href="https://account.microsoft.com/family/">Microsoft Family</a>':
'请监护人登录 <a href="https://account.microsoft.com/family/">Microsoft 家庭</a>',
'Update online play permissions': '更新在线游戏权限',
"This account hasn't accepted Xbox's Terms of Service.": '此账号尚未接受 Xbox 服务条款。',
'Accept the Terms if prompted': '如果出现提示,请接受相关条款',
'Xbox services rejected the request to authorize this account for Minecraft services, but did not return a specific account restriction that Axolotl Launcher recognizes.':
'Xbox 服务拒绝授权此账号访问 Minecraft 服务,但未返回 Axolotl Launcher 能识别的具体账号限制。',
'Complete any prompts shown by Microsoft, Xbox, or Minecraft':
'完成 Microsoft、Xbox 或 Minecraft 显示的所有提示步骤',
'If the official launcher also fails, follow the error shown there or contact Xbox Support':
'如果官方启动器也无法登录,请按照其中显示的错误处理,或联系 Xbox 支持',
}
export function translateMinecraftAuthErrorText(text: string, locale: string): string {
return locale === 'zh-CN' ? (translations[text] ?? text) : text
}

View File

@ -0,0 +1,200 @@
export interface MinecraftAuthError {
errorCode?: string
errorMatchers?: string[]
matches?: (message: string) => boolean
whatHappened: string
stepsToFix: string[]
}
export const minecraftAuthErrors: MinecraftAuthError[] = [
{
errorMatchers: ['Failed to deserialize response to JSON during step RefreshOAuthToken:'],
whatHappened:
'Your saved Microsoft sign-in token has expired or was revoked, so Axolotl Launcher cannot refresh your Minecraft session.',
stepsToFix: [
'Sign out of the affected Minecraft account in Axolotl Launcher',
'Sign in to the account again',
'Once the new sign-in finishes, try launching Minecraft again',
],
},
{
errorMatchers: ['Failed to deserialize response to JSON during step SisuAuthenticate:'],
whatHappened:
'Xbox services rejected the first sign-in response. This is most often caused by your system clock or time zone being out of sync.',
stepsToFix: [
'Open your system date and time settings',
'Turn on automatic time zone and automatic time, if available',
'Use the sync option in your system settings to synchronize the clock',
'Restart Axolotl Launcher',
'Try signing in again',
],
},
{
matches: (message) =>
message.includes('Failed to deserialize response to JSON during step MinecraftToken:') &&
message.includes('429 Too Many Requests'),
whatHappened:
'Microsoft or Minecraft temporarily blocked the sign-in request because there were too many recent attempts.',
stepsToFix: [
'Wait about an hour before trying again',
'Restart Axolotl Launcher after waiting',
'Try signing in once more',
'If the same message appears, wait longer before retrying so the temporary limit can clear',
],
},
{
matches: (message) =>
message.includes('Failed to deserialize response to JSON during step MinecraftToken:') &&
/Status Code: 5\d\d/.test(message),
whatHappened:
"Minecraft's authentication service is returning a server error, so Axolotl Launcher cannot finish signing you in right now.",
stepsToFix: [
'Wait a few minutes and try signing in again',
'Check <a href="https://support.xbox.com/xbox-live-status">Xbox Status</a> for current service issues',
'Try signing in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a> to confirm whether Minecraft sign-in is also affected there',
'If the service is healthy and this keeps happening, contact support with the debug information below',
],
},
{
errorMatchers: ['Failed to fetch player profile'],
whatHappened:
'Minecraft services could not return a Java Edition profile for this account. This most often happens when the game was purchased recently, the Java profile has not finished being created, or the wrong Microsoft account is being used.',
stepsToFix: [
'Sign in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a>',
'Launch Minecraft: Java Edition once from the official launcher',
'Wait up to an hour if the purchase or profile setup was recent',
'Make sure you are using the Microsoft account that owns Minecraft. Visit <a href="https://github.com/Mystic-Stars/Axolotl/issues">Axolotl support</a> for help',
'Try signing in to Axolotl Launcher again',
],
},
{
matches: (message) =>
message.includes('error sending request for url (') &&
[
'minecraft.net',
'minecraftservices.com',
'mojang.com',
'xbox.com',
'xboxlive.com',
'live.com',
].some((domain) => message.includes(domain)),
whatHappened:
'Axolotl Launcher could not connect to a Microsoft, Xbox, or Minecraft service needed for sign-in. This is usually caused by a local network, DNS, proxy, firewall, hosts file, VPN, or antivirus issue.',
stepsToFix: [
'Restart Axolotl Launcher and try signing in again',
'Check that your internet connection is working',
'Allow Axolotl Launcher through your firewall, antivirus, proxy, VPN, and hosts file rules',
'Try a different network or temporarily disable VPN/proxy software if you use one',
'If routing or DNS is the issue, a service like Cloudflare WARP can sometimes help',
],
},
{
errorCode: '2148916222',
whatHappened:
'Your Minecraft/Xbox Live account requires age verification to comply with UK regulations. You must complete this before signing in.',
stepsToFix: [
'Go to the <a href="https://www.minecraft.net/en-us/login">Minecraft Login</a> page and sign in',
'Follow the instructions to verify your age',
'Once verified, try signing in again',
'For additional help, visit <a href="https://support.xbox.com/en-GB/help/family-online-safety/online-safety/UK-age-verification">UK age verification on Xbox</a>',
],
},
{
errorCode: '2148916233',
whatHappened: "This account doesn't have an Xbox profile set up or doesn't own Minecraft.",
stepsToFix: [
'Make sure Minecraft is purchased on this account',
'Visit <a href="https://www.minecraft.net/en-us/login">Minecraft Login</a> and sign in',
'Complete Xbox profile setup if prompted',
'Once finished, try signing in again',
],
},
{
errorCode: '2148916235',
whatHappened: "Xbox Live isn't available in your region, so sign-in is blocked.",
stepsToFix: [
'Xbox services must be supported in your country before you can sign in',
'Check <a href="https://www.xbox.com/en-US/regions">Xbox Availability</a> for supported regions',
],
},
{
errorCode: '2148916236',
whatHappened: 'This account requires adult verification under South Korean regulations.',
stepsToFix: [
'Visit <a href="https://www.xbox.com">Xbox</a> and sign in',
'Complete the identity verification process',
'Once finished, try signing in again',
],
},
{
errorCode: '2148916237',
whatHappened: 'This account requires adult verification under South Korean regulations.',
stepsToFix: [
'Visit <a href="https://www.xbox.com">Xbox</a> and sign in',
'Complete the identity verification process',
'Once finished, try signing in again',
],
},
{
errorCode: '2148916238',
whatHappened: 'This account is underage and not linked to a Microsoft family group.',
stepsToFix: [
'Review the <a href="https://help.minecraft.net/hc/en-us/articles/4408968616077">Family Setup Guide</a>',
'Join or create a family group as instructed',
'Once finished, try signing in again',
],
},
{
errorCode: '2148916227',
whatHappened: 'This account was suspended for violating Xbox Community Standards.',
stepsToFix: [
'Visit <a href="https://support.xbox.com">Xbox Support</a> and review the enforcement details',
'Submit an appeal if one is available',
],
},
{
errorCode: '2148916229',
whatHappened: "This account is restricted and doesn't have permission to play online.",
stepsToFix: [
'Have a guardian sign in to <a href="https://account.microsoft.com/family/">Microsoft Family</a>',
'Update online play permissions',
'Once finished, try signing in again',
],
},
{
errorCode: '2148916234',
whatHappened: "This account hasn't accepted Xbox's Terms of Service.",
stepsToFix: [
'Visit <a href="https://www.xbox.com">Xbox</a> and sign in',
'Accept the Terms if prompted',
'Once finished, try signing in again',
],
},
{
errorMatchers: ['Failed to deserialize response to JSON during step XstsAuthorize:'],
whatHappened:
'Xbox services rejected the request to authorize this account for Minecraft services, but did not return a specific account restriction that Axolotl Launcher recognizes.',
stepsToFix: [
'Sign in with the <a href="https://www.minecraft.net/en-us/download">official Minecraft Launcher</a>',
'Complete any prompts shown by Microsoft, Xbox, or Minecraft',
'Try signing in to Axolotl Launcher again',
'If the official launcher also fails, follow the error shown there or contact Xbox Support',
],
},
]
export function findMinecraftAuthError(message: string): MinecraftAuthError | null {
return (
minecraftAuthErrors.find((error) => {
if (error.errorCode && message.includes(error.errorCode)) {
return true
}
if (error.errorMatchers?.some((matcher) => message.includes(matcher))) {
return true
}
return error.matches?.(message) ?? false
}) ?? null
)
}

View File

@ -0,0 +1,51 @@
<script setup lang="ts">
import { LogInIcon, SpinnerIcon } from '@modrinth/assets'
import { commonMessages, defineMessages, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
defineProps({
onFlowCancel: {
type: Function,
default() {
return async () => {}
},
},
})
const modal = ref()
const { formatMessage } = useVIntl()
const messages = defineMessages({
signInBrowser: {
id: 'app.auth.sign-in-browser',
defaultMessage: 'Please sign in in the browser window that just opened to continue.',
},
})
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 /> {{ formatMessage(commonMessages.signInButton) }}
</span>
</template>
<div class="flex justify-center gap-2">
<SpinnerIcon class="w-12 h-12 animate-spin" />
</div>
<p class="text-sm text-secondary">
{{ formatMessage(messages.signInBrowser) }}
</p>
</ModalWrapper>
</template>

View File

@ -0,0 +1,179 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.header)" fade="standard" max-width="500px">
<p class="m-0 text-secondary">
{{ formatMessage(messages.description, { count: instanceIds.length }) }}
</p>
<div class="flex flex-col gap-3 mt-4">
<RadioButtons v-model="selectedGroup" :items="groupOptions">
<template #default="{ item }">
<span class="flex items-center justify-between flex-1 leading-none">
<span>{{ item || formatMessage(messages.noGroup) }}</span>
<button
v-if="item"
class="bg-transparent border-none cursor-pointer text-secondary hover:text-red rounded flex items-center justify-center w-6 h-6"
@click.stop="deleteGroup(item)"
>
<TrashIcon class="w-4 h-4" />
</button>
<span v-else class="w-6 h-6" />
</span>
</template>
</RadioButtons>
<div class="flex gap-2 items-center">
<StyledInput
v-model="newGroupInput"
:placeholder="formatMessage(messages.enterGroupName)"
class="w-full max-w-[300px]"
@submit="addNewGroup"
/>
<ButtonStyled>
<button class="w-fit !shadow-none" @click="addNewGroup">
<PlusIcon /> {{ formatMessage(messages.createGroup) }}
</button>
</ButtonStyled>
</div>
</div>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="confirm">
<CheckIcon />
{{ formatMessage(messages.applyButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { CheckIcon, PlusIcon, TrashIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
NewModal,
RadioButtons,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { edit, list } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
const { formatMessage } = useVIntl()
const props = defineProps<{
instanceIds: string[]
}>()
const emit = defineEmits<{
(e: 'applied'): void
}>()
const messages = defineMessages({
header: {
id: 'app.instances.batch-edit-groups.header',
defaultMessage: 'Edit groups',
},
description: {
id: 'app.instances.batch-edit-groups.description',
defaultMessage: 'Select groups to apply to {count} instance(s).',
},
enterGroupName: {
id: 'app.instances.batch-edit-groups.enter-group-name',
defaultMessage: 'Enter group name',
},
createGroup: {
id: 'app.instances.batch-edit-groups.create-group',
defaultMessage: 'Create new group',
},
applyButton: {
id: 'app.instances.batch-edit-groups.apply',
defaultMessage: 'Apply',
},
noGroup: {
id: 'app.instances.group.ungrouped',
defaultMessage: 'No group',
},
})
const modal = ref<InstanceType<typeof NewModal>>()
const selectedGroup = ref('')
const newGroupInput = ref('')
const allInstances = ref<GameInstance[]>([])
const availableGroups = computed(() => {
const groups = new Set<string>()
for (const instance of allInstances.value) {
for (const group of instance.groups) {
groups.add(group)
}
}
return [...groups]
})
const groupOptions = computed(() => ['', ...availableGroups.value])
function show() {
selectedGroup.value = ''
newGroupInput.value = ''
list().then((instances) => {
allInstances.value = instances as GameInstance[]
})
modal.value?.show()
}
function addNewGroup() {
const text = newGroupInput.value.trim()
if (text.length > 0) {
const groupName = text.substring(0, 32)
allInstances.value.push({ groups: [groupName] } as GameInstance)
selectedGroup.value = groupName
newGroupInput.value = ''
}
}
async function deleteGroup(group: string) {
for (const instance of allInstances.value) {
if (instance.groups.includes(group)) {
const newGroups = instance.groups.filter((g) => g !== group)
await edit(instance.id, { groups: newGroups }).catch(() => {})
instance.groups = newGroups
}
}
if (selectedGroup.value === group) {
selectedGroup.value = ''
}
}
async function confirm() {
if (newGroupInput.value.trim().length > 0) {
addNewGroup()
}
modal.value?.hide()
const groups = selectedGroup.value ? [selectedGroup.value.trim().substring(0, 32)] : []
for (const instanceId of props.instanceIds) {
await edit(instanceId, { groups }).catch(() => {})
}
emit('applied')
}
defineExpose({
show,
})
</script>

View File

@ -0,0 +1,129 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(count > 1 ? messages.batchHeader : messages.header)"
fade="danger"
max-width="500px"
>
<Admonition
v-if="!symlinkTarget && count <= 1"
type="critical"
:header="formatMessage(messages.admonitionHeader)"
>
{{ formatMessage(messages.admonitionBody) }}
</Admonition>
<Admonition
v-else-if="!symlinkTarget"
type="critical"
:header="formatMessage(messages.admonitionHeader)"
>
{{ formatMessage(messages.batchAdmonitionBody, { count }) }}
</Admonition>
<Admonition v-else type="critical">
{{ formatMessage(messages.symlinkDeleteWarning, { path: symlinkTarget }) }}
</Admonition>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="modal?.hide()">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="red">
<button @click="confirm">
<TrashIcon />
{{
formatMessage(count > 1 ? messages.batchDeleteButton : messages.deleteButton, {
count,
})
}}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { TrashIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
withDefaults(
defineProps<{
symlinkTarget?: string | null
count?: number
}>(),
{
count: 1,
},
)
const messages = defineMessages({
header: {
id: 'app.instance.confirm-delete.header',
defaultMessage: 'Delete instance',
},
batchHeader: {
id: 'app.instance.confirm-delete.batch-header',
defaultMessage: 'Delete instances',
},
admonitionHeader: {
id: 'app.instance.confirm-delete.admonition-header',
defaultMessage: 'This action cannot be undone',
},
admonitionBody: {
id: 'app.instance.confirm-delete.admonition-body',
defaultMessage:
'All data for your instance will be permanently deleted, including your worlds, configs, and all installed content.',
},
batchAdmonitionBody: {
id: 'app.instance.confirm-delete.batch-admonition-body',
defaultMessage:
'{count, plural, one {# instance} other {# instances}} will be permanently deleted, including worlds, configs, and all installed content.',
},
symlinkDeleteWarning: {
id: 'app.instance.confirm-delete.symlink-warning',
defaultMessage:
'This is a shared instance linked to "{path}". Only the link will be removed; the original files will not be deleted.',
},
deleteButton: {
id: 'app.instance.confirm-delete.delete-button',
defaultMessage: 'Delete instance',
},
batchDeleteButton: {
id: 'app.instance.confirm-delete.batch-delete-button',
defaultMessage: 'Delete {count, plural, one {# instance} other {# instances}}',
},
})
const emit = defineEmits<{
(e: 'delete'): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
function show() {
modal.value?.show()
}
function confirm() {
modal.value?.hide()
emit('delete')
}
defineExpose({
show,
})
</script>

View File

@ -0,0 +1,78 @@
<!-- @deprecated Use ConfirmModal from @modrinth/ui directly. Ads/noblur now handled by injectModalBehavior. -->
<script setup lang="ts">
import { ConfirmModal } from '@modrinth/ui'
import { useTemplateRef } from 'vue'
defineProps({
confirmationText: {
type: String,
default: '',
},
hasToType: {
type: Boolean,
default: false,
},
title: {
type: String,
default: 'No title defined',
required: true,
},
description: {
type: String,
default: 'No description defined',
required: true,
},
proceedIcon: {
type: Object,
default: undefined,
},
proceedLabel: {
type: String,
default: 'Proceed',
},
danger: {
type: Boolean,
default: true,
},
/** @deprecated No longer used — ads are handled by provideModalBehavior */
showAdOnClose: {
type: Boolean,
default: true,
},
markdown: {
type: Boolean,
default: true,
},
})
const emit = defineEmits(['proceed'])
const modal = useTemplateRef('modal')
defineExpose({
show: () => {
modal.value?.show()
},
hide: () => {
modal.value?.hide()
},
})
function proceed() {
emit('proceed')
}
</script>
<template>
<ConfirmModal
ref="modal"
:confirmation-text="confirmationText"
:has-to-type="hasToType"
:title="title"
:description="description"
:proceed-icon="proceedIcon"
:proceed-label="proceedLabel"
:danger="danger"
:markdown="markdown"
@proceed="proceed"
/>
</template>

View File

@ -0,0 +1,162 @@
<script setup lang="ts">
import {
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
IntlFormatted,
MinecraftFormattedText,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { autoCleanToText } from '@sfirew/minecraft-motd-parser'
import { ref } from 'vue'
export interface ContentToggleDependencyItem {
title: string
iconUrl?: string | null
versionNumber?: string
}
export interface ContentToggleDependenciesData {
enabling: boolean
bulk: boolean
primaryTitle: string
related: ContentToggleDependencyItem[]
}
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'app.instance.mods.toggle-dependencies.title',
defaultMessage: 'Confirm toggle',
},
singleBody: {
id: 'app.instance.mods.toggle-dependencies.single-body',
defaultMessage:
'Toggling {project} will affect {count, plural, one {# other item} other {# other items}}.',
},
bulkBody: {
id: 'app.instance.mods.toggle-dependencies.bulk-body',
defaultMessage:
'Toggling the selected content will affect {count, plural, one {# other item} other {# other items}}.',
},
willEnable: {
id: 'app.instance.mods.toggle-dependencies.will-enable',
defaultMessage: 'The following content will be enabled:',
},
willDisable: {
id: 'app.instance.mods.toggle-dependencies.will-disable',
defaultMessage: 'The following content will be disabled:',
},
warning: {
id: 'app.instance.mods.toggle-dependencies.warning',
defaultMessage:
'Do you want to apply these related changes automatically? Ignoring them may break the game.',
},
apply: {
id: 'app.instance.mods.toggle-dependencies.apply',
defaultMessage: 'Toggle related content',
},
selectedOnly: {
id: 'app.instance.mods.toggle-dependencies.selected-only',
defaultMessage: 'Only toggle selected',
},
})
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const data = ref<ContentToggleDependenciesData | null>(null)
let settled = false
let resolveShow: ((choice: 'apply' | 'selected' | 'cancel') => void) | null = null
function finish(choice: 'apply' | 'selected' | 'cancel') {
if (settled) return
settled = true
const resolve = resolveShow
resolveShow = null
if (resolve) resolve(choice)
modal.value?.hide()
}
function show(value: ContentToggleDependenciesData): Promise<'apply' | 'selected' | 'cancel'> {
data.value = value
settled = false
modal.value?.show()
return new Promise((resolve) => {
resolveShow = resolve
})
}
defineExpose({ show })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
scrollable
max-content-height="70vh"
max-width="36rem"
:on-hide="() => finish('cancel')"
>
<div v-if="data" class="flex flex-col gap-4">
<p class="m-0 text-primary">
<IntlFormatted
:message-id="data.bulk ? messages.bulkBody : messages.singleBody"
:values="{ count: data.related.length }"
>
<template #project>
<MinecraftFormattedText :text="data.primaryTitle" />
</template>
</IntlFormatted>
</p>
<div v-if="data.related.length > 0" class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
{{ formatMessage(data.enabling ? messages.willEnable : messages.willDisable) }}
</span>
<div
v-for="item in data.related"
:key="item.title"
class="flex items-center gap-3 rounded-xl border border-solid border-surface-4 bg-surface-2 p-3"
>
<Avatar
:src="item.iconUrl"
:alt="autoCleanToText(item.title)"
size="2.5rem"
:tint-by="item.title"
no-shadow
/>
<div class="flex min-w-0 flex-col gap-0.5">
<MinecraftFormattedText
:text="item.title"
class="truncate font-semibold text-contrast"
/>
<span v-if="item.versionNumber" class="truncate text-sm text-secondary">
{{ item.versionNumber }}
</span>
</div>
</div>
</div>
<p class="m-0 text-secondary">{{ formatMessage(messages.warning) }}</p>
</div>
<template #actions>
<div class="flex items-center justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="finish('cancel')">
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled type="outlined">
<button @click="finish('apply')">{{ formatMessage(messages.apply) }}</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="finish('selected')">{{ formatMessage(messages.selectedOnly) }}</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>

View File

@ -0,0 +1,535 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
:fade="remainingCount > 0 ? 'warning' : 'standard'"
:on-hide="stopScanning"
max-width="680px"
scrollable
>
<div class="flex flex-col gap-4">
<p class="m-0 text-secondary">
{{
installed == null
? formatMessage(messages.existingBody, { manual: remainingCount })
: formatMessage(messages.body, {
installed,
manual: remainingCount,
})
}}
</p>
<Admonition
:type="remainingCount === 0 ? 'success' : 'info'"
:header="
formatMessage(remainingCount === 0 ? messages.allImported : messages.automaticImport)
"
>
<template #icon>
<CheckIcon v-if="remainingCount === 0" class="size-5 shrink-0" aria-hidden="true" />
<SpinnerIcon
v-else-if="scanning"
class="size-5 shrink-0 animate-spin"
aria-hidden="true"
/>
<FolderSearchIcon v-else class="size-5 shrink-0" aria-hidden="true" />
</template>
<span class="min-w-0 break-words text-secondary">{{ scannerStatus }}</span>
</Admonition>
<div class="max-h-72 overflow-y-auto rounded-lg border border-surface-5 bg-surface-2">
<div
v-for="item in items"
:key="itemKey(item)"
class="flex min-h-16 items-center justify-between gap-3 border-0 border-b border-solid border-surface-5 px-3 py-2 last:border-b-0"
>
<div class="flex min-w-0 items-center gap-3">
<div class="flex size-8 shrink-0 items-center justify-center rounded-full bg-surface-4">
<CheckIcon v-if="isImported(item)" class="size-5 text-green" aria-hidden="true" />
<FolderSearchIcon v-else class="size-5 text-secondary" aria-hidden="true" />
</div>
<div class="min-w-0 flex flex-col gap-0.5">
<span class="truncate font-medium text-contrast">{{ item.fileName }}</span>
<span class="truncate text-sm text-secondary">
{{
formatMessage(messages.projectFile, {
projectId: item.projectId,
fileId: item.fileId,
})
}}
</span>
<span class="text-sm" :class="isImported(item) ? 'text-green' : 'text-secondary'">
{{ itemStatus(item) }}
</span>
</div>
</div>
<div v-if="!isImported(item)" class="flex shrink-0 flex-wrap justify-end gap-2">
<ButtonStyled type="outlined" size="small">
<button :disabled="busyKeys.has(itemKey(item))" @click="openOne(item)">
<ExternalIcon aria-hidden="true" />
{{ formatMessage(messages.open) }}
</button>
</ButtonStyled>
<ButtonStyled type="outlined" size="small">
<button :disabled="busyKeys.has(itemKey(item))" @click="chooseLocalFile(item)">
<SpinnerIcon v-if="busyKeys.has(itemKey(item))" class="animate-spin" />
<UploadIcon v-else aria-hidden="true" />
{{ formatMessage(messages.chooseFile) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
<template #actions>
<div class="flex flex-wrap justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="hide">
{{ formatMessage(commonMessages.closeButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="instanceId" type="outlined">
<button @click="goToInstance">
{{ formatMessage(messages.viewInstance) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="remainingCount > 0" color="orange">
<button @click="openAll">
<ExternalIcon aria-hidden="true" />
{{ formatMessage(messages.openAll) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import {
CheckIcon,
ExternalIcon,
FolderSearchIcon,
SpinnerIcon,
UploadIcon,
} from '@modrinth/assets'
import {
Admonition,
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import {
classifyCurseForgeManualDownloadImportError,
type CurseForgeManualDownloadImport,
type CurseForgeManualDownloadScanResult,
importCurseForgeManualDownloads,
importPendingCurseForgeManualDownloadFile,
listPendingCurseForgeManualDownloads,
} from '@/helpers/curseforge'
import {
type CurseForgeManualDownloadItem,
getCurseForgeManualDownloadUrl,
} from '@/helpers/curseforge-manual'
import { getMissingContentScannerSettings } from '@/helpers/downloads-scanner'
import { instance_listener } from '@/helpers/events.js'
import { get_content_snapshot } from '@/helpers/instance'
import { get_instance_worlds } from '@/helpers/worlds'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
header: {
id: 'app.curseforge.manual-downloads.header',
defaultMessage: 'Complete CurseForge downloads',
},
body: {
id: 'app.curseforge.manual-downloads.body',
defaultMessage:
'Installed {installed, number} files automatically. {manual, number} files require browser download.',
},
existingBody: {
id: 'app.curseforge.manual-downloads.existing-body',
defaultMessage:
'{manual, number} files still require browser download. Downloaded files will be verified and imported automatically.',
},
projectFile: {
id: 'app.curseforge.manual-downloads.project-file',
defaultMessage: 'Project {projectId} / File {fileId}',
},
open: {
id: 'app.curseforge.manual-downloads.open',
defaultMessage: 'Download',
},
openAll: {
id: 'app.curseforge.manual-downloads.open-all',
defaultMessage: 'Open missing',
},
chooseFile: {
id: 'app.curseforge.manual-downloads.choose-file',
defaultMessage: 'Choose local file',
},
viewInstance: {
id: 'app.curseforge.manual-downloads.view-instance',
defaultMessage: 'View instance',
},
automaticImport: {
id: 'app.curseforge.manual-downloads.automatic-import',
defaultMessage: 'Automatic import',
},
allImported: {
id: 'app.curseforge.manual-downloads.all-imported',
defaultMessage: 'All files imported',
},
checkingDownloads: {
id: 'app.curseforge.manual-downloads.checking-downloads',
defaultMessage: 'Checking the monitored import folder...',
},
watchingDownloads: {
id: 'app.curseforge.manual-downloads.watching-downloads',
defaultMessage: 'Watching {path}. Files are verified before import.',
},
downloadsUnavailable: {
id: 'app.curseforge.manual-downloads.downloads-unavailable',
defaultMessage: 'The monitored import folder is unavailable.',
},
automaticImportDisabled: {
id: 'app.curseforge.manual-downloads.automatic-import-disabled',
defaultMessage: 'Automatic import is disabled in Resource Management settings.',
},
scannerFailed: {
id: 'app.curseforge.manual-downloads.scanner-failed',
defaultMessage: 'Automatic import check failed; retrying.',
},
importComplete: {
id: 'app.curseforge.manual-downloads.import-complete',
defaultMessage: 'Imported into the instance',
},
waiting: {
id: 'app.curseforge.manual-downloads.waiting',
defaultMessage: 'Waiting for download',
},
retrying: {
id: 'app.curseforge.manual-downloads.retrying',
defaultMessage: 'File verification failed. Choose the required file.',
},
stateChanged: {
id: 'app.curseforge.manual-downloads.state-changed',
defaultMessage: 'Download state changed. Waiting for synchronization.',
},
})
const emit = defineEmits<{
(e: 'view-instance', instanceId: string): void
(e: 'imported', instanceId: string, imports: CurseForgeManualDownloadImport[]): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const items = ref<CurseForgeManualDownloadItem[]>([])
const candidateItems = ref<CurseForgeManualDownloadItem[]>([])
const installed = ref<number | null>(null)
const instanceId = ref<string | null>(null)
const scanning = ref(false)
const scanError = ref(false)
const downloadDirectory = ref<string | null>(null)
const scannerEnabled = ref(true)
const scanDirectory = ref<string | null>(null)
const importedKeys = ref(new Set<string>())
const inconsistentKeys = ref(new Set<string>())
const errorKeys = ref(new Set<string>())
const busyKeys = ref(new Set<string>())
let scannerActive = false
let scanGeneration = 0
let reconciliationGeneration = 0
let scanInFlight: Promise<CurseForgeManualDownloadScanResult> | undefined
let scanInterval: ReturnType<typeof setInterval> | null = null
let unlistenInstances: (() => void) | null = null
const remainingCount = computed(
() => items.value.filter((item) => !importedKeys.value.has(itemKey(item))).length,
)
const scannerStatus = computed(() => {
if (remainingCount.value === 0) return formatMessage(messages.importComplete)
if (!scannerEnabled.value) return formatMessage(messages.automaticImportDisabled)
if (scanError.value) return formatMessage(messages.scannerFailed)
if (downloadDirectory.value) {
return formatMessage(messages.watchingDownloads, { path: downloadDirectory.value })
}
if (scanning.value) return formatMessage(messages.checkingDownloads)
return formatMessage(messages.downloadsUnavailable)
})
function itemKey(item: Pick<CurseForgeManualDownloadItem, 'projectId' | 'fileId'>) {
return `${item.projectId}:${item.fileId}`
}
function isImported(item: CurseForgeManualDownloadItem) {
return importedKeys.value.has(itemKey(item))
}
function itemStatus(item: CurseForgeManualDownloadItem) {
if (isImported(item)) return formatMessage(messages.importComplete)
if (errorKeys.value.has(itemKey(item))) return formatMessage(messages.retrying)
if (inconsistentKeys.value.has(itemKey(item))) return formatMessage(messages.stateChanged)
return formatMessage(messages.waiting)
}
function show(payload: {
items: CurseForgeManualDownloadItem[]
installed?: number
instanceId?: string | null
}) {
stopScanning()
scannerActive = true
const seededItems = [...new Map(payload.items.map((item) => [itemKey(item), item])).values()]
candidateItems.value = seededItems
items.value = seededItems
installed.value = payload.installed ?? null
instanceId.value = payload.instanceId ?? null
downloadDirectory.value = null
scanError.value = false
importedKeys.value = new Set()
inconsistentKeys.value = new Set()
errorKeys.value = new Set()
busyKeys.value = new Set()
const scannerSettings = getMissingContentScannerSettings()
scannerEnabled.value = scannerSettings.enabled
scanDirectory.value = scannerSettings.directory
modal.value?.show()
void reconcileManualDownloadState().catch(handleError)
if (scannerEnabled.value) {
void scanDownloads()
scanInterval = setInterval(() => {
if (scannerActive) void scanDownloads()
}, 3000)
}
}
async function reconcileManualDownloadState() {
const currentInstanceId = instanceId.value
if (!scannerActive || !currentInstanceId) return
const generation = ++reconciliationGeneration
const hasWorldCandidates = candidateItems.value.some((item) => item.projectType === 'world')
const [pending, snapshot, worlds] = await Promise.all([
listPendingCurseForgeManualDownloads(currentInstanceId),
get_content_snapshot(currentInstanceId),
hasWorldCandidates ? get_instance_worlds(currentInstanceId) : Promise.resolve([]),
])
if (
!scannerActive ||
currentInstanceId !== instanceId.value ||
generation !== reconciliationGeneration
) {
return
}
const pendingByKey = new Map(pending.map((item) => [itemKey(item), item]))
const candidateByKey = new Map(candidateItems.value.map((item) => [itemKey(item), item]))
for (const [key, item] of pendingByKey) candidateByKey.set(key, item)
const nextCandidates = [...candidateByKey.values()]
const nextItems = nextCandidates.map((item) => pendingByKey.get(itemKey(item)) ?? item)
const materializedByKey = new Map<string, string>()
for (const item of snapshot.items) {
if (
item.provider === 'curseforge' &&
item.providerProjectId != null &&
item.providerReleaseId != null &&
item.materializationState === 'present' &&
item.content != null
) {
materializedByKey.set(
`${item.providerProjectId}:${item.providerReleaseId}`,
item.expectedRelativePath,
)
}
}
const nextImported = new Set<string>()
const nextInconsistent = new Set<string>()
const newlyImported: CurseForgeManualDownloadImport[] = []
for (const item of nextItems) {
const key = itemKey(item)
if (pendingByKey.has(key)) continue
if (item.projectType === 'world') {
const worldName = item.fileName.replace(/\.zip$/i, '')
const importedWorld = worlds.some(
(world) => world.type === 'singleplayer' && world.path === worldName,
)
if (importedWorld) {
nextImported.add(key)
if (!importedKeys.value.has(key)) {
newlyImported.push({
projectId: item.projectId,
fileId: item.fileId,
relativePath: `saves/${worldName}`,
})
}
continue
}
}
const relativePath = materializedByKey.get(key)
if (relativePath == null) {
nextInconsistent.add(key)
continue
}
nextImported.add(key)
if (!importedKeys.value.has(key)) {
newlyImported.push({ projectId: item.projectId, fileId: item.fileId, relativePath })
}
}
candidateItems.value = nextCandidates
items.value = nextItems
importedKeys.value = nextImported
inconsistentKeys.value = nextInconsistent
errorKeys.value = new Set([...errorKeys.value].filter((key) => pendingByKey.has(key)))
if (newlyImported.length > 0) emit('imported', currentInstanceId, newlyImported)
}
function hide() {
modal.value?.hide()
}
function stopScanning() {
scannerActive = false
scanGeneration += 1
reconciliationGeneration += 1
if (scanInterval != null) {
clearInterval(scanInterval)
scanInterval = null
}
}
async function scanDownloads(): Promise<void> {
if (!scannerEnabled.value) return
if (scanInFlight) {
await scanInFlight.catch(() => undefined)
return
}
const currentInstanceId = instanceId.value
if (!currentInstanceId) return
const generation = scanGeneration
if (remainingCount.value === 0) return
scanning.value = true
const operation = importCurseForgeManualDownloads(currentInstanceId, scanDirectory.value)
scanInFlight = operation
try {
const result = await operation
if (generation !== scanGeneration) return
scanError.value = false
downloadDirectory.value = result.downloadDirectory ?? null
errorKeys.value = new Set(result.errors.map((item) => `${item.projectId}:${item.fileId}`))
await reconcileManualDownloadState().catch(handleError)
} catch {
if (generation !== scanGeneration) return
scanError.value = true
errorKeys.value = new Set(
items.value
.filter((item) => !isImported(item) && !inconsistentKeys.value.has(itemKey(item)))
.map((item) => itemKey(item)),
)
} finally {
if (scanInFlight === operation) scanInFlight = undefined
if (generation === scanGeneration) {
scanning.value = false
} else if (!scanInFlight) {
scanning.value = false
}
}
}
async function openOne(item: CurseForgeManualDownloadItem) {
await openUrl(getCurseForgeManualDownloadUrl(item))
}
async function chooseLocalFile(item: CurseForgeManualDownloadItem) {
const currentInstanceId = instanceId.value
const key = itemKey(item)
if (!currentInstanceId || busyKeys.value.has(key)) return
const selected = await open({ multiple: false })
const sourcePath = typeof selected === 'string' ? selected : null
if (!sourcePath) return
busyKeys.value = new Set(busyKeys.value).add(key)
try {
await importPendingCurseForgeManualDownloadFile(
currentInstanceId,
item.projectId,
item.fileId,
sourcePath,
)
await reconcileManualDownloadState().catch(handleError)
} catch (error) {
const errorKind = classifyCurseForgeManualDownloadImportError(error)
if (errorKind === 'verification_failed') {
const nextErrors = new Set(errorKeys.value)
nextErrors.add(key)
errorKeys.value = nextErrors
const nextInconsistent = new Set(inconsistentKeys.value)
nextInconsistent.delete(key)
inconsistentKeys.value = nextInconsistent
} else if (errorKind === 'not_pending') {
const nextErrors = new Set(errorKeys.value)
nextErrors.delete(key)
errorKeys.value = nextErrors
await reconcileManualDownloadState().catch(handleError)
} else {
handleError(error)
}
} finally {
const nextBusy = new Set(busyKeys.value)
nextBusy.delete(key)
busyKeys.value = nextBusy
}
}
async function openAll() {
for (const item of items.value) {
if (isImported(item)) continue
await openOne(item)
}
}
function goToInstance() {
if (!instanceId.value) return
hide()
emit('view-instance', instanceId.value)
}
onMounted(() => {
void instance_listener(async (event: { event: string; instance_id: string }) => {
if (
event.event === 'content_changed' &&
event.instance_id === instanceId.value &&
scannerActive
) {
await reconcileManualDownloadState().catch(() => {
scanError.value = true
})
}
})
.then((unlisten) => {
unlistenInstances = unlisten
})
.catch(() => undefined)
})
onUnmounted(() => {
stopScanning()
unlistenInstances?.()
})
defineExpose({
show,
hide,
})
</script>

View File

@ -0,0 +1,274 @@
<template>
<NewModal ref="modal" :header="formatMessage(messages.installToPlay)" :closable="true">
<div v-if="requiredContentProject" class="flex flex-col gap-6 max-w-[500px]">
<Admonition type="info" :header="formatMessage(messages.contentRequired)">
{{ formatMessage(messages.serverRequiresMods) }}
</Admonition>
<div class="flex flex-col gap-1">
<div class="flex justify-between items-center">
<span class="font-semibold text-contrast">{{
formatMessage(messages.requiredModpack)
}}</span>
<ButtonStyled type="transparent">
<button @click="openViewContents">
<EyeIcon />
{{ formatMessage(messages.viewContents) }}
</button>
</ButtonStyled>
</div>
<div class="flex items-center gap-3 rounded-xl bg-surface-2 p-3">
<Avatar
:src="requiredContentProject.icon_url"
:alt="requiredContentProject.title"
size="48px"
/>
<div class="flex flex-col gap-0.5">
<span class="font-semibold text-contrast">
<template v-if="usingCustomModpack && modpackVersion">
{{ modpackVersion.name }}
</template>
<template v-else>
{{ requiredContentProject.title }}
</template>
</span>
<span class="text-sm text-secondary">
{{ loaderDisplay }} {{ requiredContentProject.game_versions?.[0] }}
<template v-if="modCount">
· {{ formatMessage(messages.modCount, { count: modCount }) }}
</template>
</span>
</div>
</div>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled>
<button @click="handleDecline">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button @click="handleAccept">
<DownloadIcon />
{{ formatMessage(messages.installButton) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
<ModpackContentModal
ref="modpackContentModal"
:modpack-name="project?.name ?? ''"
:modpack-icon-url="project?.icon_url ?? undefined"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon, EyeIcon, XIcon } from '@modrinth/assets'
import type { ContentItem } from '@modrinth/ui'
import {
Admonition,
Avatar,
ButtonStyled,
commonMessages,
defineMessages,
formatLoader,
ModpackContentModal,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { get_project, get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { injectServerInstall } from '@/providers/server-install'
const modal = ref<InstanceType<typeof NewModal>>()
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<Labrinth.Versions.v2.Version | null>(null)
const project = ref<Labrinth.Projects.v3.Project | null>(null)
const requiredContentProject = ref<Labrinth.Projects.v2.Project | null>(null)
const onInstallComplete = ref<() => void>(() => {})
const { formatMessage } = useVIntl()
const { installServerProject, startInstallingServer, stopInstallingServer } = injectServerInstall()
const usingCustomModpack = computed(() => {
return requiredContentProject.value?.id === project.value?.id
})
const loaderDisplay = computed(() => {
const loader = requiredContentProject.value?.loaders?.[0]
if (!loader) return ''
return formatLoader(formatMessage, loader)
})
const modCount = computed(() => modpackVersion.value?.dependencies?.length)
async function fetchData(versionId: string) {
// cache is making version null for some reason so bypassing for now
modpackVersion.value = await get_version(versionId, 'bypass')
if (modpackVersion.value?.project_id) {
requiredContentProject.value = await get_project(modpackVersion.value.project_id, 'bypass')
}
}
async function handleAccept() {
hide()
const serverProjectId = project.value?.id
startInstallingServer(serverProjectId)
try {
await installServerProject(serverProjectId)
onInstallComplete.value()
} catch (error) {
console.error('Failed to install server project from InstallToPlayModal:', error)
} finally {
stopInstallingServer(serverProjectId)
}
}
function handleDecline() {
hide()
}
const modpackContentModal = ref<InstanceType<typeof ModpackContentModal>>()
async function openViewContents() {
modpackContentModal.value?.showLoading()
try {
// Ensure version data is available — the useQuery may not have resolved yet
const versionId = modpackVersionId.value
const version =
modpackVersion.value ?? (versionId ? await get_version(versionId, 'must_revalidate') : null)
const deps = version?.dependencies ?? []
const projectIds = deps
.map((d: { project_id?: string }) => d.project_id)
.filter((id: string | undefined): id is string => !!id)
const versionIds = deps
.map((d: { version_id?: string }) => d.version_id)
.filter((id: string | undefined): id is string => !!id)
const projects: Labrinth.Projects.v2.Project[] =
projectIds.length > 0 ? await get_project_many(projectIds, 'must_revalidate') : []
const versions: Labrinth.Versions.v2.Version[] =
versionIds.length > 0 ? await get_version_many(versionIds, 'must_revalidate') : []
const projectMap = new Map(projects.map((p: Labrinth.Projects.v2.Project) => [p.id, p]))
const contentItems: ContentItem[] = deps.map(
(dep: Labrinth.Versions.v2.Dependency): ContentItem => {
const depProject = dep.project_id ? projectMap.get(dep.project_id) : null
// @ts-expect-error - version_id is missing from the type for some reason
const depVersion = dep.version_id
? // @ts-expect-error - version_id is missing from the type for some reason
versions.find((v: Labrinth.Versions.v2.Version) => v.id === dep.version_id)
: null
return {
id: dep.file_name ?? dep.project_id ?? 'unknown',
file_name: dep.file_name ?? depProject?.title ?? 'Unknown',
project_type: depProject?.project_type ?? 'mod',
update: null,
origin_provider: null,
provider_refs: [],
enabled: true,
project: {
id: depProject?.id ?? dep.project_id ?? dep.file_name ?? 'unknown',
slug: depProject?.slug ?? dep.project_id ?? 'unknown',
title: depProject?.title ?? dep.file_name ?? 'Unknown',
icon_url: depProject?.icon_url ?? undefined,
},
...(depVersion
? {
version: {
id: depVersion.id,
file_name: depVersion.files?.[0]?.filename ?? dep.file_name,
version_number: depVersion.version_number ?? undefined,
date_published: depVersion.date_published ?? undefined,
},
}
: {}),
}
},
)
modpackContentModal.value?.show(contentItems)
} catch (err) {
console.error('Failed to load modpack contents:', err)
modpackContentModal.value?.show([])
}
}
async function show(
projectVal: Labrinth.Projects.v3.Project,
modpackVersionIdVal: string | null = null,
callback: () => void = () => {},
e?: MouseEvent,
) {
project.value = projectVal
modpackVersionId.value = modpackVersionIdVal
modpackVersion.value = null
requiredContentProject.value = null
onInstallComplete.value = callback
if (modpackVersionIdVal) await fetchData(modpackVersionIdVal)
modal.value?.show(e)
}
function hide() {
modal.value?.hide()
}
const messages = defineMessages({
installToPlay: {
id: 'app.modal.install-to-play.header',
defaultMessage: 'Install to play',
},
sharedServerInstance: {
id: 'app.modal.install-to-play.shared-server-instance',
defaultMessage: 'Shared server instance',
},
contentRequired: {
id: 'app.modal.install-to-play.content-required',
defaultMessage: 'Content required',
},
serverRequiresMods: {
id: 'app.modal.install-to-play.server-requires-mods',
defaultMessage:
'This server requires mods to play. Click Install to set up the required files from Modrinth, then launch directly into the server.',
},
requiredModpack: {
id: 'app.modal.install-to-play.required-modpack',
defaultMessage: 'Required modpack',
},
sharedInstance: {
id: 'app.modal.install-to-play.shared-instance',
defaultMessage: 'Shared instance',
},
modCount: {
id: 'app.modal.install-to-play.mod-count',
defaultMessage: '{count, plural, one {# mod} other {# mods}}',
},
installButton: {
id: 'app.modal.install-to-play.install-button',
defaultMessage: 'Install',
},
viewContents: {
id: 'app.modal.install-to-play.view-contents',
defaultMessage: 'View contents',
},
})
defineExpose({ show, hide })
</script>

View File

@ -0,0 +1,374 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
:on-hide="handleHide"
width="min(760px, calc(100vw - 2rem))"
max-width="760px"
scrollable
>
<div class="grid gap-6 md:grid-cols-[220px_minmax(0,1fr)]">
<div class="flex flex-col items-center gap-4">
<div
class="flex aspect-square w-44 items-center justify-center overflow-hidden rounded-3xl shadow-lg"
:style="selectedBackground.style"
>
<img :src="selectedIcon.url" alt="" class="h-[72%] w-[72%] object-contain" />
</div>
<p class="m-0 text-center text-sm text-secondary">
{{ formatMessage(messages.description) }}
</p>
<ButtonStyled type="outlined">
<button :disabled="saving" @click="surpriseMe">
<RefreshCwIcon />
{{ formatMessage(messages.surpriseMe) }}
</button>
</ButtonStyled>
</div>
<div class="flex min-w-0 flex-col gap-5">
<section class="flex flex-col gap-2.5">
<h2 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.background) }}
</h2>
<div class="flex flex-wrap gap-2">
<button
v-for="background in backgrounds"
:key="background.id"
type="button"
class="h-10 w-10 cursor-pointer rounded-xl border-2 border-solid transition-transform hover:scale-105 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand disabled:cursor-wait disabled:opacity-60"
:class="
background.id === selectedBackgroundId
? 'border-contrast shadow-md'
: 'border-transparent'
"
:style="background.style"
:aria-label="formatMessage(background.name)"
:aria-pressed="background.id === selectedBackgroundId"
:disabled="saving"
@click="selectedBackgroundId = background.id"
/>
</div>
</section>
<section v-for="group in iconGroups" :key="group.id" class="flex min-w-0 flex-col gap-2.5">
<h2 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(group.name) }}
</h2>
<div class="grid grid-cols-4 gap-2 sm:grid-cols-6">
<button
v-for="icon in group.icons"
:key="icon.id"
type="button"
class="group flex min-w-0 cursor-pointer flex-col items-center gap-1.5 rounded-xl border border-solid bg-surface-2 p-2 text-secondary transition-colors hover:border-brand hover:bg-brand-highlight hover:text-contrast focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-brand disabled:cursor-wait disabled:opacity-60"
:class="
icon.id === selectedIconId
? 'border-brand bg-brand-highlight text-contrast'
: 'border-surface-5'
"
:aria-label="formatMessage(icon.name)"
:aria-pressed="icon.id === selectedIconId"
:disabled="saving"
@click="selectedIconId = icon.id"
>
<img :src="icon.url" alt="" class="aspect-square w-full object-contain" />
<span class="w-full truncate text-center text-xs font-semibold">
{{ formatMessage(icon.name) }}
</span>
</button>
</div>
</section>
</div>
</div>
<template #actions>
<div class="flex w-full items-center justify-between gap-2">
<ButtonStyled type="outlined">
<button :disabled="saving" @click="selectUploadedIcon">
<UploadIcon />
{{ formatMessage(messages.upload) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="saving" @click="saveGeneratedIcon">
<SpinnerIcon v-if="saving" class="animate-spin" />
<SaveIcon v-else />
{{ formatMessage(saving ? messages.saving : messages.useIcon) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { RefreshCwIcon, SaveIcon, SpinnerIcon, UploadIcon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessage,
defineMessages,
injectNotificationManager,
type MessageDescriptor,
NewModal,
type PickedFile,
useVIntl,
} from '@modrinth/ui'
import { convertFileSrc } from '@tauri-apps/api/core'
import { computed, ref, useTemplateRef } from 'vue'
import { cache_icon } from '@/helpers/instance'
import { builtInInstanceIcons, modrinth3DInstanceIcons } from '@/helpers/instance-icons'
import { pickImage } from '@/providers/setup/file-picker'
interface IconBackground {
id: string
name: MessageDescriptor
colors: [string, string] | null
style: Record<string, string>
}
function background(
id: string,
name: MessageDescriptor,
top: string,
bottom: string,
): IconBackground {
return {
id,
name,
colors: [top, bottom],
style: { backgroundImage: `linear-gradient(145deg, ${top}, ${bottom})` },
}
}
const backgroundNames = defineMessages({
transparent: {
id: 'app.instance.icon-picker.background.transparent',
defaultMessage: 'Transparent',
},
grass: { id: 'app.instance.icon-picker.background.grass', defaultMessage: 'Grass' },
ocean: { id: 'app.instance.icon-picker.background.ocean', defaultMessage: 'Ocean' },
amethyst: { id: 'app.instance.icon-picker.background.amethyst', defaultMessage: 'Amethyst' },
sunset: { id: 'app.instance.icon-picker.background.sunset', defaultMessage: 'Sunset' },
cherry: { id: 'app.instance.icon-picker.background.cherry', defaultMessage: 'Cherry' },
nether: { id: 'app.instance.icon-picker.background.nether', defaultMessage: 'Nether' },
slime: { id: 'app.instance.icon-picker.background.slime', defaultMessage: 'Slime' },
deepDark: { id: 'app.instance.icon-picker.background.deep-dark', defaultMessage: 'Deep Dark' },
stone: { id: 'app.instance.icon-picker.background.stone', defaultMessage: 'Stone' },
midnight: { id: 'app.instance.icon-picker.background.midnight', defaultMessage: 'Midnight' },
})
const backgrounds = [
{
id: 'transparent',
name: backgroundNames.transparent,
colors: null,
style: {
backgroundColor: '#ffffff',
backgroundImage:
'linear-gradient(45deg, #d7d9de 25%, transparent 25%), linear-gradient(-45deg, #d7d9de 25%, transparent 25%), linear-gradient(45deg, transparent 75%, #d7d9de 75%), linear-gradient(-45deg, transparent 75%, #d7d9de 75%)',
backgroundPosition: '0 0, 0 8px, 8px -8px, -8px 0',
backgroundSize: '16px 16px',
},
} satisfies IconBackground,
background('grass', backgroundNames.grass, '#7fc95b', '#2f7d32'),
background('ocean', backgroundNames.ocean, '#55b7ff', '#3157c8'),
background('amethyst', backgroundNames.amethyst, '#c084fc', '#6d28d9'),
background('sunset', backgroundNames.sunset, '#ffba52', '#e94b64'),
background('cherry', backgroundNames.cherry, '#ff9ec4', '#b93670'),
background('nether', backgroundNames.nether, '#ef5a46', '#6d1717'),
background('slime', backgroundNames.slime, '#b6ee55', '#44972f'),
background('deep-dark', backgroundNames.deepDark, '#245369', '#0c1f2b'),
background('stone', backgroundNames.stone, '#aeb5bd', '#525b66'),
background('midnight', backgroundNames.midnight, '#4d5f8f', '#171b2d'),
]
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const modal = useTemplateRef<InstanceType<typeof NewModal>>('modal')
const iconOptions = [...builtInInstanceIcons, ...modrinth3DInstanceIcons]
const iconGroups = [
{
id: 'original',
name: defineMessage({
id: 'app.instance.icon-picker.group.original',
defaultMessage: 'Original icons',
}),
icons: builtInInstanceIcons,
},
{
id: 'modrinth-3d',
name: defineMessage({
id: 'app.instance.icon-picker.group.modrinth-3d',
defaultMessage: 'Modrinth 3D icons',
}),
icons: modrinth3DInstanceIcons,
},
]
const selectedBackgroundId = ref(backgrounds[0].id)
const selectedIconId = ref(builtInInstanceIcons[0].id)
const saving = ref(false)
let resolveSelection: ((selection: PickedFile | null) => void) | null = null
const selectedBackground = computed(
() => backgrounds.find((item) => item.id === selectedBackgroundId.value) ?? backgrounds[0],
)
const selectedIcon = computed(
() => iconOptions.find((icon) => icon.id === selectedIconId.value) ?? builtInInstanceIcons[0],
)
const messages = defineMessages({
title: {
id: 'app.instance.icon-picker.title',
defaultMessage: 'Create an instance icon',
},
description: {
id: 'app.instance.icon-picker.description',
defaultMessage: 'Combine a background and a Minecraft element, or upload your own image.',
},
background: {
id: 'app.instance.icon-picker.background',
defaultMessage: 'Background',
},
surpriseMe: {
id: 'app.instance.icon-picker.surprise-me',
defaultMessage: 'Surprise me',
},
upload: {
id: 'app.instance.icon-picker.upload',
defaultMessage: 'Upload image',
},
useIcon: {
id: 'app.instance.icon-picker.use-icon',
defaultMessage: 'Use this icon',
},
saving: {
id: 'app.instance.icon-picker.saving',
defaultMessage: 'Saving...',
},
loadError: {
id: 'app.instance.icon-picker.load-error',
defaultMessage: 'Failed to load the bundled icon.',
},
})
function finish(selection: PickedFile | null) {
const resolve = resolveSelection
resolveSelection = null
modal.value?.hide()
resolve?.(selection)
}
function handleHide() {
saving.value = false
if (resolveSelection) {
resolveSelection(null)
resolveSelection = null
}
}
function surpriseMe() {
if (backgrounds.length > 1) {
const candidates = backgrounds.filter((item) => item.id !== selectedBackgroundId.value)
selectedBackgroundId.value = candidates[Math.floor(Math.random() * candidates.length)].id
}
if (iconOptions.length > 1) {
const candidates = iconOptions.filter((icon) => icon.id !== selectedIconId.value)
selectedIconId.value = candidates[Math.floor(Math.random() * candidates.length)].id
}
}
function loadImage(url: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error(formatMessage(messages.loadError)))
image.src = url
})
}
function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob((blob) => {
if (blob) resolve(blob)
else reject(new Error(formatMessage(messages.loadError)))
}, 'image/png')
})
}
async function renderGeneratedIcon(): Promise<Blob> {
if (!selectedBackground.value.colors) {
const response = await fetch(selectedIcon.value.url)
if (!response.ok) throw new Error(formatMessage(messages.loadError))
return await response.blob()
}
const size = 512
const canvas = document.createElement('canvas')
canvas.width = size
canvas.height = size
const context = canvas.getContext('2d')
if (!context) throw new Error(formatMessage(messages.loadError))
if (selectedBackground.value.colors) {
const gradient = context.createLinearGradient(0, 0, size, size)
gradient.addColorStop(0, selectedBackground.value.colors[0])
gradient.addColorStop(1, selectedBackground.value.colors[1])
context.fillStyle = gradient
context.fillRect(0, 0, size, size)
}
const symbol = await loadImage(selectedIcon.value.url)
const maxSymbolSize = size * 0.72
const scale = Math.min(maxSymbolSize / symbol.naturalWidth, maxSymbolSize / symbol.naturalHeight)
const width = symbol.naturalWidth * scale
const height = symbol.naturalHeight * scale
context.drawImage(symbol, (size - width) / 2, (size - height) / 2, width, height)
return await canvasToBlob(canvas)
}
async function saveGeneratedIcon() {
if (saving.value) return
saving.value = true
try {
const blob = await renderGeneratedIcon()
const fileName = `generated-${selectedBackgroundId.value}-${selectedIconId.value}.png`
const bytes = Array.from(new Uint8Array(await blob.arrayBuffer()))
const path = await cache_icon(fileName, bytes)
finish({
file: new File([blob], fileName, { type: 'image/png' }),
path,
previewUrl: convertFileSrc(path),
frameless: selectedBackgroundId.value === 'transparent',
})
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
async function selectUploadedIcon() {
try {
const selection = await pickImage()
if (selection) finish(selection)
} catch (error) {
handleError(error)
}
}
function show(): Promise<PickedFile | null> {
resolveSelection?.(null)
const modalInstance = modal.value
if (!modalInstance) return Promise.resolve(null)
return new Promise((resolve) => {
resolveSelection = resolve
modalInstance.show()
})
}
defineExpose({ show })
</script>

View File

@ -0,0 +1,21 @@
<script setup lang="ts">
import { ChevronRightIcon } from '@modrinth/assets'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import type { GameInstance } from '@/helpers/types'
defineProps<{
instance: GameInstance
}>()
</script>
<template>
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
<InstanceIcon
:icon-path="instance.icon_path"
:instance-id="instance.id"
:loader="instance.loader"
size="24px"
/>
{{ instance.name }} <ChevronRightIcon />
</span>
</template>

View File

@ -0,0 +1,208 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import {
ChevronRightIcon,
CodeIcon,
CoffeeIcon,
FileArchiveIcon,
InfoIcon,
MonitorIcon,
WrenchIcon,
} from '@modrinth/assets'
import {
commonMessages,
defineMessage,
TabbedModal,
type TabbedModalTab,
useVIntl,
} from '@modrinth/ui'
import type { PlatformTag } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref, watch } from 'vue'
import CoreComponentsSettings from '@/components/ui/instance_settings/CoreComponentsSettings.vue'
import GeneralSettings from '@/components/ui/instance_settings/GeneralSettings.vue'
import HooksSettings from '@/components/ui/instance_settings/HooksSettings.vue'
import InstallationSettings from '@/components/ui/instance_settings/InstallationSettings.vue'
import JavaSettings from '@/components/ui/instance_settings/JavaSettings.vue'
import WindowSettings from '@/components/ui/instance_settings/WindowSettings.vue'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import { get_project_v3 } from '@/helpers/cache'
import { get_linked_modpack_info } from '@/helpers/instance'
import { get_game_versions, get_loaders } from '@/helpers/tags'
import { provideInstanceSettings } from '@/providers/instance-settings'
import type { GameInstance } from '../../../helpers/types'
const { formatMessage } = useVIntl()
const queryClient = useQueryClient()
const props = defineProps<{
instance: GameInstance
offline?: boolean
}>()
const emit = defineEmits<{
unlinked: []
}>()
const isMinecraftServer = ref(false)
const handleUnlinked = () => emit('unlinked')
let serverMetadataGeneration = 0
const instanceRef = computed(() => props.instance)
const tabbedModal = ref<InstanceType<typeof TabbedModal> | null>(null)
function hide() {
tabbedModal.value?.hide()
}
provideInstanceSettings({
instance: instanceRef,
offline: props.offline,
isMinecraftServer,
onUnlinked: handleUnlinked,
closeModal: hide,
})
watch(
() => props.instance,
(instance) => {
const generation = ++serverMetadataGeneration
isMinecraftServer.value = false
if (instance.install_stage === 'installed' && instance.link?.project_id) {
const instanceId = instance.id
get_project_v3(instance.link.project_id, 'must_revalidate')
.then((project: Labrinth.Projects.v3.Project | undefined) => {
if (
generation === serverMetadataGeneration &&
props.instance.id === instanceId &&
props.instance.install_stage === 'installed' &&
project?.minecraft_server != null
) {
isMinecraftServer.value = true
}
})
.catch(() => {})
}
},
{ immediate: true },
)
const tabs = computed<TabbedModalTab[]>(() => [
{
name: defineMessage({
id: 'instance.settings.tabs.general',
defaultMessage: 'General',
}),
icon: InfoIcon,
content: GeneralSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.installation',
defaultMessage: 'Installation',
}),
icon: WrenchIcon,
content: InstallationSettings,
},
{
// Core component editing is instance-specific and advanced, so it is intentionally excluded from first-run onboarding.
name: defineMessage({
id: 'instance.settings.tabs.core-components',
defaultMessage: 'Core components',
}),
icon: FileArchiveIcon,
content: CoreComponentsSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.window',
defaultMessage: 'Window',
}),
icon: MonitorIcon,
content: WindowSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.java',
defaultMessage: 'Java and memory',
}),
icon: CoffeeIcon,
content: JavaSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.hooks',
defaultMessage: 'Launch preparation',
}),
icon: CodeIcon,
content: HooksSettings,
},
])
function getSupportedModpackLoaders() {
return get_loaders().then((value: PlatformTag[]) =>
value
.filter((item) => item.supported_project_types.includes('modpack') || item.name === 'vanilla')
.sort((a, b) => (a.name === 'vanilla' ? -1 : b.name === 'vanilla' ? 1 : 0)),
)
}
// Preload metadata that is not scoped to an editable Minecraft version.
useQuery({
queryKey: ['instance-settings', 'game-versions'],
queryFn: get_game_versions,
})
useQuery({
queryKey: ['instance-settings', 'loaders', 'modpack'],
queryFn: getSupportedModpackLoaders,
})
useQuery({
queryKey: computed(() => ['linkedModpackInfo', props.instance.id]),
queryFn: () => get_linked_modpack_info(props.instance.id, 'stale_while_revalidate'),
enabled: computed(
() =>
props.instance.install_stage === 'installed' &&
!!props.instance.link?.project_id &&
!props.offline,
),
})
function show(tabIndex?: number) {
if (props.instance.install_stage === 'installed' && props.instance.link?.project_id) {
queryClient.prefetchQuery({
queryKey: ['linkedModpackInfo', props.instance.id],
queryFn: () => get_linked_modpack_info(props.instance.id, 'stale_while_revalidate'),
})
}
tabbedModal.value?.show()
if (tabIndex !== undefined) {
nextTick(() => tabbedModal.value?.setTab(tabIndex))
}
}
defineExpose({ show, hide })
</script>
<template>
<TabbedModal
ref="tabbedModal"
:tabs="tabs"
:max-width="'min(928px, calc(95vw - 10rem))'"
:width="'min(928px, calc(95vw - 10rem))'"
>
<template #title>
<span class="flex items-center gap-2 text-lg font-semibold text-primary">
<InstanceIcon
:icon-path="instance.icon_path"
:instance-id="props.instance.id"
:loader="instance.loader"
size="24px"
/>
{{ instance.name }} <ChevronRightIcon />
<span class="font-extrabold text-contrast">{{
formatMessage(commonMessages.settingsLabel)
}}</span>
</span>
</template>
</TabbedModal>
</template>

View File

@ -0,0 +1,147 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title, { version })"
:on-hide="handleHide"
:disable-close="responding"
fade="warning"
max-width="520px"
>
<div class="flex flex-col gap-3">
<p class="m-0 leading-relaxed text-contrast">
{{ formatMessage(messages.body, { version }) }}
</p>
<p class="m-0 leading-relaxed text-secondary">
{{ formatMessage(messages.laterDescription, { version }) }}
</p>
</div>
<template #actions>
<div class="flex flex-wrap justify-end gap-2">
<ButtonStyled type="outlined">
<button type="button" :disabled="responding" @click="modal?.hide()">
<ClockIcon aria-hidden="true" />
{{ formatMessage(messages.setUpLater) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button type="button" :disabled="responding" @click="confirmDownload">
<SpinnerIcon v-if="responding" class="animate-spin" aria-hidden="true" />
<DownloadIcon v-else aria-hidden="true" />
{{ formatMessage(messages.download) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { ClockIcon, DownloadIcon, SpinnerIcon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { onUnmounted, ref, useTemplateRef } from 'vue'
import { useRouter } from 'vue-router'
import { respond_to_java_download_confirmation } from '@/helpers/jre'
interface JavaDownloadConfirmationRequest {
requestId: string
version: number
}
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const router = useRouter()
const messages = defineMessages({
title: {
id: 'app.java-download-confirmation.title',
defaultMessage: 'Download Java {version}?',
},
body: {
id: 'app.java-download-confirmation.body',
defaultMessage:
'No compatible Java {version} installation was found. You can download it now or postpone Java setup while the remaining game resources continue downloading.',
},
laterDescription: {
id: 'app.java-download-confirmation.later-description',
defaultMessage:
'The instance will finish installing, but Java {version} must be configured before you can play.',
},
setUpLater: {
id: 'app.java-download-confirmation.set-up-later',
defaultMessage: 'Set up later',
},
download: {
id: 'app.java-download-confirmation.download',
defaultMessage: 'Download Java',
},
postponedTitle: {
id: 'app.java-download-confirmation.postponed-title',
defaultMessage: 'Java setup postponed',
},
postponedBody: {
id: 'app.java-download-confirmation.postponed-body',
defaultMessage:
'The instance will finish installing. Configure Java {version} before launching it.',
},
})
const modal = useTemplateRef('modal')
const request = ref<JavaDownloadConfirmationRequest | null>(null)
const version = ref(0)
const responding = ref(false)
let decisionSent = false
function show(payload: JavaDownloadConfirmationRequest) {
request.value = payload
version.value = payload.version
responding.value = false
decisionSent = false
modal.value?.show()
}
function handleHide() {
const pendingRequest = request.value
request.value = null
responding.value = false
if (!pendingRequest || decisionSent) return
decisionSent = true
void respond_to_java_download_confirmation(pendingRequest.requestId, false)
addNotification({
title: formatMessage(messages.postponedTitle),
text: formatMessage(messages.postponedBody, { version: pendingRequest.version }),
type: 'warning',
})
}
async function confirmDownload() {
const pendingRequest = request.value
if (!pendingRequest || responding.value) return
responding.value = true
decisionSent = true
const response = respond_to_java_download_confirmation(pendingRequest.requestId, true)
modal.value?.hide()
await router.push('/downloads')
await response
}
onUnmounted(() => {
const pendingRequest = request.value
if (!pendingRequest || decisionSent) return
decisionSent = true
void respond_to_java_download_confirmation(pendingRequest.requestId, false)
})
defineExpose({ show })
</script>

View File

@ -0,0 +1,602 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
:fade="remaining > 0 ? 'warning' : 'standard'"
:on-hide="stopScanning"
max-width="760px"
scrollable
>
<div class="flex flex-col gap-4">
<Admonition
:type="continuing || remaining === 0 ? 'success' : 'warning'"
:header="
continuing
? formatMessage(messages.continuing)
: formatMessage(messages.remaining, { count: remaining })
"
>
<template #icon>
<SpinnerIcon v-if="loading || continuing" class="size-5 animate-spin" />
<CheckIcon v-else-if="remaining === 0" class="size-5 text-green" />
<DownloadIcon v-else class="size-5" />
</template>
{{ continuing ? formatMessage(messages.continuingBody) : formatMessage(messages.body) }}
</Admonition>
<Admonition
v-if="!continuing && remaining > 0"
:type="scannerPresentation.phase === 'rejected' ? 'warning' : 'info'"
:header="scannerHeader"
>
<template #icon>
<SpinnerIcon
v-if="
scannerPresentation.phase === 'importing' ||
scannerPresentation.phase === 'verifying' ||
scannerPresentation.phase === 'waiting_for_stability'
"
class="size-5 animate-spin"
/>
<FolderSearchIcon v-else class="size-5" />
</template>
{{ scannerStatus }}
</Admonition>
<div
v-if="files.length"
class="flex flex-col overflow-hidden rounded-lg border border-surface-5"
>
<div
v-for="file in files"
:key="file.itemId"
class="flex flex-col gap-3 border-0 border-b border-solid border-surface-5 bg-surface-2 p-4 last:border-b-0"
>
<div class="flex min-w-0 items-start justify-between gap-3">
<div class="min-w-0">
<div class="break-all font-medium text-contrast">{{ file.path }}</div>
<div class="mt-1 flex flex-wrap gap-2 text-sm text-secondary">
<span>{{
formatMessage(messages.expectedSize, { size: formatBytes(file.expectedSize) })
}}</span>
<span>·</span>
<span>{{ attemptText(file) }}</span>
<span v-if="file.browserUrls.length > 1">·</span>
<span v-if="file.browserUrls.length > 1">
{{ formatMessage(messages.fallbacks, { count: file.browserUrls.length - 1 }) }}
</span>
</div>
<div v-if="file.browserUrls[0]" class="mt-1 truncate text-xs text-secondary">
<code v-tooltip="file.browserUrls[0]">{{ file.browserUrls[0] }}</code>
</div>
<div v-if="file.lastError" class="mt-1 text-sm text-red">
{{ file.lastError }}
</div>
<div v-if="scannerItemStatus(file.itemId)" class="mt-1 text-sm text-orange">
{{ scannerItemStatus(file.itemId) }}
</div>
</div>
<Badge :color="statusColor(file.status)" :type="statusLabel(file.status)" />
</div>
<div class="flex flex-wrap gap-2">
<ButtonStyled color="brand" size="small">
<button :disabled="isBusy(file.itemId)" @click="retryOne(file.itemId)">
<SpinnerIcon v-if="isBusy(file.itemId)" class="animate-spin" />
<RefreshCwIcon v-else />
{{ formatMessage(messages.retry) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="file.browserUrls.length" type="outlined" size="small">
<button :disabled="isBusy(file.itemId)" @click="openBrowser(file.browserUrls[0])">
<ExternalIcon />{{ formatMessage(messages.browserDownload) }}
</button>
</ButtonStyled>
<ButtonStyled type="outlined" size="small">
<button :disabled="isBusy(file.itemId)" @click="selectLocal(file.itemId)">
<UploadIcon />{{ formatMessage(messages.chooseFile) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
<template #actions>
<div class="flex flex-wrap justify-end gap-2">
<ButtonStyled type="outlined">
<button @click="modal?.hide()">{{ formatMessage(commonMessages.closeButton) }}</button>
</ButtonStyled>
<ButtonStyled v-if="remaining > 0" color="brand">
<button :disabled="loading || busy.size > 0" @click="retryAll">
<RefreshCwIcon />{{ formatMessage(messages.retryAll) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import {
CheckIcon,
DownloadIcon,
ExternalIcon,
FolderSearchIcon,
RefreshCwIcon,
SpinnerIcon,
UploadIcon,
} from '@modrinth/assets'
import {
Admonition,
Badge,
ButtonStyled,
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
useFormatBytes,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { openUrl } from '@tauri-apps/plugin-opener'
import { computed, onUnmounted, ref } from 'vue'
import {
createDownloadsScanLoop,
createDownloadsScannerPresentationState,
getMissingContentScannerSettings,
reduceDownloadsScannerPresentation,
} from '@/helpers/downloads-scanner'
import { install_job_listener } from '@/helpers/events'
import {
install_job_get,
install_job_import_missing_file,
install_job_missing_files,
install_job_resume,
install_job_retry_missing_file,
install_job_scan_missing_files,
type InstallJobSnapshot,
type MissingModpackContentView,
} from '@/helpers/install'
type MissingFile = MissingModpackContentView['files'][number]
const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const { handleError } = injectNotificationManager()
const modal = ref<InstanceType<typeof NewModal>>()
const jobId = ref<string | null>(null)
const content = ref<MissingModpackContentView>({ remaining: 0, files: [] })
const loading = ref(false)
const continuing = ref(false)
const busy = ref(new Set<string>())
const scannerPresentation = ref(createDownloadsScannerPresentationState())
const scannerErrors = ref(new Map<string, string>())
const scannerEnabled = ref(true)
const scanDirectory = ref<string | null>(null)
let unlisten: (() => void) | null = null
const files = computed(() => content.value.files)
const remaining = computed(() => content.value.remaining)
const scannerHeader = computed(() => {
if (!scannerEnabled.value) return formatMessage(messages.automaticImportDisabled)
if (scannerPresentation.value.phase === 'rejected') {
return formatMessage(messages.fileMismatchTitle)
}
if (scannerPresentation.value.phase === 'verifying') {
return formatMessage(messages.verifyingCandidate)
}
if (scannerPresentation.value.phase === 'importing') {
return formatMessage(messages.importingCandidate)
}
if (
scannerPresentation.value.phase === 'monitoring' ||
scannerPresentation.value.phase === 'idle'
) {
return formatMessage(messages.watchingDownloadsTitle)
}
return formatMessage(messages.automaticImport)
})
const scannerStatus = computed(() => {
if (!scannerEnabled.value) return formatMessage(messages.automaticImportDisabledBody)
const state = scannerPresentation.value
if (state.phase === 'importing') return formatMessage(messages.importingCandidateBody)
if (state.phase === 'verifying') return formatMessage(messages.verifyingCandidateBody)
if (state.phase === 'waiting_for_stability') return formatMessage(messages.waitingForCompletion)
if (state.phase === 'rejected') return formatMessage(messages.fileMismatchBody)
if (state.phase === 'imported') {
return formatMessage(messages.importedAutomatically, {
count: state.importedCount,
remaining: remaining.value,
})
}
if (state.phase === 'error') return formatMessage(messages.scannerFailed)
if (state.phase === 'monitoring' && state.downloadDirectory) {
return formatMessage(messages.watchingDirectory, { path: state.downloadDirectory })
}
if (state.phase === 'idle') {
return formatMessage(messages.watchingDownloadsBody)
}
return formatMessage(messages.downloadsUnavailable)
})
const messages = defineMessages({
header: { id: 'app.downloads.missing-content.header', defaultMessage: 'Complete missing files' },
body: {
id: 'app.downloads.missing-content.body',
defaultMessage: 'Each local file is verified before it can replace the required instance file.',
},
remaining: {
id: 'app.downloads.missing-content.remaining',
defaultMessage:
'{count, plural, one {# file still needs to be completed} other {# files still need to be completed}}',
},
continuing: {
id: 'app.downloads.missing-content.continuing',
defaultMessage: 'Continuing installation',
},
continuingBody: {
id: 'app.downloads.missing-content.continuing-body',
defaultMessage:
'All required files are ready. The launcher is verifying them again and continuing installation.',
},
expectedSize: {
id: 'app.downloads.missing-content.expected-size',
defaultMessage: 'Expected size: {size}',
},
fallbacks: {
id: 'app.downloads.missing-content.fallbacks',
defaultMessage: '{count} fallback links',
},
retry: { id: 'app.downloads.missing-content.retry', defaultMessage: 'Retry download' },
retryAll: {
id: 'app.downloads.missing-content.retry-all',
defaultMessage: 'Retry all missing files',
},
browserDownload: {
id: 'app.downloads.missing-content.browser-download',
defaultMessage: 'Browser download',
},
chooseFile: {
id: 'app.downloads.missing-content.choose-file',
defaultMessage: 'Choose local file',
},
attempts: {
id: 'app.downloads.missing-content.attempts',
defaultMessage: 'Attempt {attempt}/{max}',
},
noAttempts: {
id: 'app.downloads.missing-content.no-attempts',
defaultMessage: 'No attempt information',
},
automaticImport: {
id: 'app.downloads.missing-content.automatic-import',
defaultMessage: 'Automatic import from monitored folder',
},
automaticImportDisabled: {
id: 'app.downloads.missing-content.automatic-import-disabled',
defaultMessage: 'Automatic import is disabled',
},
automaticImportDisabledBody: {
id: 'app.downloads.missing-content.automatic-import-disabled-body',
defaultMessage: 'Retry downloading or choose each missing file manually.',
},
verifyingCandidate: {
id: 'app.downloads.missing-content.verifying-candidate',
defaultMessage: 'Verifying downloaded file',
},
verifyingCandidateBody: {
id: 'app.downloads.missing-content.verifying-candidate-body',
defaultMessage: 'Checking that the candidate matches the file required by the modpack.',
},
importingCandidate: {
id: 'app.downloads.missing-content.importing-candidate',
defaultMessage: 'Importing verified file',
},
importingCandidateBody: {
id: 'app.downloads.missing-content.importing-candidate-body',
defaultMessage: 'Adding the verified file to the instance...',
},
watchingDownloadsTitle: {
id: 'app.downloads.missing-content.watching-downloads-title',
defaultMessage: 'Watching the import folder',
},
watchingDownloadsBody: {
id: 'app.downloads.missing-content.watching-downloads-body',
defaultMessage: 'Completed downloads will be verified and imported automatically.',
},
watchingDirectory: {
id: 'app.downloads.missing-content.watching-downloads',
defaultMessage: 'Watching {path}. Matching files are verified before import.',
},
waitingForCompletion: {
id: 'app.downloads.missing-content.waiting-for-completion',
defaultMessage: 'Waiting for a browser download to finish writing...',
},
downloadsUnavailable: {
id: 'app.downloads.missing-content.downloads-unavailable',
defaultMessage: 'The monitored folder is unavailable. Choose a local file instead.',
},
scannerFailed: {
id: 'app.downloads.missing-content.scanner-failed',
defaultMessage: 'Automatic checking failed. Manual file selection is still available.',
},
fileMismatch: {
id: 'app.downloads.missing-content.file-mismatch',
defaultMessage: 'A similarly named file was found, but it did not match this required file.',
},
fileMismatchTitle: {
id: 'app.downloads.missing-content.file-mismatch-title',
defaultMessage: 'A same-named file was found, but file verification failed',
},
fileMismatchBody: {
id: 'app.downloads.missing-content.file-mismatch-body',
defaultMessage:
'The launcher will keep waiting for the correct file. You can also choose a local file manually.',
},
importedAutomatically: {
id: 'app.downloads.missing-content.imported-automatically',
defaultMessage:
'{count, plural, one {# file imported automatically} other {# files imported automatically}}. {remaining, plural, one {# file still needs to be completed} other {# files still need to be completed}}.',
},
})
const statusMessages = defineMessages({
failed: { id: 'app.downloads.item-status.failed', defaultMessage: 'Failed' },
worker_started: { id: 'app.downloads.item-status.worker-started', defaultMessage: 'Starting' },
connecting: { id: 'app.downloads.item-status.connecting', defaultMessage: 'Connecting' },
verifying: { id: 'app.downloads.item-status.verifying', defaultMessage: 'Verifying' },
writing: { id: 'app.downloads.item-status.writing', defaultMessage: 'Writing' },
finalizing: { id: 'app.downloads.item-status.finalizing', defaultMessage: 'Finalizing' },
metadata: { id: 'app.downloads.item-status.metadata', defaultMessage: 'Loading metadata' },
waiting_for_database: {
id: 'app.downloads.item-status.waiting-for-database',
defaultMessage: 'Waiting for database',
},
completed: { id: 'app.downloads.item-status.completed', defaultMessage: 'Completed' },
downloading: { id: 'app.downloads.item-status.downloading', defaultMessage: 'Downloading' },
waiting_for_resource: {
id: 'app.downloads.item-status.waiting-for-resource',
defaultMessage: 'Waiting for download resources',
},
queued: { id: 'app.downloads.status.queued', defaultMessage: 'Queued' },
})
const scanner = createDownloadsScanLoop({
scan: async () => {
if (!jobId.value) throw new Error('Missing install job ID')
return await install_job_scan_missing_files(jobId.value, scanDirectory.value)
},
onResult: (result) => {
content.value = result.content
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
type: 'scan_result',
downloadDirectory: result.downloadDirectory ?? null,
importedItemIds: result.importedItemIds,
rejectedItemIds: result.rejectedItemIds,
pendingCandidates: result.pendingCandidates,
hasErrors: result.errors.length > 0,
items: result.content.files.map((file) => ({ id: file.itemId, status: file.status })),
})
scannerErrors.value = new Map(result.errors.map((error) => [error.itemId, error.message]))
for (const itemId of result.importedItemIds) {
scannerErrors.value.delete(itemId)
}
if (result.job.status !== 'waiting_for_user') {
continuing.value = true
stopScanning()
}
},
onError: () => {
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
type: 'scan_failed',
})
},
intervalMs: 3000,
})
async function show(job: InstallJobSnapshot) {
stopScanning()
const scannerSettings = getMissingContentScannerSettings()
scannerEnabled.value = scannerSettings.enabled
scanDirectory.value = scannerSettings.directory
jobId.value = job.job_id
continuing.value = false
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
type: 'reset',
})
scannerErrors.value = new Map()
content.value = {
remaining: job.items.filter((item) => item.status === 'failed').length,
files: [],
}
modal.value?.show()
await refresh()
if (!unlisten) {
unlisten = await install_job_listener((update: InstallJobSnapshot) => {
if (update.job_id !== jobId.value) return
if (update.status === 'waiting_for_user') {
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
type: 'items_updated',
items: update.items,
})
void refresh()
} else if (update.status === 'queued' || update.status === 'running') {
continuing.value = true
stopScanning()
}
})
}
if (scannerEnabled.value) scanner.start()
}
function stopScanning() {
scanner.stop()
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
type: 'reset',
})
}
async function refresh() {
if (!jobId.value || continuing.value) return
loading.value = true
try {
const nextContent = await install_job_missing_files(jobId.value)
content.value = nextContent
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
type: 'items_updated',
items: nextContent.files.map((file) => ({ id: file.itemId, status: file.status })),
})
} catch (error) {
handleError(error)
} finally {
loading.value = false
}
}
async function runItem(itemId: string, action: () => Promise<InstallJobSnapshot>) {
busy.value = new Set([...busy.value, itemId])
try {
const job = await action()
await applyItemResult(itemId, job)
} catch (error) {
const latest = jobId.value ? await install_job_get(jobId.value).catch(() => null) : null
if (
latest &&
(isContinuingStatus(latest.status) ||
latest.items.some(
(item) => item.id === itemId && ['completed', 'skipped'].includes(item.status),
))
) {
await applyItemResult(itemId, latest)
} else {
handleError(error)
await refresh()
}
} finally {
const next = new Set(busy.value)
next.delete(itemId)
busy.value = next
}
}
async function applyItemResult(itemId: string, job: InstallJobSnapshot) {
if (
job.status !== 'waiting_for_user' ||
job.items.some((item) => item.id === itemId && ['completed', 'skipped'].includes(item.status))
) {
scannerPresentation.value = reduceDownloadsScannerPresentation(scannerPresentation.value, {
type: 'items_resolved',
itemIds: [itemId],
})
scannerErrors.value.delete(itemId)
}
if (job.status === 'waiting_for_user') await refresh()
else if (isContinuingStatus(job.status)) {
continuing.value = true
stopScanning()
}
}
function isContinuingStatus(status: InstallJobSnapshot['status']) {
return status === 'queued' || status === 'running' || status === 'succeeded'
}
async function retryOne(itemId: string) {
if (!jobId.value) return
await runItem(itemId, () => install_job_retry_missing_file(jobId.value!, itemId))
}
async function selectLocal(itemId: string) {
if (!jobId.value) return
const selected = await open({ multiple: false })
const path = selectedPath(selected)
if (!path) return
await runItem(itemId, () => install_job_import_missing_file(jobId.value!, itemId, path))
}
function selectedPath(selected: unknown) {
if (typeof selected === 'string') return selected
if (
selected &&
typeof selected === 'object' &&
'path' in selected &&
typeof selected.path === 'string'
) {
return selected.path
}
return null
}
async function retryAll() {
if (!jobId.value) return
loading.value = true
stopScanning()
try {
await install_job_resume(jobId.value)
continuing.value = true
} catch (error) {
const latest = await install_job_get(jobId.value).catch(() => null)
if (latest && isContinuingStatus(latest.status)) {
continuing.value = true
} else {
handleError(error)
if (latest?.status === 'waiting_for_user' && scannerEnabled.value) scanner.start()
}
} finally {
loading.value = false
}
}
async function openBrowser(url: string) {
try {
await openUrl(url)
} catch (error) {
handleError(error)
}
}
function isBusy(itemId: string) {
return busy.value.has(itemId)
}
function scannerItemStatus(itemId: string) {
if (scannerErrors.value.has(itemId)) return scannerErrors.value.get(itemId)
if (scannerPresentation.value.rejectedItemIds.includes(itemId)) {
return formatMessage(messages.fileMismatch)
}
return null
}
function attemptText(file: MissingFile) {
if (file.attempt == null || file.maxAttempts == null) return formatMessage(messages.noAttempts)
return formatMessage(messages.attempts, { attempt: file.attempt, max: file.maxAttempts })
}
function statusLabel(status: MissingFile['status']) {
return status in statusMessages
? formatMessage(statusMessages[status as keyof typeof statusMessages])
: status
}
function statusColor(status: MissingFile['status']): 'green' | 'red' | 'orange' | 'blue' | 'gray' {
if (status === 'completed') return 'green'
if (status === 'failed') return 'red'
if (
status === 'verifying' ||
status === 'writing' ||
status === 'finalizing' ||
status === 'metadata' ||
status === 'waiting_for_database'
)
return 'orange'
return 'blue'
}
onUnmounted(() => {
stopScanning()
unlisten?.()
})
defineExpose({ show })
</script>

View File

@ -0,0 +1,56 @@
<!-- @deprecated Use NewModal from @modrinth/ui directly. Ads/noblur now handled by injectModalBehavior. -->
<script setup lang="ts">
import { NewModal as Modal } from '@modrinth/ui'
import { useTemplateRef } from 'vue'
const props = defineProps({
header: {
type: String,
default: null,
},
hideHeader: {
type: Boolean,
default: false,
},
closable: {
type: Boolean,
default: true,
},
onHide: {
type: Function,
default() {
return () => {}
},
},
/** @deprecated No longer used — ads are handled by provideModalBehavior */
showAdOnClose: {
type: Boolean,
default: true,
},
})
const modal = useTemplateRef('modal')
defineExpose({
show: (e?: MouseEvent) => {
modal.value?.show(e)
},
hide: () => {
modal.value?.hide()
},
})
</script>
<template>
<Modal
ref="modal"
:header="header"
:closable="closable"
:hide-header="hideHeader"
:on-hide="() => props.onHide?.()"
>
<template #title>
<slot name="title" />
</template>
<slot />
</Modal>
</template>

View File

@ -0,0 +1,116 @@
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.header)"
fade="warning"
max-width="500px"
:on-hide="handleHide"
>
<p class="m-0 text-secondary">
<IntlFormatted :message-id="messages.body" :values="{ instanceName }">
<template #bold="{ children }">
<span class="font-medium text-contrast"><component :is="() => children" /></span>
</template>
</IntlFormatted>
</p>
<template #actions>
<div class="flex gap-2 justify-end">
<ButtonStyled type="outlined">
<button @click="handleCancel">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="handleGoToInstance">
{{ formatMessage(messages.instance) }}
<RightArrowIcon />
</button>
</ButtonStyled>
<ButtonStyled color="orange">
<button @click="handleCreateAnyway">
<PlusIcon />
{{ formatMessage(messages.create) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>
<script setup lang="ts">
import { PlusIcon, RightArrowIcon, XIcon } from '@modrinth/assets'
import {
ButtonStyled,
commonMessages,
defineMessages,
IntlFormatted,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
header: {
id: 'app.instance.modpack-already-installed.header',
defaultMessage: 'Modpack already installed',
},
body: {
id: 'app.instance.modpack-already-installed.body',
defaultMessage:
'This modpack is already installed in the <bold>{instanceName}</bold> instance. Are you sure you want to duplicate it?',
},
instance: {
id: 'app.instance.modpack-already-installed.instance',
defaultMessage: 'Instance',
},
create: {
id: 'app.instance.modpack-already-installed.create',
defaultMessage: 'Create',
},
})
const emit = defineEmits<{
(e: 'go-to-instance', instanceId: string): void
(e: 'create-anyway' | 'cancel'): void
}>()
const modal = ref<InstanceType<typeof NewModal>>()
const instanceName = ref('')
const instanceId = ref('')
const accepted = ref(false)
function show(name: string, id: string) {
instanceName.value = name
instanceId.value = id
accepted.value = false
modal.value?.show()
}
function handleCancel() {
modal.value?.hide()
}
function handleGoToInstance() {
accepted.value = true
modal.value?.hide()
emit('go-to-instance', instanceId.value)
}
function handleCreateAnyway() {
accepted.value = true
modal.value?.hide()
emit('create-anyway')
}
function handleHide() {
if (!accepted.value) emit('cancel')
}
defineExpose({
show,
})
</script>

View File

@ -0,0 +1,243 @@
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { CheckIcon, DownloadIcon, XIcon } from '@modrinth/assets'
import {
Avatar,
Badge,
ButtonStyled,
Combobox,
commonMessages,
defineMessages,
NewModal,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { releaseColor } from '@/helpers/utils'
export interface ModpackInstallExistingInstance {
id: string
name: string
}
export interface ModpackInstallModalData {
project: Pick<Labrinth.Projects.v2.Project, 'id' | 'title' | 'icon_url'>
versions: Labrinth.Versions.v2.Version[]
initialVersionId?: string | null
existingInstancesByVersion: Record<string, ModpackInstallExistingInstance[]>
instancePaths: string[]
}
const emit = defineEmits<{
install: [versionId: string, name: string]
cancel: []
}>()
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const data = ref<ModpackInstallModalData | null>(null)
const selectedVersionId = ref('')
const instanceName = ref('')
const submitting = ref(false)
const submitted = ref(false)
const messages = defineMessages({
title: {
id: 'app.modpack-install.title',
defaultMessage: 'Install modpack',
},
version: {
id: 'app.modpack-install.version',
defaultMessage: 'Version',
},
instanceName: {
id: 'app.modpack-install.instance-name',
defaultMessage: 'Instance name',
},
instanceNamePlaceholder: {
id: 'app.modpack-install.instance-name-placeholder',
defaultMessage: 'My modpack instance',
},
installed: {
id: 'app.modpack-install.already-installed',
defaultMessage: 'This version is already installed',
},
installedDescription: {
id: 'app.modpack-install.already-installed-description',
defaultMessage: 'Creating another instance will not change these existing instances: {names}',
},
install: {
id: 'app.modpack-install.install',
defaultMessage: 'Install',
},
selectVersion: {
id: 'app.modpack-install.select-version',
defaultMessage: 'Select a version',
},
folderName: {
id: 'app.modpack-install.folder-name',
defaultMessage: 'Instance folder: {name}',
},
folderNameConflict: {
id: 'app.modpack-install.folder-name-conflict',
defaultMessage: 'A folder with this name already exists. The instance folder will be: {name}',
},
})
const selectedVersion = computed(() =>
data.value?.versions.find((version) => version.id === selectedVersionId.value),
)
const versionOptions = computed(() =>
(data.value?.versions ?? []).map((version) => ({
value: version.id,
label: versionLabel(version),
})),
)
const existingInstances = computed(
() => data.value?.existingInstancesByVersion[selectedVersionId.value] ?? [],
)
const folderName = computed(() => {
const baseName = instanceName.value.trim().replace(/[\\/?*:'"|<>!]/g, '_')
let candidate = baseName
let index = 1
while (candidate && data.value?.instancePaths.includes(candidate)) {
candidate = `${baseName} (${index++})`
}
return candidate
})
const hasFolderNameConflict = computed(() => folderName.value !== instanceName.value.trim())
const canInstall = computed(
() => !!selectedVersion.value && instanceName.value.trim().length > 0 && !submitting.value,
)
function versionLabel(version: Labrinth.Versions.v2.Version) {
return [version.version_number || version.name, version.game_versions.join(', ')].filter(Boolean).join(' · ')
}
function show(nextData: ModpackInstallModalData) {
data.value = nextData
selectedVersionId.value =
nextData.versions.find((version) => version.id === nextData.initialVersionId)?.id ??
nextData.versions[0]?.id ??
''
instanceName.value = nextData.project.title
submitting.value = false
submitted.value = false
modal.value?.show()
}
function hide() {
modal.value?.hide()
}
function submit() {
if (!canInstall.value) return
submitting.value = true
submitted.value = true
emit('install', selectedVersionId.value, instanceName.value.trim())
modal.value?.hide()
}
function handleHide() {
if (!submitted.value) emit('cancel')
}
defineExpose({ show, hide })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
width="min(36rem, calc(95vw - 10rem))"
max-width="36rem"
:on-hide="handleHide"
>
<div v-if="data" class="flex min-w-0 flex-col gap-4">
<div class="flex min-w-0 items-center gap-3">
<Avatar
:src="data.project.icon_url"
:alt="data.project.title"
size="3rem"
:tint-by="data.project.title"
no-shadow
/>
<span class="min-w-0 truncate text-lg font-semibold text-contrast">{{ data.project.title }}</span>
</div>
<label class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.version) }}</span>
<Combobox
v-model="selectedVersionId"
:options="versionOptions"
:name="formatMessage(messages.version)"
:display-value="selectedVersion ? versionLabel(selectedVersion) : formatMessage(messages.selectVersion)"
/>
</label>
<div v-if="selectedVersion" class="flex flex-wrap items-center gap-2 text-sm text-secondary">
<Badge :color="releaseColor(selectedVersion.version_type)" :type="selectedVersion.version_type" />
<span v-if="selectedVersion.loaders.length">{{ selectedVersion.loaders.join(', ') }}</span>
<span v-if="selectedVersion.game_versions.length">{{ selectedVersion.game_versions.join(', ') }}</span>
</div>
<label class="flex flex-col gap-2">
<span class="font-semibold text-contrast">{{ formatMessage(messages.instanceName) }}</span>
<StyledInput
v-model="instanceName"
:placeholder="formatMessage(messages.instanceNamePlaceholder)"
autocomplete="off"
wrapper-class="w-full"
/>
</label>
<p v-if="folderName" class="m-0 text-sm text-secondary">
{{
formatMessage(hasFolderNameConflict ? messages.folderNameConflict : messages.folderName, {
name: folderName,
})
}}
</p>
<div
v-if="existingInstances.length"
class="flex items-start gap-2 rounded-lg border border-warning bg-warning-bg p-3"
>
<CheckIcon class="mt-0.5 shrink-0" />
<div class="min-w-0">
<p class="m-0 font-semibold text-contrast">{{ formatMessage(messages.installed) }}</p>
<p class="mt-1 mb-0 text-sm text-secondary">
{{
formatMessage(messages.installedDescription, {
names: existingInstances.map((instance) => instance.name).join(', '),
})
}}
</p>
</div>
</div>
</div>
<template #actions>
<div class="flex justify-end gap-2">
<ButtonStyled type="outlined">
<button :disabled="submitting" @click="hide">
<XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button :disabled="!canInstall" @click="submit">
<DownloadIcon />
{{ formatMessage(messages.install) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>

View File

@ -0,0 +1,145 @@
<script setup lang="ts">
import { ExternalIcon, ShieldIcon, SpinnerIcon } from '@modrinth/assets'
import {
ButtonStyled,
defineMessages,
injectNotificationManager,
NewModal,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import { ref } from 'vue'
import { type PrivacySettings, savePrivacySettings } from '@/helpers/settings'
const emit = defineEmits<{
saved: [privacy: PrivacySettings]
}>()
const CONSENT_VERSION = 1
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const modal = ref<InstanceType<typeof NewModal>>()
const telemetry = ref(true)
const discordRpc = ref(true)
const saving = ref(false)
const messages = defineMessages({
title: {
id: 'app.privacy-consent.title',
defaultMessage: 'Privacy & security',
},
intro: {
id: 'app.privacy-consent.intro',
defaultMessage:
'Choose what Axolotl may send or display. Nothing is sent until you confirm these choices.',
},
telemetry: {
id: 'app.privacy-consent.telemetry',
defaultMessage: 'Allow anonymous telemetry',
},
telemetryDescription: {
id: 'app.privacy-consent.telemetry-description',
defaultMessage:
'Helps count opted-in installations and daily active users. Full Minecraft logs and account credentials are never uploaded.',
},
discordRpc: {
id: 'app.privacy-consent.discord-rpc',
defaultMessage: 'Discord Rich Presence',
},
discordRpcDescription: {
id: 'app.privacy-consent.discord-rpc-description',
defaultMessage:
'Shows your current launcher or game activity in Discord when Discord is running.',
},
privacyPolicy: {
id: 'app.privacy-consent.privacy-policy',
defaultMessage: 'Read the privacy policy',
},
continue: {
id: 'app.privacy-consent.continue',
defaultMessage: 'Save and continue',
},
})
function show(current: PrivacySettings) {
telemetry.value = true
discordRpc.value = current.discord_rpc
modal.value?.show()
}
async function save() {
if (saving.value) return
saving.value = true
try {
const privacy = await savePrivacySettings({
telemetry: telemetry.value,
discord_rpc: discordRpc.value,
consent_version: CONSENT_VERSION,
})
modal.value?.hide()
emit('saved', privacy)
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
defineExpose({ show })
</script>
<template>
<NewModal ref="modal" :header="formatMessage(messages.title)" :closable="false" max-width="600px">
<div class="flex flex-col gap-6">
<div class="flex items-start gap-3">
<ShieldIcon class="mt-0.5 size-6 shrink-0 text-brand" />
<p class="m-0 leading-relaxed text-primary">
{{ formatMessage(messages.intro) }}
</p>
</div>
<div class="flex items-center justify-between gap-5">
<div class="min-w-0">
<label for="consent-telemetry" class="font-semibold text-contrast">
{{ formatMessage(messages.telemetry) }}
</label>
<p class="mb-0 mt-1 text-sm leading-relaxed text-secondary">
{{ formatMessage(messages.telemetryDescription) }}
</p>
</div>
<Toggle id="consent-telemetry" v-model="telemetry" :disabled="saving" />
</div>
<div class="flex items-center justify-between gap-5">
<div class="min-w-0">
<label for="consent-discord-rpc" class="font-semibold text-contrast">
{{ formatMessage(messages.discordRpc) }}
</label>
<p class="mb-0 mt-1 text-sm leading-relaxed text-secondary">
{{ formatMessage(messages.discordRpcDescription) }}
</p>
</div>
<Toggle id="consent-discord-rpc" v-model="discordRpc" :disabled="saving" />
</div>
</div>
<template #actions>
<div class="flex items-center justify-between gap-4">
<ButtonStyled type="transparent">
<button type="button" :disabled="saving" @click="openUrl('https://axlmc.org/privacy')">
<ExternalIcon />
{{ formatMessage(messages.privacyPolicy) }}
</button>
</ButtonStyled>
<ButtonStyled color="brand">
<button type="button" :disabled="saving" @click="save">
<SpinnerIcon v-if="saving" class="animate-spin" />
{{ formatMessage(messages.continue) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>

View File

@ -0,0 +1,50 @@
<!-- @deprecated Use ShareModal from @modrinth/ui directly. Ads/noblur now handled by injectModalBehavior. -->
<script setup lang="ts">
import { ShareModal } from '@modrinth/ui'
import { ref } from 'vue'
defineProps({
header: {
type: String,
default: 'Share',
},
shareTitle: {
type: String,
default: 'Modrinth',
},
shareText: {
type: String,
default: null,
},
link: {
type: Boolean,
default: false,
},
openInNewTab: {
type: Boolean,
default: true,
},
})
const modal = ref(null)
defineExpose({
show: (passedContent) => {
modal.value.show(passedContent)
},
hide: () => {
modal.value.hide()
},
})
</script>
<template>
<ShareModal
ref="modal"
:header="header"
:share-title="shareTitle"
:share-text="shareText"
:link="link"
:open-in-new-tab="openInNewTab"
/>
</template>

View File

@ -0,0 +1,314 @@
<template>
<SymlinkInstanceWarning
v-if="instance?.symlink_target"
:symlink-target="instance.symlink_target"
/>
<ContentDiffModal
ref="diffModal"
:header="formatMessage(messages.updateToPlay)"
:admonition-header="formatMessage(messages.updateRequired)"
:description="
instance ? formatMessage(messages.updateRequiredDescription, { name: instance.name }) : ''
"
:diffs="normalizedDiffs"
:confirm-label="formatMessage(commonMessages.updateButton)"
:confirm-icon="DownloadIcon"
:show-report-button="true"
@confirm="handleUpdate"
@cancel="handleDecline"
@report="handleReport"
/>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { DownloadIcon } from '@modrinth/assets'
import {
commonMessages,
type ContentDiffItem,
ContentDiffModal,
defineMessages,
useVIntl,
} from '@modrinth/ui'
import { openUrl } from '@tauri-apps/plugin-opener'
import dayjs from 'dayjs'
import { computed, ref, watch } from 'vue'
import SymlinkInstanceWarning from '@/components/ui/SymlinkInstanceWarning.vue'
import { get_project_many, get_version, get_version_many } from '@/helpers/cache.js'
import { wait_for_install_job } from '@/helpers/install'
import { update_managed_modrinth_version } from '@/helpers/instance'
import type { GameInstance } from '@/helpers/types'
import { injectServerInstall } from '@/providers/server-install'
defineOptions({
inheritAttrs: false,
})
type Dependency = Labrinth.Versions.v3.Dependency
type Version = Labrinth.Versions.v2.Version
interface BaseDiff {
project_id: string
project?: {
title: string
icon_url?: string
slug: string
}
currentVersionId?: string
newVersionId?: string
currentVersion?: Version
newVersion?: Version
fileName?: string
}
interface AddedDiff extends BaseDiff {
type: 'added'
newVersionId: string
}
interface RemovedDiff extends BaseDiff {
type: 'removed'
}
interface UpdatedDiff extends BaseDiff {
type: 'updated'
currentVersionId: string
newVersionId: string
}
type DependencyDiff = AddedDiff | RemovedDiff | UpdatedDiff
type ProjectInfo = {
id: string
title: string
icon_url?: string
slug: string
}
const { formatMessage } = useVIntl()
const { startInstallingServer, stopInstallingServer } = injectServerInstall()
type UpdateCompleteCallback = () => void | Promise<void>
const diffModal = ref<InstanceType<typeof ContentDiffModal>>()
const instance = ref<GameInstance | null>(null)
const onUpdateComplete = ref<UpdateCompleteCallback>(() => {})
const diffs = ref<DependencyDiff[]>([])
const modpackVersionId = ref<string | null>(null)
const modpackVersion = ref<Version | null>(null)
const normalizedDiffs = computed<ContentDiffItem[]>(() =>
diffs.value.map((diff) => ({
type: diff.type,
projectName: diff.project?.title,
fileName: diff.fileName,
currentVersionName: diff.currentVersion?.version_number,
newVersionName: diff.newVersion?.version_number,
})),
)
async function computeDependencyDiffs(
currentDeps: Dependency[],
latestDeps: Dependency[],
): Promise<DependencyDiff[]> {
console.log('Computing dependency diffs', { currentDeps, latestDeps })
// Separate deps with project_id from file_name-only deps
const currentWithProject = currentDeps.filter((d) => d.project_id)
const latestWithProject = latestDeps.filter((d) => d.project_id)
const currentFileOnly = currentDeps.filter((d) => !d.project_id && d.file_name)
const latestFileOnly = latestDeps.filter((d) => !d.project_id && d.file_name)
const currentByProject = new Map<string, Dependency>(
currentWithProject.map((d) => [d.project_id!, d]),
)
const latestByProject = new Map<string, Dependency>(
latestWithProject.map((d) => [d.project_id!, d]),
)
const currentFilenames = new Set(currentFileOnly.map((d) => d.file_name!))
const latestFilenames = new Set(latestFileOnly.map((d) => d.file_name!))
const diffs: DependencyDiff[] = []
// Find added and updated dependencies (by project_id)
latestByProject.forEach((latestDep, projectId) => {
const currentDep = currentByProject.get(projectId)
if (!currentDep && latestDep.version_id) {
diffs.push({ type: 'added', project_id: projectId, newVersionId: latestDep.version_id })
} else if (
currentDep?.version_id &&
latestDep?.version_id &&
currentDep?.version_id !== latestDep.version_id
) {
diffs.push({
type: 'updated',
project_id: projectId,
currentVersionId: currentDep.version_id,
newVersionId: latestDep.version_id,
})
}
})
// Find removed dependencies (by project_id)
currentByProject.forEach((currentDep, projectId) => {
if (!latestByProject.has(projectId)) {
diffs.push({
type: 'removed',
project_id: projectId,
currentVersionId: currentDep.version_id,
})
}
})
// Find added/removed file_name-only dependencies
// ideally in future, this should use the hash of the file instead of filename, but since version dependencies don't include file hashes, we'll use filename as a best effort approach
for (const fileName of latestFilenames) {
if (!currentFilenames.has(fileName)) {
diffs.push({ type: 'added', project_id: '', newVersionId: '' as string, fileName })
}
}
for (const fileName of currentFilenames) {
if (!latestFilenames.has(fileName)) {
diffs.push({ type: 'removed', project_id: '', fileName })
}
}
// Fetch projects and versions of diffs
const allProjectIds = [...new Set(diffs.map((d) => d.project_id).filter(Boolean))]
const allVersionIds = [
...new Set(
[...diffs.map((d) => d.newVersionId), ...diffs.map((d) => d.currentVersionId)].filter(
Boolean,
),
),
] as string[]
const [projects, versions] = await Promise.all([
get_project_many(allProjectIds, 'bypass'),
get_version_many(allVersionIds, 'bypass'),
])
const projectMap = new Map<string, ProjectInfo>(projects.map((p: ProjectInfo) => [p.id, p]))
const versionMap = new Map<string, Version>(versions.map((v: Version) => [v.id, v]))
const mappedDiffs = diffs
.map((diff) => {
const project = projectMap.get(diff.project_id)
return {
...diff,
project: project
? { title: project.title, icon_url: project.icon_url, slug: project.slug }
: undefined,
currentVersion: diff.currentVersionId ? versionMap.get(diff.currentVersionId) : undefined,
newVersion: diff.newVersionId ? versionMap.get(diff.newVersionId) : undefined,
}
})
.sort((a, b) => {
const typeOrder = { added: 0, updated: 1, removed: 2 }
const typeCompare = typeOrder[a.type] - typeOrder[b.type]
if (typeCompare !== 0) return typeCompare
const aDate = a.newVersion?.date_published || a.currentVersion?.date_published || ''
const bDate = b.newVersion?.date_published || b.currentVersion?.date_published || ''
return dayjs(bDate).valueOf() - dayjs(aDate).valueOf()
})
.filter((d) => d.project || d.fileName) // filter out any diffs that couldn't be matched to a project or file
return mappedDiffs
}
async function checkUpdateAvailable(inst: GameInstance): Promise<DependencyDiff[] | null> {
if (!inst.link) return null
if (!modpackVersionId.value || !inst.link.version_id) return null
try {
// For server projects, link.project_id is the server project but
// link.version_id references a content modpack version from a different project.
// Detect this by comparing the version's project_id with link.project_id.
modpackVersion.value = await get_version(modpackVersionId.value, 'bypass')
const instanceModpackVersion = await get_version(inst.link.version_id, 'bypass')
// Compute dependency diffs between current and latest version
if (instanceModpackVersion && modpackVersion.value) {
return await computeDependencyDiffs(
instanceModpackVersion.dependencies || [],
modpackVersion.value.dependencies || [],
)
}
} catch (error) {
console.error('Error checking for updates:', error)
return null
}
return null
}
watch(
() => instance.value,
async (newInstance) => {
if (!newInstance) return
const result = await checkUpdateAvailable(newInstance)
diffs.value = result || []
},
{ immediate: true, deep: true },
)
async function handleUpdate() {
hide()
const serverProjectId = instance.value?.link?.project_id
if (serverProjectId) startInstallingServer(serverProjectId)
try {
if (modpackVersionId.value && instance.value) {
const job = await update_managed_modrinth_version(instance.value.id, modpackVersionId.value)
await wait_for_install_job(job.job_id)
await onUpdateComplete.value()
}
} catch (error) {
console.error('Error updating instance:', error)
} finally {
if (serverProjectId) stopInstallingServer(serverProjectId)
}
}
function handleReport() {
if (instance.value?.link?.project_id) {
openUrl(`https://modrinth.com/report?item=project&itemID=${instance.value.link.project_id}`)
}
}
function handleDecline() {
hide()
}
function show(
instanceVal: GameInstance,
modpackVersionIdVal: string | null = null,
callback: UpdateCompleteCallback = () => {},
e?: MouseEvent,
) {
instance.value = instanceVal
modpackVersionId.value = modpackVersionIdVal
onUpdateComplete.value = callback
diffModal.value?.show(e)
}
function hide() {
diffModal.value?.hide()
}
const messages = defineMessages({
updateToPlay: {
id: 'app.modal.update-to-play.header',
defaultMessage: 'Update to play',
},
updateRequired: {
id: 'app.modal.update-to-play.update-required',
defaultMessage: 'Update required',
},
updateRequiredDescription: {
id: 'app.modal.update-to-play.update-required-description',
defaultMessage:
'An update is required to play {name}. Please update to the latest version to launch the game.',
},
})
const hasUpdate = computed(() => {
if (!instance.value?.link) return false
return modpackVersionId.value != null && modpackVersionId.value !== instance.value.link.version_id
})
defineExpose({ show, hide, hasUpdate })
</script>

View File

@ -0,0 +1,203 @@
<script setup lang="ts">
import { RightArrowIcon, XIcon } from '@modrinth/assets'
import { ButtonStyled, useVIntl } from '@modrinth/ui'
import { computed } from 'vue'
import { onboardingMessages, type OnboardingStep } from './onboardingConfig'
import OnboardingMascotStage from './OnboardingMascotStage.vue'
const props = defineProps<{
step: OnboardingStep
current: number
total: number
docked: boolean
}>()
defineEmits<{
advance: []
skip: []
}>()
const { formatMessage } = useVIntl()
const progressWidth = computed(() => `${Math.min(100, (props.current / props.total) * 100)}%`)
</script>
<template>
<div class="onboarding-dialogue-layout" :class="{ 'onboarding-dialogue-layout-docked': docked }">
<div v-if="docked" class="onboarding-dialogue-progress" aria-hidden="true">
<span :style="{ width: progressWidth }"></span>
</div>
<OnboardingMascotStage :alt="formatMessage(onboardingMessages.mascotAlt)" />
<div class="onboarding-dialogue-copy">
<div class="onboarding-dialogue-header">
<div class="onboarding-dialogue-heading">
<p class="m-0 mb-1.5 text-[0.8125rem] font-bold text-brand">{{ current }} / {{ total }}</p>
<h2 :id="`onboarding-title-${step.id}`">{{ formatMessage(step.title) }}</h2>
</div>
<ButtonStyled circular type="transparent">
<button :aria-label="formatMessage(onboardingMessages.skip)" @click.stop="$emit('skip')">
<XIcon />
</button>
</ButtonStyled>
</div>
<div class="onboarding-dialogue-body">
<p :id="`onboarding-description-${step.id}`">
{{ formatMessage(step.description) }}
</p>
<ButtonStyled v-if="step.interaction === 'manual'" color="brand">
<button @click="$emit('advance')">
{{ formatMessage(step.action) }}
<RightArrowIcon />
</button>
</ButtonStyled>
<p v-else class="onboarding-action-hint">
<RightArrowIcon aria-hidden="true" />
{{ formatMessage(step.action) }}
</p>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.onboarding-dialogue-layout {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: 0.875rem;
}
.onboarding-dialogue-layout > :deep(.onboarding-mascot) {
width: 6rem;
align-self: center;
transform: scale(1.35);
transform-origin: center;
}
.onboarding-dialogue-layout-docked {
width: min(72rem, 100%);
margin-inline: auto;
gap: 1.25rem;
}
.onboarding-dialogue-progress {
position: absolute;
top: -1px;
left: 0;
width: 100%;
height: 2px;
overflow: hidden;
pointer-events: none;
}
.onboarding-dialogue-progress span {
display: block;
height: 100%;
background: var(--color-brand);
transition: width 280ms cubic-bezier(0.22, 1, 0.36, 1);
}
.onboarding-dialogue-layout-docked > :deep(.onboarding-mascot) {
width: 8rem;
align-self: end;
transform: none;
}
.onboarding-dialogue-copy {
min-width: 0;
color: var(--color-contrast);
}
.onboarding-dialogue-header {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: flex-start;
gap: 1rem;
padding-bottom: 0.375rem;
}
.onboarding-dialogue-heading h2,
.onboarding-dialogue-body p {
margin: 0;
}
.onboarding-dialogue-heading h2 {
color: var(--color-contrast);
font-size: 1.25rem;
font-weight: 700;
line-height: 1.25;
letter-spacing: 0;
}
.onboarding-dialogue-body > p:not(.onboarding-action-hint) {
max-width: 46ch;
color: var(--color-contrast);
font-size: 1rem;
line-height: 1.55;
}
.onboarding-dialogue-layout-docked .onboarding-dialogue-body > p:not(.onboarding-action-hint) {
max-width: 68ch;
}
.onboarding-progress,
.onboarding-action-hint {
font-size: 0.8125rem;
font-weight: 700;
}
.onboarding-progress {
margin-bottom: 0.375rem;
color: var(--color-brand);
}
.onboarding-action-hint {
display: flex;
align-items: center;
gap: 0.375rem;
margin-top: 0.625rem !important;
color: var(--color-brand);
text-wrap: pretty;
}
.onboarding-action-hint :deep(svg) {
flex: none;
}
.onboarding-dialogue-body :deep(.button-outer) {
margin-top: 1rem;
}
.onboarding-dialogue-header :deep(.button-outer) {
flex: none;
margin-top: 0;
}
.onboarding-dialogue-copy :deep(svg) {
width: 1rem;
height: 1rem;
}
@media (max-width: 700px) {
.onboarding-dialogue-layout > :deep(.onboarding-mascot) {
width: 4.5rem;
transform: scale(1.3);
}
.onboarding-dialogue-layout-docked {
width: 100%;
gap: 0.75rem;
}
.onboarding-dialogue-layout-docked > :deep(.onboarding-mascot) {
width: 5rem;
transform: none;
}
}
@media (prefers-reduced-motion: reduce) {
.onboarding-dialogue-progress span {
transition: none;
}
}
</style>

View File

@ -0,0 +1,23 @@
<script setup lang="ts">
import searchingVisual from '@/assets/axo-searching.svg?url'
import teachingVisual from '@/assets/axo-teaching.svg?url'
defineProps<{
alt: string
}>()
const mascotVisuals = [teachingVisual, searchingVisual]
const mascotVisual = mascotVisuals[Math.floor(Math.random() * mascotVisuals.length)]
</script>
<template>
<img class="onboarding-mascot" :src="mascotVisual" :alt="alt" />
</template>
<style scoped lang="scss">
.onboarding-mascot {
width: clamp(6rem, 11vw, 9rem);
height: auto;
object-fit: contain;
}
</style>

View File

@ -0,0 +1,235 @@
<script setup lang="ts">
import { toRef } from 'vue'
import type { OnboardingMode } from './onboardingConfig'
import OnboardingDialogue from './OnboardingDialogue.vue'
import OnboardingWelcome from './OnboardingWelcome.vue'
import { useOnboardingTour } from './useOnboardingTour'
const props = defineProps<{
visible: boolean
mode: OnboardingMode
}>()
const emit = defineEmits<{
complete: []
skip: []
requestCloseSettings: []
}>()
const {
bubbleElement,
bubblePlacement,
controlSpotlightStyle,
handleManualClick,
isDialogueStep,
isWelcomeStep,
step,
stepIndex,
steps,
targetRect,
advance,
} = useOnboardingTour(toRef(props, 'visible'), toRef(props, 'mode'), {
complete: () => emit('complete'),
skip: () => emit('skip'),
closeSettings: () => emit('requestCloseSettings'),
})
</script>
<template>
<div v-if="visible" class="fixed inset-0 z-[10001] overflow-hidden pointer-events-none" aria-live="polite">
<div
v-if="targetRect && step.spotlight === 'control'"
class="onboarding-spotlight"
:style="controlSpotlightStyle"
aria-hidden="true"
>
<span class="onboarding-corner onboarding-corner-top-left"></span>
<span class="onboarding-corner onboarding-corner-top-right"></span>
<span class="onboarding-corner onboarding-corner-bottom-right"></span>
<span class="onboarding-corner onboarding-corner-bottom-left"></span>
</div>
<section
ref="bubbleElement"
data-onboarding-overlay-ui
class="onboarding-surface"
:class="[
`onboarding-surface-${bubblePlacement.direction}`,
{
'onboarding-surface-centered': !targetRect,
'onboarding-surface-dialogue': isDialogueStep,
'onboarding-surface-welcome': isWelcomeStep,
},
]"
:style="bubblePlacement.style"
:aria-labelledby="`onboarding-title-${step.id}`"
:aria-describedby="`onboarding-description-${step.id}`"
@click="step.interaction === 'inspect' ? advance() : undefined"
>
<OnboardingWelcome
v-if="isWelcomeStep"
:step="step"
@start="handleManualClick"
@skip="emit('skip')"
/>
<OnboardingDialogue
v-else
:key="`${mode}-${step.id}`"
:step="step"
:current="stepIndex + 1"
:total="steps.length"
:docked="isDialogueStep"
@advance="handleManualClick"
@skip="emit('skip')"
/>
</section>
</div>
</template>
<style scoped lang="scss">
.onboarding-spotlight {
position: fixed;
pointer-events: none;
transform-origin: center;
animation: onboarding-focus 1.6s ease-in-out infinite;
}
.onboarding-corner {
position: absolute;
width: 0.75rem;
height: 0.75rem;
border-color: var(--color-brand);
border-style: solid;
border-width: 0;
}
.onboarding-corner-top-left {
top: 0;
left: 0;
border-top-width: 2px;
border-left-width: 2px;
}
.onboarding-corner-top-right {
top: 0;
right: 0;
border-top-width: 2px;
border-right-width: 2px;
}
.onboarding-corner-bottom-right {
right: 0;
bottom: 0;
border-right-width: 2px;
border-bottom-width: 2px;
}
.onboarding-corner-bottom-left {
bottom: 0;
left: 0;
border-bottom-width: 2px;
border-left-width: 2px;
}
.onboarding-surface {
position: fixed;
z-index: 1;
width: min(32rem, calc(100vw - 2rem));
max-height: calc(100vh - 4rem);
overflow-y: auto;
border: 1px solid var(--color-divider);
border-radius: var(--radius-lg);
background: var(--color-super-raised-bg);
padding: 1.25rem;
box-sizing: border-box;
pointer-events: auto;
}
.onboarding-surface-centered {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.onboarding-surface-welcome {
inset: 0;
z-index: 3;
width: auto;
height: 100dvh;
min-height: 0;
max-height: none;
transform: none;
overflow: hidden;
border: 0;
border-radius: 0;
padding: 0;
}
.onboarding-surface-dialogue {
top: auto !important;
right: 0;
bottom: 0;
left: 0 !important;
z-index: 2;
width: auto;
min-height: 0;
max-height: min(15rem, 40vh);
transform: none;
overflow-y: auto;
border: 0;
border-top: 1px solid var(--color-divider);
border-radius: 0;
padding: 0.75rem 1.5rem;
}
:global(body.onboarding-reserve-dialogue-space .modal-container) {
height: calc(100% - var(--onboarding-dialogue-reserved-space, 0px));
}
@keyframes onboarding-focus {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.015);
}
}
@media (prefers-reduced-motion: reduce) {
.onboarding-spotlight {
animation: none;
}
}
@media (max-width: 700px) {
.onboarding-surface {
top: auto !important;
bottom: 1rem;
left: 1rem !important;
width: calc(100vw - 2rem);
max-height: min(22rem, calc(100vh - 4rem));
transform: none;
padding: 1rem;
}
.onboarding-surface-dialogue {
right: 0;
bottom: 0;
left: 0 !important;
width: auto;
min-height: 0;
max-height: min(18rem, 52vh);
padding: 0.75rem 1rem;
}
.onboarding-surface-welcome {
inset: 0 !important;
width: auto;
height: 100dvh;
max-height: none;
padding: 0;
}
}
</style>

View File

@ -0,0 +1,457 @@
<script setup lang="ts">
import { RightArrowIcon } from '@modrinth/assets'
import { ButtonStyled, useVIntl } from '@modrinth/ui'
import AxolotlLogo from '@/components/ui/AxolotlLogo.vue'
import { onboardingMessages, type OnboardingStep } from './onboardingConfig'
defineProps<{
step: OnboardingStep
}>()
defineEmits<{
start: []
skip: []
}>()
const { formatMessage } = useVIntl()
</script>
<template>
<div class="onboarding-welcome-content">
<div class="onboarding-welcome-brand-stage">
<div class="onboarding-welcome-brand">
<div class="onboarding-welcome-logo">
<AxolotlLogo icon-only />
</div>
<div class="onboarding-welcome-wordmark" aria-label="Axolotl Launcher">
<span class="onboarding-welcome-wordmark-core" data-wordmark="Axolotl"> Axolotl </span>
<span class="onboarding-welcome-wordmark-suffix" data-wordmark="Launcher">
Launcher
</span>
</div>
</div>
</div>
<div class="onboarding-welcome-panel">
<div class="onboarding-welcome-panel-inner">
<div class="onboarding-welcome-copy">
<h1 :id="`onboarding-title-${step.id}`">{{ formatMessage(step.title) }}</h1>
<p :id="`onboarding-description-${step.id}`">
{{ formatMessage(step.description) }}
</p>
</div>
<div class="onboarding-welcome-actions">
<ButtonStyled color="brand">
<button @click="$emit('start')">
{{ formatMessage(step.action) }}
<RightArrowIcon />
</button>
</ButtonStyled>
<div class="onboarding-welcome-secondary-action">
<span>{{ formatMessage(onboardingMessages.welcomeFooter) }}</span>
<ButtonStyled type="transparent">
<button @click="$emit('skip')">
{{ formatMessage(onboardingMessages.skip) }}
</button>
</ButtonStyled>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped lang="scss">
.onboarding-welcome-content {
position: relative;
width: 100%;
height: 100dvh;
min-height: 0;
overflow: hidden;
box-sizing: border-box;
background: var(--color-bg);
isolation: isolate;
}
.onboarding-welcome-brand-stage {
position: absolute;
inset: 0;
overflow: hidden;
}
.onboarding-welcome-brand {
position: absolute;
top: 50%;
left: 50%;
display: flex;
align-items: center;
justify-content: flex-start;
gap: clamp(0.75rem, 2vw, 1.5rem);
transform: translate(-50%, -50%);
animation: onboarding-welcome-brand-lift 3200ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.onboarding-welcome-logo {
position: relative;
z-index: 2;
flex: none;
width: clamp(7rem, 15vw, 11rem);
height: clamp(7rem, 15vw, 11rem);
animation: onboarding-welcome-logo-reveal 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.onboarding-welcome-wordmark {
display: flex;
align-items: baseline;
gap: 0.35em;
max-width: 0;
overflow: hidden;
color: var(--color-contrast);
font-size: 4.5rem;
font-weight: 800;
line-height: 1;
letter-spacing: 0;
white-space: nowrap;
animation: onboarding-welcome-wordmark-reveal 1050ms 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.onboarding-welcome-wordmark span {
position: relative;
display: inline-block;
transform: translateX(-4rem);
animation: onboarding-welcome-wordmark-flight 1050ms 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.onboarding-welcome-wordmark-core {
background-image: linear-gradient(90deg, var(--color-contrast), var(--color-base));
background-clip: text;
-webkit-background-clip: text;
color: transparent;
-webkit-text-fill-color: transparent;
}
.onboarding-welcome-wordmark-suffix {
color: var(--color-secondary);
font-weight: 650;
}
.onboarding-welcome-wordmark span::after {
position: absolute;
inset: 0;
color: var(--color-brand);
content: attr(data-wordmark);
animation: onboarding-welcome-brand-scan 700ms 2050ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.onboarding-welcome-wordmark-suffix::after {
animation-delay: 2400ms;
}
.onboarding-welcome-panel {
position: absolute;
right: 0;
bottom: 0;
left: 0;
z-index: 3;
border-top: 1px solid var(--color-divider);
background: var(--color-raised-bg);
opacity: 0;
transform: translateY(100%);
animation: onboarding-welcome-panel-enter 650ms 3200ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.onboarding-welcome-panel::before {
position: absolute;
top: -2px;
left: 0;
width: clamp(4rem, 12vw, 10rem);
height: 2px;
background: var(--color-brand);
content: '';
}
.onboarding-welcome-panel-inner {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: clamp(2rem, 6vw, 6rem);
width: min(80rem, 100%);
min-height: clamp(11rem, 27vh, 15rem);
margin-inline: auto;
padding: 1.5rem clamp(1.5rem, 5vw, 4rem);
box-sizing: border-box;
}
.onboarding-welcome-copy {
min-width: 0;
}
.onboarding-welcome-copy h1,
.onboarding-welcome-copy p {
margin: 0;
}
.onboarding-welcome-copy h1 {
color: var(--color-contrast);
font-size: 2.25rem;
font-weight: 750;
line-height: 1.15;
letter-spacing: 0;
text-wrap: balance;
}
.onboarding-welcome-copy p {
max-width: 46rem;
margin-top: 0.75rem;
color: var(--color-secondary);
font-size: 1rem;
line-height: 1.55;
text-wrap: pretty;
}
.onboarding-welcome-actions {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.75rem;
min-width: 15rem;
}
.onboarding-welcome-actions :deep(.button-outer),
.onboarding-welcome-actions :deep(button) {
width: 100%;
justify-content: center;
white-space: nowrap;
}
.onboarding-welcome-secondary-action {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 0.5rem;
color: var(--color-secondary);
font-size: 0.8125rem;
}
.onboarding-welcome-secondary-action :deep(.button-outer),
.onboarding-welcome-secondary-action :deep(button) {
width: auto;
}
@keyframes onboarding-welcome-logo-reveal {
0% {
opacity: 0;
transform: scale(0.72);
}
65% {
opacity: 1;
transform: scale(1.04);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes onboarding-welcome-wordmark-reveal {
from {
max-width: 0;
}
to {
max-width: 42rem;
}
}
@keyframes onboarding-welcome-wordmark-flight {
from {
opacity: 0;
transform: translateX(-4rem);
}
to {
opacity: 1;
transform: translateX(0);
}
}
@keyframes onboarding-welcome-brand-scan {
0%,
12% {
clip-path: inset(0 0 0 0);
}
100% {
clip-path: inset(0 0 0 100%);
}
}
@keyframes onboarding-welcome-brand-lift {
0%,
70% {
top: 50%;
}
100% {
top: clamp(9rem, 33vh, 18rem);
}
}
@keyframes onboarding-welcome-panel-enter {
from {
opacity: 0;
transform: translateY(100%);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.onboarding-welcome-brand,
.onboarding-welcome-logo,
.onboarding-welcome-wordmark,
.onboarding-welcome-wordmark span,
.onboarding-welcome-panel {
animation: none;
}
.onboarding-welcome-brand {
top: clamp(9rem, 33vh, 18rem);
}
.onboarding-welcome-wordmark {
max-width: 42rem;
}
.onboarding-welcome-logo,
.onboarding-welcome-wordmark span,
.onboarding-welcome-panel {
opacity: 1;
transform: none;
}
.onboarding-welcome-wordmark span::after {
display: none;
}
}
@media (max-width: 700px) {
.onboarding-welcome-brand {
gap: 0.75rem;
}
.onboarding-welcome-logo {
width: 6.5rem;
height: 6.5rem;
}
.onboarding-welcome-wordmark {
flex-direction: column;
align-items: flex-start;
gap: 0.1rem;
font-size: 2.5rem;
}
.onboarding-welcome-panel-inner {
grid-template-columns: minmax(0, 1fr);
gap: 1rem;
min-height: min(19rem, 46vh);
padding: 1.25rem;
}
.onboarding-welcome-copy h1 {
font-size: 1.5rem;
}
.onboarding-welcome-copy p {
margin-top: 0.5rem;
font-size: 0.9375rem;
line-height: 1.45;
}
.onboarding-welcome-actions {
min-width: 0;
}
}
@media (max-width: 480px) {
.onboarding-welcome-wordmark {
font-size: 1.875rem;
}
.onboarding-welcome-secondary-action > span {
display: none;
}
}
@media (min-width: 701px) and (max-width: 1000px) {
.onboarding-welcome-wordmark {
font-size: 3.5rem;
}
}
@media (max-height: 680px) {
.onboarding-welcome-brand {
gap: 0.75rem;
}
.onboarding-welcome-logo {
width: 6.5rem;
height: 6.5rem;
}
.onboarding-welcome-wordmark {
font-size: 2.5rem;
}
.onboarding-welcome-panel-inner {
min-height: min(10rem, 32vh);
padding-block: 1rem;
}
.onboarding-welcome-copy p {
margin-top: 0.375rem;
line-height: 1.4;
}
}
@media (max-width: 700px) and (max-height: 680px) {
.onboarding-welcome-brand {
animation-name: onboarding-welcome-brand-lift-compact;
}
.onboarding-welcome-panel-inner {
grid-template-columns: minmax(0, 1fr) auto;
gap: 1rem;
min-height: min(9.5rem, 40vh);
}
.onboarding-welcome-copy h1 {
font-size: 1.25rem;
}
.onboarding-welcome-copy p {
display: -webkit-box;
overflow: hidden;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.onboarding-welcome-actions {
min-width: 10rem;
}
.onboarding-welcome-secondary-action > span {
display: none;
}
}
@keyframes onboarding-welcome-brand-lift-compact {
0%,
70% {
top: 50%;
}
100% {
top: 29%;
}
}
</style>

View File

@ -0,0 +1,767 @@
import { defineMessages, type MessageDescriptor } from '@modrinth/ui'
export type OnboardingInteraction = 'manual' | 'navigate' | 'activate' | 'inspect'
export type OnboardingMode = 'main' | 'instance'
export type CreationPath = 'custom'
export type StepDestination = string | 'complete'
export type OnboardingStep = {
id: string
targetId?: string
interaction: OnboardingInteraction
title: MessageDescriptor
description: MessageDescriptor
action: MessageDescriptor
spotlight?: 'control'
expectedPath?: string
next?: StepDestination
closeSettingsAfter?: boolean
nextByCreationPath?: Partial<Record<CreationPath, StepDestination>>
branchByTarget?: Record<
string,
{
creationPath?: CreationPath
next: StepDestination
}
>
}
export const onboardingMessages = defineMessages({
welcomeTitle: {
id: 'app.onboarding.welcome.title',
defaultMessage: 'Everything is ready',
},
welcomeDescription: {
id: 'app.onboarding.welcome.description',
defaultMessage:
'Your instances, content, worlds, and downloads now have one home. Let us take a quick lap before you settle in.',
},
welcomeFooter: {
id: 'app.onboarding.welcome.footer',
defaultMessage: 'Your last next launcher.',
},
start: { id: 'app.onboarding.action.start', defaultMessage: 'Take the tour' },
homeWidgetsTitle: {
id: 'app.onboarding.home-widgets.title',
defaultMessage: 'Your Home, your layout',
},
homeWidgetsDescription: {
id: 'app.onboarding.home-widgets.description',
defaultMessage:
'Information Home is built from widgets for recent activity, playtime, instances, worlds, and servers. The grid reflows as the window or account sidebar changes.',
},
homeCustomizeTitle: {
id: 'app.onboarding.home-customize.title',
defaultMessage: 'Arrange it your way',
},
homeCustomizeDescription: {
id: 'app.onboarding.home-customize.description',
defaultMessage:
'Use the bottom-right edit control to add, resize, and configure widgets. While editing, switch between an automatically packed grid and a free grid that preserves empty cells.',
},
discoverTitle: { id: 'app.onboarding.discover.title', defaultMessage: 'Find something new' },
discoverDescription: {
id: 'app.onboarding.discover.description',
defaultMessage:
'Modpacks, mods, plugins, resource packs, shaders: the good kind of rabbit hole.',
},
clickDiscover: {
id: 'app.onboarding.action.click-discover',
defaultMessage: 'Click Discover content to continue',
},
browseTitle: { id: 'app.onboarding.browse.title', defaultMessage: 'Search with receipts' },
browseDescription: {
id: 'app.onboarding.browse.description',
defaultMessage:
'Use types, search, and filters to narrow things down. Project pages keep versions, changelogs, galleries, and install options in one place.',
},
favoritesTitle: {
id: 'app.onboarding.favorites.title',
defaultMessage: 'Keep a short list',
},
favoritesDescription: {
id: 'app.onboarding.favorites.description',
defaultMessage:
'Open Favorites to revisit saved mods, resource packs, data packs, and shaders. The launcher refreshes their project details, then you can choose an instance and add mixed sources to the same install cart.',
},
clickFavorites: {
id: 'app.onboarding.action.click-favorites',
defaultMessage: 'Click Favorites to continue',
},
homeLayoutTitle: {
id: 'app.onboarding.home-layout.title',
defaultMessage: 'Change the amount of detail',
},
homeLayoutDescription: {
id: 'app.onboarding.home-layout.description',
defaultMessage:
'Use the bottom-right control to switch between Information Home and Minimal Home. Widget editing stays with Information Home.',
},
continueArea: {
id: 'app.onboarding.action.continue-area',
defaultMessage: 'Click anywhere for the next bit',
},
skinsTitle: { id: 'app.onboarding.skins.title', defaultMessage: 'A new look, maybe' },
skinsDescription: {
id: 'app.onboarding.skins.description',
defaultMessage:
'Keep your Minecraft skins together. Signing in can wait until you feel like it.',
},
clickSkins: {
id: 'app.onboarding.action.click-skins',
defaultMessage: 'Click Skin selector to continue',
},
skinsPageTitle: { id: 'app.onboarding.skins-page.title', defaultMessage: 'Your skin drawer' },
skinsPageDescription: {
id: 'app.onboarding.skins-page.description',
defaultMessage: 'Add, preview, sort, and apply skins here. No pressure to sign in just yet.',
},
accountTitle: {
id: 'app.onboarding.account.title',
defaultMessage: 'Accounts, on your schedule',
},
accountDescription: {
id: 'app.onboarding.account.description',
defaultMessage:
'When you are ready, sign in, switch accounts, or open your profile here. No deadline.',
},
downloadsTitle: { id: 'app.onboarding.downloads.title', defaultMessage: 'Download control room' },
downloadsDescription: {
id: 'app.onboarding.downloads.description',
defaultMessage:
'Installations and content downloads report in here, so nothing has to disappear mysteriously.',
},
clickDownloads: {
id: 'app.onboarding.action.click-downloads',
defaultMessage: 'Click Downloads to continue',
},
downloadsPageTitle: {
id: 'app.onboarding.downloads-page.title',
defaultMessage: 'Nothing gets lost',
},
downloadsPageDescription: {
id: 'app.onboarding.downloads-page.description',
defaultMessage:
'Active work, history, errors, retries, cancellations, and diagnostics all leave a paper trail here.',
},
settingsTitle: { id: 'app.onboarding.settings.title', defaultMessage: 'Make it yours' },
settingsDescription: {
id: 'app.onboarding.settings.description',
defaultMessage:
'The useful controls live here: launcher preferences, game launch behavior, content downloads, and privacy.',
},
clickSettings: {
id: 'app.onboarding.action.click-settings',
defaultMessage: 'Click Settings to continue',
},
appearanceTitle: { id: 'app.onboarding.appearance.title', defaultMessage: 'Set the vibe' },
appearanceDescription: {
id: 'app.onboarding.appearance.description',
defaultMessage:
'Theme, accent, backgrounds, and window effects all live here. Make the launcher feel familiar.',
},
languageTitle: { id: 'app.onboarding.language.title', defaultMessage: 'Speak your language' },
languageDescription: {
id: 'app.onboarding.language.description',
defaultMessage: 'Pick the launcher language and manage translations. No decoder ring required.',
},
translationTitle: {
id: 'app.onboarding.translation.title',
defaultMessage: 'Translation, the Axolotl way',
},
translationDescription: {
id: 'app.onboarding.translation.description',
defaultMessage:
'Translate Modrinth project titles, summaries, and descriptions while you browse. Keep the original, show both, or make the translation the main character.',
},
aiTitle: {
id: 'app.onboarding.ai.title',
defaultMessage: 'Bring your own AI provider',
},
aiDescription: {
id: 'app.onboarding.ai.description',
defaultMessage:
'Connect text-model providers once, choose the models you want available, or switch every AI feature off in one place.',
},
javaTitle: { id: 'app.onboarding.java.title', defaultMessage: 'Java, under the hood' },
javaDescription: {
id: 'app.onboarding.java.description',
defaultMessage:
'The Java runtimes that start Minecraft live here. Technical, but well-behaved.',
},
defaultsTitle: { id: 'app.onboarding.defaults.title', defaultMessage: 'Start ahead' },
defaultsDescription: {
id: 'app.onboarding.defaults.description',
defaultMessage:
'New instances inherit these choices, so you do not have to repeat the homework.',
},
resourcesTitle: {
id: 'app.onboarding.resources.title',
defaultMessage: 'Do not cook the computer',
},
resourcesDescription: {
id: 'app.onboarding.resources.description',
defaultMessage:
'Choose how content downloads and installs, from download sources to safety checks.',
},
privacyTitle: {
id: 'app.onboarding.privacy.title',
defaultMessage: 'Your data, your call',
},
privacyDescription: {
id: 'app.onboarding.privacy.description',
defaultMessage:
'Manage anonymous telemetry, Discord Rich Presence, and the Minecraft log analysis service whenever you need to.',
},
updatesTitle: { id: 'app.onboarding.updates.title', defaultMessage: 'Stay in the loop' },
updatesDescription: {
id: 'app.onboarding.updates.description',
defaultMessage: 'Choose when Axolotl checks for updates and whether it installs them for you.',
},
clickTab: { id: 'app.onboarding.action.click-tab', defaultMessage: 'Click this tab to continue' },
libraryTitle: { id: 'app.onboarding.library.title', defaultMessage: 'Your launch shelf' },
libraryDescription: {
id: 'app.onboarding.library.description',
defaultMessage:
'Instances keep versions, loaders, content, and saves separate. No accidental mod soup.',
},
clickLibrary: {
id: 'app.onboarding.action.click-library',
defaultMessage: 'Click Library to continue',
},
libraryPageTitle: {
id: 'app.onboarding.library-page.title',
defaultMessage: 'Everything, in its place',
},
libraryPageDescription: {
id: 'app.onboarding.library-page.description',
defaultMessage:
'Filter by modpack, server, or custom setup, then open any instance to manage it.',
},
createTitle: { id: 'app.onboarding.create.title', defaultMessage: 'Make a fresh start' },
createDescription: {
id: 'app.onboarding.create.description',
defaultMessage: 'Start from scratch or bring in an existing instance or modpack. Your call.',
},
clickCreate: {
id: 'app.onboarding.action.click-create',
defaultMessage: 'Click Create new instance to continue',
},
creationTitle: { id: 'app.onboarding.creation.title', defaultMessage: 'Pick your route' },
creationDescription: {
id: 'app.onboarding.creation.description',
defaultMessage:
'Start fresh with a custom setup, or import an existing instance or modpack. Your call.',
},
clickCreationMethod: {
id: 'app.onboarding.action.click-creation-method',
defaultMessage: 'Choose a route to continue',
},
creationNameTitle: {
id: 'app.onboarding.creation-name.title',
defaultMessage: 'Give it a memorable name',
},
creationNameDescription: {
id: 'app.onboarding.creation-name.description',
defaultMessage: 'Pick a name your future self will recognize at a glance.',
},
creationLoaderTitle: {
id: 'app.onboarding.creation-loader.title',
defaultMessage: 'Choose the engine',
},
creationLoaderDescription: {
id: 'app.onboarding.creation-loader.description',
defaultMessage: 'Vanilla, Fabric, Forge, NeoForge, and friends. Pick what your content needs.',
},
creationVersionTitle: {
id: 'app.onboarding.creation-version.title',
defaultMessage: 'Set the game version',
},
creationVersionDescription: {
id: 'app.onboarding.creation-version.description',
defaultMessage:
'Choose the Minecraft version for this instance. Compatibility likes specifics.',
},
creationConfirmTitle: {
id: 'app.onboarding.creation-confirm.title',
defaultMessage: 'One last look',
},
creationConfirmDescription: {
id: 'app.onboarding.creation-confirm.description',
defaultMessage:
'This creates the instance with your choices. I keep a strict hands-off policy.',
},
finishArea: {
id: 'app.onboarding.action.finish-area',
defaultMessage: 'Click anywhere and you are all set',
},
instanceActionsTitle: {
id: 'app.onboarding.instance-actions.title',
defaultMessage: 'The main controls',
},
instanceActionsDescription: {
id: 'app.onboarding.instance-actions.description',
defaultMessage:
'Launch, stop, repair, configure, export, or open the instance from its header.',
},
instanceTabsTitle: {
id: 'app.onboarding.instance-tabs.title',
defaultMessage: 'The rest of the workshop',
},
instanceTabsDescription: {
id: 'app.onboarding.instance-tabs.description',
defaultMessage: 'Use these tabs for content, files, screenshots, worlds, and logs. Tidy chaos.',
},
labTitle: {
id: 'app.onboarding.lab.title',
defaultMessage: 'Useful tools, built in',
},
labDescription: {
id: 'app.onboarding.lab.description',
defaultMessage:
'The Lab keeps local Minecraft tools inside the launcher, without another website or account.',
},
clickLab: {
id: 'app.onboarding.action.click-lab',
defaultMessage: 'Click Lab to continue',
},
labToolsTitle: {
id: 'app.onboarding.lab-tools.title',
defaultMessage: 'Local tools for Minecraft',
},
labToolsDescription: {
id: 'app.onboarding.lab-tools.description',
defaultMessage:
'Create formatted text and recipe data packs, explore Java worlds, and inspect schematic builds without leaving the launcher.',
},
openGradientText: {
id: 'app.onboarding.action.open-gradient-text',
defaultMessage: 'Open Gradient text generator to continue',
},
labEditorTitle: {
id: 'app.onboarding.lab-editor.title',
defaultMessage: 'Build and copy in one place',
},
labEditorDescription: {
id: 'app.onboarding.lab-editor.description',
defaultMessage:
'Edit text, choose colors, preview the result, and copy the format your Minecraft setup expects.',
},
labSeedMapTitle: {
id: 'app.onboarding.lab-seed-map.title',
defaultMessage: 'Find a world before you load it',
},
labSeedMapDescription: {
id: 'app.onboarding.lab-seed-map.description',
defaultMessage:
'Enter a seed or load one from an instance, then inspect biomes, structures, and ore layers on the local map.',
},
returnToLab: {
id: 'app.onboarding.action.return-lab',
defaultMessage: 'Click Lab to continue',
},
openSeedMap: {
id: 'app.onboarding.action.open-seed-map',
defaultMessage: 'Open Seed map to continue',
},
openSchematicWorkshop: {
id: 'app.onboarding.action.open-schematic-workshop',
defaultMessage: 'Open Schematic workshop to continue',
},
openRecipeGenerator: {
id: 'app.onboarding.action.open-recipe-generator',
defaultMessage: 'Open Recipe generator to continue',
},
labRecipeGeneratorTitle: {
id: 'app.onboarding.lab-recipe-generator.title',
defaultMessage: 'Craft data pack recipes',
},
labRecipeGeneratorDescription: {
id: 'app.onboarding.lab-recipe-generator.description',
defaultMessage:
'Pick a Java version, fill the recipe slots, and copy or export the JSON locally.',
},
labSchematicTitle: {
id: 'app.onboarding.lab-schematic.title',
defaultMessage: 'Inspect a build before placing it',
},
labSchematicDescription: {
id: 'app.onboarding.lab-schematic.description',
defaultMessage:
'Open a local .litematic or .schem file, or choose one from an installed instance. The 3D workspace keeps viewing, measurement, layer controls, materials, and local edits together.',
},
skip: { id: 'app.onboarding.action.skip', defaultMessage: 'Leave the tour' },
mascotAlt: { id: 'app.onboarding.mascot-alt', defaultMessage: 'Axolotl guide' },
})
const step = (
id: string,
interaction: OnboardingInteraction,
copy: {
title: MessageDescriptor
description: MessageDescriptor
action: MessageDescriptor
},
options: Omit<OnboardingStep, 'id' | 'interaction' | 'title' | 'description' | 'action'> = {},
): OnboardingStep => ({ id, interaction, ...copy, ...options })
const copy = (
title: MessageDescriptor,
description: MessageDescriptor,
action: MessageDescriptor,
) => ({ title, description, action })
const control = (targetId: string, expectedPath?: string) => ({
targetId,
spotlight: 'control' as const,
...(expectedPath ? { expectedPath } : {}),
})
const inspect = (
id: string,
targetId: string,
title: MessageDescriptor,
description: MessageDescriptor,
) => step(id, 'inspect', copy(title, description, onboardingMessages.continueArea), { targetId })
const settingsTourSteps: Array<[string, string, MessageDescriptor, MessageDescriptor]> = [
[
'settings-interface',
'settings-tab-interface',
onboardingMessages.appearanceTitle,
onboardingMessages.appearanceDescription,
],
[
'settings-launch-defaults',
'settings-tab-launch-defaults',
onboardingMessages.defaultsTitle,
onboardingMessages.defaultsDescription,
],
[
'settings-content-downloads',
'settings-tab-content-downloads',
onboardingMessages.resourcesTitle,
onboardingMessages.resourcesDescription,
],
]
export const onboardingTours: Record<OnboardingMode, OnboardingStep[]> = {
main: [
step(
'welcome',
'manual',
copy(
onboardingMessages.welcomeTitle,
onboardingMessages.welcomeDescription,
onboardingMessages.start,
),
),
inspect(
'home-widget-grid',
'home-widget-grid',
onboardingMessages.homeWidgetsTitle,
onboardingMessages.homeWidgetsDescription,
),
inspect(
'home-widget-customize',
'home-widget-customize',
onboardingMessages.homeCustomizeTitle,
onboardingMessages.homeCustomizeDescription,
),
inspect(
'home-layout-switch',
'home-layout-switch',
onboardingMessages.homeLayoutTitle,
onboardingMessages.homeLayoutDescription,
),
step(
'discover-navigation',
'navigate',
copy(
onboardingMessages.discoverTitle,
onboardingMessages.discoverDescription,
onboardingMessages.clickDiscover,
),
control('nav-discover', '/browse/modpack'),
),
inspect(
'discover-content',
'browse-content',
onboardingMessages.browseTitle,
onboardingMessages.browseDescription,
),
step(
'discover-favorites-navigation',
'navigate',
copy(
onboardingMessages.favoritesTitle,
onboardingMessages.favoritesDescription,
onboardingMessages.clickFavorites,
),
control('browse-favorites-tab', '/browse/favorites'),
),
inspect(
'discover-favorites-content',
'browse-favorites-content',
onboardingMessages.favoritesTitle,
onboardingMessages.favoritesDescription,
),
step(
'skins-navigation',
'navigate',
copy(
onboardingMessages.skinsTitle,
onboardingMessages.skinsDescription,
onboardingMessages.clickSkins,
),
control('nav-skins', '/skins'),
),
inspect(
'skins-page',
'skins-page',
onboardingMessages.skinsPageTitle,
onboardingMessages.skinsPageDescription,
),
step(
'account',
'inspect',
copy(
onboardingMessages.accountTitle,
onboardingMessages.accountDescription,
onboardingMessages.continueArea,
),
control('account-entry'),
),
step(
'lab-navigation',
'navigate',
copy(
onboardingMessages.labTitle,
onboardingMessages.labDescription,
onboardingMessages.clickLab,
),
control('nav-lab', '/lab'),
),
inspect(
'lab-tools',
'lab-tools',
onboardingMessages.labToolsTitle,
onboardingMessages.labToolsDescription,
),
step(
'lab-gradient-text-navigation',
'navigate',
copy(
onboardingMessages.labEditorTitle,
onboardingMessages.labEditorDescription,
onboardingMessages.openGradientText,
),
control('lab-gradient-text-card', '/lab/gradient-text'),
),
inspect(
'lab-gradient-text-editor',
'lab-gradient-text-editor',
onboardingMessages.labEditorTitle,
onboardingMessages.labEditorDescription,
),
step(
'lab-return-navigation',
'navigate',
copy(
onboardingMessages.labSeedMapTitle,
onboardingMessages.labSeedMapDescription,
onboardingMessages.returnToLab,
),
control('nav-lab', '/lab'),
),
step(
'lab-seed-map-navigation',
'navigate',
copy(
onboardingMessages.labSeedMapTitle,
onboardingMessages.labSeedMapDescription,
onboardingMessages.openSeedMap,
),
control('lab-seed-map-card', '/lab/seed-map'),
),
inspect(
'lab-seed-map-workspace',
'seed-map-workspace',
onboardingMessages.labSeedMapTitle,
onboardingMessages.labSeedMapDescription,
),
step(
'lab-return-schematic-navigation',
'navigate',
copy(
onboardingMessages.labSchematicTitle,
onboardingMessages.labSchematicDescription,
onboardingMessages.returnToLab,
),
control('nav-lab', '/lab'),
),
step(
'lab-schematic-navigation',
'navigate',
copy(
onboardingMessages.labSchematicTitle,
onboardingMessages.labSchematicDescription,
onboardingMessages.openSchematicWorkshop,
),
control('lab-schematic-preview-card', '/lab/schematic-preview'),
),
inspect(
'lab-schematic-workspace',
'schematic-preview-workspace',
onboardingMessages.labSchematicTitle,
onboardingMessages.labSchematicDescription,
),
step(
'lab-recipe-generator-navigation',
'navigate',
copy(
onboardingMessages.labRecipeGeneratorTitle,
onboardingMessages.labRecipeGeneratorDescription,
onboardingMessages.openRecipeGenerator,
),
control('lab-recipe-generator-card', '/lab/recipe-generator'),
),
inspect(
'lab-recipe-generator-workspace',
'recipe-generator-workspace',
onboardingMessages.labRecipeGeneratorTitle,
onboardingMessages.labRecipeGeneratorDescription,
),
step(
'downloads-navigation',
'navigate',
copy(
onboardingMessages.downloadsTitle,
onboardingMessages.downloadsDescription,
onboardingMessages.clickDownloads,
),
control('nav-downloads', '/downloads'),
),
inspect(
'downloads-tabs',
'downloads-tabs',
onboardingMessages.downloadsPageTitle,
onboardingMessages.downloadsPageDescription,
),
step(
'settings-navigation',
'activate',
copy(
onboardingMessages.settingsTitle,
onboardingMessages.settingsDescription,
onboardingMessages.clickSettings,
),
control('nav-settings', '/settings'),
),
...settingsTourSteps.map(([id, targetId, title, description], index) =>
step(id, 'activate', copy(title, description, onboardingMessages.clickTab), {
...control(targetId),
closeSettingsAfter: index === settingsTourSteps.length - 1,
}),
),
step(
'library-navigation',
'navigate',
copy(
onboardingMessages.libraryTitle,
onboardingMessages.libraryDescription,
onboardingMessages.clickLibrary,
),
control('nav-library', '/library'),
),
inspect(
'library-content',
'library-content',
onboardingMessages.libraryPageTitle,
onboardingMessages.libraryPageDescription,
),
step(
'create-instance',
'navigate',
copy(
onboardingMessages.createTitle,
onboardingMessages.createDescription,
onboardingMessages.clickCreate,
),
control('create-instance', '/create'),
),
step(
'creation-flow',
'activate',
copy(
onboardingMessages.creationTitle,
onboardingMessages.creationDescription,
onboardingMessages.clickCreationMethod,
),
{
targetId: 'creation-methods',
branchByTarget: {
'creation-method-custom': { creationPath: 'custom', next: 'creation-name' },
'creation-method-import': { next: 'complete' },
},
},
),
inspect(
'creation-name',
'creation-name',
onboardingMessages.creationNameTitle,
onboardingMessages.creationNameDescription,
),
inspect(
'creation-loader',
'creation-loader',
onboardingMessages.creationLoaderTitle,
onboardingMessages.creationLoaderDescription,
),
step(
'creation-version',
'inspect',
copy(
onboardingMessages.creationVersionTitle,
onboardingMessages.creationVersionDescription,
onboardingMessages.continueArea,
),
{
targetId: 'creation-game-version',
nextByCreationPath: { custom: 'creation-confirm' },
},
),
step(
'creation-confirm',
'inspect',
copy(
onboardingMessages.creationConfirmTitle,
onboardingMessages.creationConfirmDescription,
onboardingMessages.finishArea,
),
{ targetId: 'creation-confirm' },
),
],
instance: [
inspect(
'instance-actions',
'instance-actions',
onboardingMessages.instanceActionsTitle,
onboardingMessages.instanceActionsDescription,
),
step(
'instance-tabs',
'inspect',
copy(
onboardingMessages.instanceTabsTitle,
onboardingMessages.instanceTabsDescription,
onboardingMessages.finishArea,
),
{ targetId: 'instance-tabs' },
),
],
}
export function onboardingTargetSelector(targetId: string) {
return `[data-onboarding-id="${targetId}"]`
}

View File

@ -0,0 +1,372 @@
import { computed, nextTick, onBeforeUnmount, onMounted, type Ref, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import {
type CreationPath,
type OnboardingMode,
onboardingTargetSelector,
onboardingTours,
type StepDestination,
} from './onboardingConfig'
type TourEvents = {
complete: () => void
skip: () => void
closeSettings: () => void
}
const controlSpotlightPadding = 3
const missingTargetRetryLimit = 8
const missingTargetRetryDelay = 250
export function useOnboardingTour(
visible: Ref<boolean>,
mode: Ref<OnboardingMode>,
events: TourEvents,
) {
const route = useRoute()
const stepIndex = ref(0)
const targetRect = ref<DOMRect | null>(null)
const bubbleElement = ref<HTMLElement>()
const bubbleSize = ref({ width: 512, height: 160 })
const waitingForRoute = ref(false)
const creationPath = ref<CreationPath>()
const targetElement = ref<HTMLElement>()
let targetObserver: ResizeObserver | undefined
let bubbleObserver: ResizeObserver | undefined
let targetRetryTimer: ReturnType<typeof setTimeout> | undefined
let advanceTimer: ReturnType<typeof setTimeout> | undefined
let unlockTimer: ReturnType<typeof setTimeout> | undefined
let targetRetryCount = 0
let transitionLocked = false
const steps = computed(() => onboardingTours[mode.value])
const step = computed(() => steps.value[stepIndex.value])
const isWelcomeStep = computed(() => step.value.id === 'welcome')
const isDialogueStep = computed(
() => !!step.value.targetId && (step.value.spotlight !== 'control' || !targetRect.value),
)
const controlSpotlightStyle = computed(() => {
if (!targetRect.value) return {}
const rect = targetRect.value
return {
left: `${Math.max(0, rect.left - controlSpotlightPadding)}px`,
top: `${Math.max(0, rect.top - controlSpotlightPadding)}px`,
width: `${rect.width + controlSpotlightPadding * 2}px`,
height: `${rect.height + controlSpotlightPadding * 2}px`,
}
})
const bubblePlacement = computed(() => {
if (!targetRect.value || isDialogueStep.value) return { direction: 'center', style: {} }
const rect = targetRect.value
const safeInset = 16
const safeTop = 48
const bubbleWidth = Math.min(bubbleSize.value.width, window.innerWidth - safeInset * 2)
const bubbleHeight = Math.min(bubbleSize.value.height, window.innerHeight - safeTop - safeInset)
const gap = 20
const positions = [
{
direction: 'right',
left: rect.right + gap,
top: rect.top + rect.height / 2 - bubbleHeight / 2,
},
{
direction: 'bottom',
left: rect.left + rect.width / 2 - bubbleWidth / 2,
top: rect.bottom + gap,
},
{
direction: 'left',
left: rect.left - bubbleWidth - gap,
top: rect.top + rect.height / 2 - bubbleHeight / 2,
},
{
direction: 'top',
left: rect.left + rect.width / 2 - bubbleWidth / 2,
top: rect.top - bubbleHeight - gap,
},
]
const position =
positions.find(
(candidate) =>
candidate.left >= safeInset &&
candidate.top >= safeTop &&
candidate.left + bubbleWidth <= window.innerWidth - safeInset &&
candidate.top + bubbleHeight <= window.innerHeight - safeInset,
) ?? positions[0]
return {
direction: position.direction,
style: {
left: `${Math.min(
Math.max(safeInset, position.left),
window.innerWidth - bubbleWidth - safeInset,
)}px`,
top: `${Math.min(
Math.max(safeTop, position.top),
window.innerHeight - bubbleHeight - safeInset,
)}px`,
},
}
})
function clearTargetTracking() {
targetObserver?.disconnect()
targetObserver = undefined
if (targetRetryTimer) clearTimeout(targetRetryTimer)
targetRetryTimer = undefined
}
function clearModalReservation() {
document.body.classList.remove('onboarding-reserve-dialogue-space')
document.body.style.removeProperty('--onboarding-dialogue-reserved-space')
}
function updateModalReservation() {
const targetIsInModal = !!targetElement.value?.closest('[role="dialog"]')
if (!visible.value || !isDialogueStep.value || !targetIsInModal) {
clearModalReservation()
return
}
document.body.classList.add('onboarding-reserve-dialogue-space')
document.body.style.setProperty(
'--onboarding-dialogue-reserved-space',
`${Math.ceil(bubbleSize.value.height)}px`,
)
}
function updateBubbleSize() {
if (!bubbleElement.value) return
const { width, height } = bubbleElement.value.getBoundingClientRect()
bubbleSize.value = { width, height }
updateModalReservation()
}
function scheduleMissingTargetRetry(stepId: string) {
if (targetRetryCount >= missingTargetRetryLimit) {
void advance()
return
}
targetRetryCount++
targetRetryTimer = setTimeout(() => {
if (visible.value && step.value.id === stepId && !targetRect.value) updateTarget()
}, missingTargetRetryDelay)
}
function updateTarget() {
clearTargetTracking()
if (!visible.value || !step.value.targetId) {
targetElement.value = undefined
targetRect.value = null
clearModalReservation()
return
}
const target = document.querySelector<HTMLElement>(
onboardingTargetSelector(step.value.targetId),
)
const rect = target?.getBoundingClientRect()
if (!target || !rect || rect.width < 1 || rect.height < 1) {
targetElement.value = undefined
targetRect.value = null
clearModalReservation()
scheduleMissingTargetRetry(step.value.id)
return
}
targetRetryCount = 0
targetElement.value = target
const updateRect = () => {
targetRect.value = target.getBoundingClientRect()
}
updateRect()
targetObserver = new ResizeObserver(updateRect)
targetObserver.observe(target)
updateModalReservation()
requestAnimationFrame(updateRect)
}
function goTo(destination: StepDestination) {
if (destination === 'complete') {
events.complete()
return
}
const destinationIndex = steps.value.findIndex((candidate) => candidate.id === destination)
if (destinationIndex === -1) {
events.complete()
return
}
stepIndex.value = destinationIndex
}
async function advance() {
if (transitionLocked) return
transitionLocked = true
const destination = creationPath.value
? step.value.nextByCreationPath?.[creationPath.value]
: step.value.next
if (destination) {
goTo(destination)
scheduleUnlock()
return
}
if (stepIndex.value === steps.value.length - 1) {
events.complete()
scheduleUnlock()
return
}
if (step.value.closeSettingsAfter) events.closeSettings()
stepIndex.value++
await nextTick()
updateTarget()
scheduleUnlock()
}
function scheduleUnlock() {
if (unlockTimer) clearTimeout(unlockTimer)
unlockTimer = setTimeout(() => {
transitionLocked = false
unlockTimer = undefined
}, 180)
}
function scheduleDestination(destination?: StepDestination) {
if (advanceTimer || transitionLocked) return
if (destination) transitionLocked = true
advanceTimer = setTimeout(() => {
advanceTimer = undefined
if (destination) {
goTo(destination)
scheduleUnlock()
} else {
void advance()
}
}, 150)
}
function handleManualClick() {
if (step.value.interaction === 'manual') void advance()
}
function handleBranchClick(target: Element) {
if (!step.value.branchByTarget) return false
for (const [targetId, branch] of Object.entries(step.value.branchByTarget)) {
if (!target.closest(onboardingTargetSelector(targetId))) continue
creationPath.value = branch.creationPath
scheduleDestination(branch.next)
return true
}
return false
}
function handleDocumentClick(event: MouseEvent) {
if (!visible.value) return
const clickedElement = event.target instanceof Element ? event.target : null
if (clickedElement?.closest('[data-onboarding-overlay-ui]')) return
if (step.value.interaction === 'inspect') {
event.preventDefault()
event.stopImmediatePropagation()
void advance()
return
}
if (!step.value.targetId || !['navigate', 'activate'].includes(step.value.interaction)) return
if (clickedElement && handleBranchClick(clickedElement)) return
if (!targetElement.value?.contains(event.target as Node)) return
if (step.value.expectedPath && route.path !== step.value.expectedPath) {
waitingForRoute.value = true
return
}
scheduleDestination()
}
function handleKeydown(event: KeyboardEvent) {
if (!visible.value || event.key !== 'Escape') return
event.preventDefault()
events.skip()
}
watch(
() => route.path,
(path) => {
if (!waitingForRoute.value || !step.value.expectedPath) return
if (path !== step.value.expectedPath) return
waitingForRoute.value = false
void advance()
},
)
watch(visible, async (isVisible) => {
if (isVisible) {
stepIndex.value = 0
waitingForRoute.value = false
creationPath.value = undefined
targetRetryCount = 0
await nextTick()
updateBubbleSize()
if (bubbleElement.value) bubbleObserver?.observe(bubbleElement.value)
} else {
bubbleObserver?.disconnect()
clearModalReservation()
}
updateTarget()
})
watch(step, async () => {
if (!visible.value) return
targetRetryCount = 0
await nextTick()
updateBubbleSize()
updateTarget()
})
watch(mode, () => {
stepIndex.value = 0
})
onMounted(() => {
document.addEventListener('click', handleDocumentClick, true)
document.addEventListener('keydown', handleKeydown)
window.addEventListener('resize', updateTarget)
window.addEventListener('scroll', updateTarget, true)
bubbleObserver = new ResizeObserver(updateBubbleSize)
if (bubbleElement.value) bubbleObserver.observe(bubbleElement.value)
})
onBeforeUnmount(() => {
clearTargetTracking()
clearModalReservation()
bubbleObserver?.disconnect()
if (advanceTimer) clearTimeout(advanceTimer)
if (unlockTimer) clearTimeout(unlockTimer)
document.removeEventListener('click', handleDocumentClick, true)
document.removeEventListener('keydown', handleKeydown)
window.removeEventListener('resize', updateTarget)
window.removeEventListener('scroll', updateTarget, true)
})
return {
advance,
bubbleElement,
bubblePlacement,
controlSpotlightStyle,
handleManualClick,
isDialogueStep,
isWelcomeStep,
step,
stepIndex,
steps,
targetRect,
}
}

View File

@ -0,0 +1,452 @@
<script setup lang="ts">
import { type Component, computed, useId } from 'vue'
import {
lobeModelAvatarBrands,
lobeModelIconMappings,
openAIModelBackgrounds,
} from '@/data/lobeModelIcons'
import { lobeProviderIcons } from '@/data/lobeProviderIcons'
import CodeFlowLogo from './CodeFlowLogo.vue'
import LobeBrandCombine from './LobeBrandCombine.vue'
const props = withDefaults(
defineProps<{
kind: 'model' | 'provider-avatar' | 'provider-combine' | 'provider-wordmark'
value: string
size?: number
}>(),
{ size: 24 },
)
const componentModules = import.meta.glob(
'../../../../node_modules/@lobehub/icons-static-svg/icons/*.svg',
{
eager: true,
import: 'default',
query: '?component',
},
) as Record<string, Component>
const iconComponents = Object.fromEntries(
Object.entries(componentModules).map(([path, component]) => [
path.split('/').pop()?.replace('.svg', ''),
component,
]),
) as Record<string, Component>
const soraGradientId = useId()
const providerConfig = computed(() => lobeProviderIcons[props.value.toLocaleLowerCase()])
const isCodeFlow = computed(() => props.value.toLocaleLowerCase() === 'codeflow')
const avatarConfig = computed(() => providerConfig.value?.avatar)
const avatarComponent = computed(() => {
if (!providerConfig.value || !avatarConfig.value) return undefined
const suffix = avatarConfig.value.asset === 'color' ? '-color' : ''
return (
iconComponents[`${providerConfig.value.slug}${suffix}`] ??
iconComponents[providerConfig.value.slug]
)
})
const combineSize = computed(() => props.size * (providerConfig.value?.combine.multiple ?? 1))
const combineBrand = computed(
() => providerConfig.value?.combine.brand ?? providerConfig.value?.slug ?? props.value,
)
const specialAsset = computed(() => {
switch (providerConfig.value?.combine.kind) {
case 'google':
return iconComponents['google-brand-color']
case 'v0':
return iconComponents.v0
default:
return undefined
}
})
const modelMapping = computed(() => {
const model = props.value.toLocaleLowerCase()
return lobeModelIconMappings.find(({ keywords }) =>
keywords.some((keyword) => new RegExp(keyword, 'i').test(model)),
)
})
const modelAvatar = computed(() =>
modelMapping.value ? lobeModelAvatarBrands[modelMapping.value.slug] : undefined,
)
const modelBackground = computed(() => {
const openAIType = modelMapping.value?.openAIType
return openAIType ? openAIModelBackgrounds[openAIType] : modelAvatar.value?.background
})
const modelComponent = computed(() => {
if (!modelMapping.value || !modelAvatar.value) return undefined
if (
['aihubmix', 'dalle', 'lg', 'nanobanana', 'sora', 'stepfun'].includes(modelMapping.value.slug)
) {
return undefined
}
const suffix = modelAvatar.value.asset === 'color' ? '-color' : ''
return (
iconComponents[`${modelMapping.value.slug}${suffix}`] ?? iconComponents[modelMapping.value.slug]
)
})
const modelAvatarStyle = computed(() => ({
background: modelBackground.value ?? 'var(--color-button-bg)',
color: modelAvatar.value?.color ?? 'var(--color-secondary)',
height: `${props.size}px`,
width: `${props.size}px`,
}))
const avatarStyle = computed(() => ({
background: avatarConfig.value?.background,
borderRadius: `${Math.floor(props.size * 0.1)}px`,
color: avatarConfig.value?.color ?? (isCodeFlow.value ? 'var(--color-contrast)' : undefined),
height: `${props.size}px`,
width: `${props.size}px`,
}))
</script>
<template>
<span
v-if="kind === 'provider-avatar'"
class="lobe-provider-avatar inline-flex flex-none items-center justify-center overflow-hidden"
:class="{
'black-background': avatarConfig?.background === '#000',
'white-background': avatarConfig?.background === '#fff',
}"
:style="avatarStyle"
aria-hidden="true"
>
<svg
v-if="avatarConfig?.variant === 'ai302'"
fill="currentColor"
fill-rule="evenodd"
viewBox="0 0 24 24"
:style="{ transform: `scale(${avatarConfig.multiple})` }"
>
<path
d="M11.88 21.5a4.49 4.49 0 01-2.772-.959 4.516 4.516 0 01-1.71-3.024 4.513 4.513 0 01-.007-1.086 4.46 4.46 0 01-.859.078A4.537 4.537 0 012 11.975c0-2.5 2.036-4.54 4.532-4.54.356 0 .7.041 1.034.12A4.543 4.543 0 0112.07 2.5c2.497 0 4.525 2.04 4.525 4.54 0 .145-.005.286-.02.43a4.596 4.596 0 011.125-.056 4.542 4.542 0 014.18 4.864 4.507 4.507 0 01-1.562 3.103 4.484 4.484 0 01-3.287 1.085 4.54 4.54 0 01-.647-.091c0 .01.007.019.007.028a4.522 4.522 0 01-.922 3.349 4.496 4.496 0 01-3.019 1.713 4.53 4.53 0 01-.57.035zm-2.512-5.993a2.893 2.893 0 00-.366 1.812 2.906 2.906 0 003.244 2.538 2.899 2.899 0 001.943-1.1 2.89 2.89 0 00.59-2.15 2.905 2.905 0 00-.562-1.396 4.516 4.516 0 01-.542-.641.807.807 0 01.19-1.128.805.805 0 011.126.19c.061.085.122.163.19.24a.846.846 0 01.155.14c.028.034.05.067.077.1.474.429 1.08.692 1.731.74 1.605.12 3-1.09 3.118-2.693a2.913 2.913 0 00-2.681-3.124 2.884 2.884 0 00-1.739.423.804.804 0 01-.9.085.82.82 0 01-.324-1.107c.234-.425.359-.905.359-1.396a2.92 2.92 0 00-2.914-2.918A2.919 2.919 0 009.15 7.04c0 .576.168 1.13.485 1.608.016.024.03.053.043.077a4.52 4.52 0 011.379 3.25c0 1.426-.66 2.707-1.689 3.54v-.008zm-2.843-6.45a2.914 2.914 0 00-2.906 2.918c0 1.61 1.3 2.92 2.906 2.92a2.92 2.92 0 000-5.838z"
/>
</svg>
<svg
v-else-if="avatarConfig?.variant === 'aihubmix'"
fill="currentColor"
fill-rule="evenodd"
viewBox="0 0 24 24"
:style="{ transform: `scale(${avatarConfig.multiple})` }"
>
<path
clip-rule="evenodd"
d="M10.853 6.285c.141-.972.455-2.221.942-3.747l.205-.63.206.63c.486 1.526.8 2.775.942 3.748.108.713.108 1.62 0 2.72-.109 1.105-.109 2.019 0 2.741a4.218 4.218 0 001.452 2.635 4.224 4.224 0 002.855 1.07c1.2 0 2.224-.423 3.074-1.268.846-.845 1.273-1.865 1.282-3.06.005-1.058.114-2.225.326-3.5.104-.637.21-1.17.319-1.6l.142-.581.255.538A11.88 11.88 0 0124 10.883v.24c0 1.63-.314 3.186-.942 4.669a12.017 12.017 0 01-6.39 6.39 11.848 11.848 0 01-4.668.942c-1.629 0-3.185-.314-4.668-.942a12.016 12.016 0 01-6.39-6.39A11.848 11.848 0 010 11.124v-.241A11.881 11.881 0 011.148 5.98l.255-.538.141.58c.11.43.215.964.32 1.601.212 1.275.32 2.442.325 3.5.01 1.195.437 2.215 1.282 3.06.85.845 1.875 1.268 3.075 1.268a4.225 4.225 0 002.854-1.07 4.218 4.218 0 001.453-2.635c.108-.722.108-1.636 0-2.741-.109-1.1-.109-2.007 0-2.72zM12 20.936a9.651 9.651 0 004.661-1.176 9.643 9.643 0 002.677-2.113c.095-.107-.017-.27-.154-.232a6.574 6.574 0 01-1.73.227 6.402 6.402 0 01-3.293-.893c-.82-.478-1.5-1.099-2.04-1.862a.149.149 0 00-.242 0 6.427 6.427 0 01-2.04 1.862 6.402 6.402 0 01-3.293.893 6.574 6.574 0 01-1.73-.227c-.137-.037-.248.125-.154.232a9.643 9.643 0 002.677 2.113A9.651 9.651 0 0012 20.935z"
/>
</svg>
<svg
v-else-if="avatarConfig?.variant === 'stepfun'"
fill="currentColor"
fill-rule="evenodd"
viewBox="0 0 24 24"
:style="{ transform: `scale(${avatarConfig.multiple})` }"
>
<path
d="M1 23h6.335v-6.337H1V23zM8.832 23h6.336v-6.337H8.832V23zM8.832 15.17h6.336V8.835H8.832v6.337zM8.832 7.342h6.336V1.005H8.832v6.337zM16.665 7.337H23V1h-6.335v6.337z"
/>
</svg>
<component
:is="avatarComponent"
v-else-if="avatarComponent && avatarConfig"
:style="{
height: `${size}px`,
transform: `scale(${avatarConfig.multiple})`,
width: `${size}px`,
}"
/>
<CodeFlowLogo v-else-if="isCodeFlow" :size="size" />
<span v-else>{{ value.slice(0, 1).toLocaleUpperCase() }}</span>
</span>
<span
v-else-if="kind === 'provider-combine'"
class="inline-flex min-w-0 flex-none items-center justify-start text-contrast"
:style="{ gap: `${combineSize / 3}px`, height: `${size * 1.5}px` }"
aria-hidden="true"
>
<template v-if="providerConfig?.combine.kind === 'bedrock'">
<component
:is="iconComponents['aws-color']"
class="special-square"
:style="{ height: `${combineSize * 1.2}px`, width: `${combineSize * 1.2}px` }"
/>
<span class="block h-[1em] w-px flex-none bg-divider" :style="{ margin: `0 ${combineSize / 6}px` }" />
<LobeBrandCombine brand="bedrock" :size="combineSize" />
</template>
<template v-else-if="providerConfig?.combine.kind === 'google'">
<component :is="specialAsset" :style="{ height: `${combineSize * 0.95}px`, width: 'auto' }" />
<span class="block h-[1em] w-px flex-none bg-divider" :style="{ margin: `0 ${combineSize / 6}px` }" />
<LobeBrandCombine brand="gemini" :size="combineSize" />
</template>
<template v-else-if="providerConfig?.combine.kind === 'azure'">
<LobeBrandCombine brand="azure" :size="combineSize * 0.92" />
<span class="block h-[1em] w-px flex-none bg-divider" :style="{ margin: `0 ${combineSize / 6}px` }" />
<LobeBrandCombine brand="openai" :size="combineSize" />
</template>
<template v-else-if="providerConfig?.combine.kind === 'anthropic'">
<component
:is="iconComponents['anthropic-text']"
:style="{ height: `${combineSize * 0.75}px`, width: 'auto' }"
/>
<span class="block h-[1em] w-px flex-none bg-divider" :style="{ margin: `0 ${combineSize / 6}px` }" />
<LobeBrandCombine brand="claude" :size="combineSize" />
</template>
<template v-else-if="providerConfig?.combine.kind === 'qwen'">
<LobeBrandCombine brand="alibabacloud" :size="combineSize" />
<span class="block h-[1em] w-px flex-none bg-divider" :style="{ margin: `0 ${combineSize / 6}px` }" />
<LobeBrandCombine brand="qwen" :size="combineSize * 0.9" />
</template>
<template v-else-if="providerConfig?.combine.kind === 'wenxin'">
<LobeBrandCombine brand="baiducloud" :size="combineSize * 0.9" />
<span class="block h-[1em] w-px flex-none bg-divider" :style="{ margin: `0 ${combineSize / 6}px` }" />
<LobeBrandCombine brand="wenxin" extra="千帆" :size="combineSize" />
</template>
<template v-else-if="providerConfig?.combine.kind === 'cloudflare'">
<LobeBrandCombine brand="cloudflare" :size="combineSize * 1.1" />
<span class="block h-[1em] w-px flex-none bg-divider" :style="{ margin: `0 ${combineSize / 6}px` }" />
<LobeBrandCombine brand="workersai" :size="combineSize * 0.9" />
</template>
<template v-else-if="providerConfig?.combine.kind === 'v0'">
<LobeBrandCombine brand="vercel" :size="combineSize * 0.85" />
<span class="block h-[1em] w-px flex-none bg-divider" :style="{ margin: `0 ${combineSize / 6}px` }" />
<component
:is="specialAsset"
class="special-square"
:style="{ height: `${combineSize * 1.1}px`, width: `${combineSize * 1.1}px` }"
/>
</template>
<LobeBrandCombine
v-else-if="providerConfig?.combine.kind === 'ollamacloud'"
brand="ollama"
:extra-font-size="size * 0.78"
:extra-margin-left="size * 0.2"
extra="Cloud"
:size="size * 1.16"
/>
<CodeFlowLogo v-else-if="isCodeFlow" :size="combineSize" />
<LobeBrandCombine v-else :brand="combineBrand" :size="combineSize" />
</span>
<span
v-else-if="kind === 'provider-wordmark'"
class="lobe-provider-wordmark"
:style="{
color: 'var(--color-contrast)',
gap: `${Math.max(4, Math.round(size * 0.214))}px`,
}"
aria-hidden="true"
>
<CodeFlowLogo v-if="isCodeFlow" :size="size" />
<LobeBrandCombine v-else :brand="combineBrand" :size="combineSize" />
<span
v-if="isCodeFlow"
class="font-semibold"
:style="{ fontSize: `${Math.round(size * 0.536)}px`, letterSpacing: '-0.01em' }"
>CodeFlow</span
>
</span>
<span
v-else
class="model-icon inline-flex flex-none items-center justify-center overflow-hidden rounded-full"
:class="{
'black-background': modelBackground === '#000',
fallback: !modelMapping,
'white-background': modelBackground === '#fff',
}"
:style="modelAvatarStyle"
aria-hidden="true"
>
<svg
v-if="modelMapping?.slug === 'aihubmix'"
fill="currentColor"
fill-rule="evenodd"
viewBox="0 0 24 24"
:style="{ transform: `scale(${modelAvatar?.multiple})` }"
>
<path
clip-rule="evenodd"
d="M10.853 6.285c.141-.972.455-2.221.942-3.747l.205-.63.206.63c.486 1.526.8 2.775.942 3.748.108.713.108 1.62 0 2.72-.109 1.105-.109 2.019 0 2.741a4.218 4.218 0 001.452 2.635 4.224 4.224 0 002.855 1.07c1.2 0 2.224-.423 3.074-1.268.846-.845 1.273-1.865 1.282-3.06.005-1.058.114-2.225.326-3.5.104-.637.21-1.17.319-1.6l.142-.581.255.538A11.88 11.88 0 0124 10.883v.24c0 1.63-.314 3.186-.942 4.669a12.017 12.017 0 01-6.39 6.39 11.848 11.848 0 01-4.668.942c-1.629 0-3.185-.314-4.668-.942a12.016 12.016 0 01-6.39-6.39A11.848 11.848 0 010 11.124v-.241A11.881 11.881 0 011.148 5.98l.255-.538.141.58c.11.43.215.964.32 1.601.212 1.275.32 2.442.325 3.5.01 1.195.437 2.215 1.282 3.06.85.845 1.875 1.268 3.075 1.268a4.225 4.225 0 002.854-1.07 4.218 4.218 0 001.453-2.635c.108-.722.108-1.636 0-2.741-.109-1.1-.109-2.007 0-2.72zM12 20.936a9.651 9.651 0 004.661-1.176 9.643 9.643 0 002.677-2.113c.095-.107-.017-.27-.154-.232a6.574 6.574 0 01-1.73.227 6.402 6.402 0 01-3.293-.893c-.82-.478-1.5-1.099-2.04-1.862a.149.149 0 00-.242 0 6.427 6.427 0 01-2.04 1.862 6.402 6.402 0 01-3.293.893 6.574 6.574 0 01-1.73-.227c-.137-.037-.248.125-.154.232a9.643 9.643 0 002.677 2.113A9.651 9.651 0 0012 20.935z"
fill-rule="evenodd"
/>
</svg>
<svg
v-else-if="modelMapping?.slug === 'lg'"
fill="currentColor"
fill-rule="evenodd"
viewBox="0 0 24 24"
:style="{ transform: `scale(${modelAvatar?.multiple})` }"
>
<path
d="M19.167 19.18a10.082 10.082 0 002.97-7.169v-.549l-.498.003h-6.68v1.12h6.038l-.002.034a9.038 9.038 0 01-8.993 8.41 8.96 8.96 0 01-6.375-2.642 8.962 8.962 0 01-2.64-6.376c0-2.406.939-4.67 2.64-6.373A8.961 8.961 0 0112 2.998l.572.007V1.882l-.57-.007A10.15 10.15 0 001.864 12.011c0 2.708 1.055 5.253 2.97 7.17A10.079 10.079 0 0012 22.15a10.078 10.078 0 007.171-2.97m-6.6-2.942V6.656h-1.14v10.705h3.529V16.24H12.57zM9.703 8.183a1.533 1.533 0 10-3.066-.01 1.533 1.533 0 003.066.01z"
/>
</svg>
<svg
v-else-if="modelMapping?.slug === 'nanobanana'"
viewBox="0 0 24 24"
:style="{ transform: `scale(${modelAvatar?.multiple})` }"
>
<path
d="M12.453 1.026c.826-.118 1.574.17 2.207.684.625.508 1.157 1.25 1.596 2.102.797 1.548 1.332 3.555 1.535 5.487a3.689 3.689 0 013.263 1.107l1.634 1.704c.645.674.23 1.89-.775 1.89H19.88l.002.088v5.664l-.014.237c-.028.234-.1.457-.228.647-.177.263-.445.44-.769.485-.613.087-1.256-.302-1.815-.942l-.002-.002-1.387-1.602c-1.57 1.96-4.028 3.442-6.387 4.08-2.409.65-4.976.471-6.262-1.34H2.49c-.823 0-1.49-.668-1.491-1.49l.008-.153A1.492 1.492 0 012.27 18.35c.203-1.603 1.343-2.938 2.804-3.625l.326-.141A6.95 6.95 0 006.554 14H3.587c-1.004 0-1.42-1.218-.775-1.89l1.633-1.704a3.68 3.68 0 015.105-.241 8.88 8.88 0 00.4-1.615c.099-.696.112-1.431.11-2.532 0-.88-.037-2.013.215-2.952.13-.48.342-.946.702-1.319.366-.38.855-.631 1.476-.72z"
stroke="#451D1C"
/>
<path
d="M1.5 19.824c0-.548.444-.992.991-.992h.744a.991.991 0 010 1.983H2.49a.991.991 0 01-.991-.991z"
fill="#F3AD61"
/>
<path
d="M14.837 13.5h7.076c.522 0 .784-.657.413-1.044l-1.634-1.704a3.183 3.183 0 00-4.636 0l-1.633 1.704c-.37.385-.107 1.044.414 1.044zM3.587 13.5h7.076c.521 0 .784-.659.414-1.044l-1.635-1.704a3.183 3.183 0 00-4.636 0l-1.633 1.704c-.37.385-.107 1.044.414 1.044z"
fill="#F9C23C"
/>
<path
d="M12.525 1.521c3.69-.53 5.97 8.923 4.309 12.744-1.662 3.82-5.248 4.657-9.053 6.152a3.49 3.49 0 01-1.279.244c-1.443 0-2.227 1.187-2.774-.282-.707-1.9.22-4.031 2.069-4.757 2.014-.79 3.084-2.308 3.89-4.364.82-2.096.877-2.956.873-5.241-.003-1.827-.123-4.195 1.965-4.496z"
fill="#FEEFC2"
/>
<path
d="M16.834 14.264l-7.095-3.257c-.815 1.873-2.29 3.308-4.156 4.043-2.16.848-3.605 3.171-2.422 5.54 2.364 4.727 13.673-.05 13.673-6.325z"
fill="#FCD53F"
/>
<path
clip-rule="evenodd"
d="M13.68 12.362c.296.094.46.41.365.707-1.486 4.65-5.818 6.798-9.689 6.997a.562.562 0 11-.057-1.124c3.553-.182 7.372-2.138 8.674-6.216a.562.562 0 01.707-.364z"
fill="#F9C23C"
fill-rule="evenodd"
/>
<path
d="M17.43 19.85l-7.648-8.835h6.753c1.595.08 2.846 1.433 2.846 3.073v5.664c0 .997-.898 1.302-1.95.098z"
fill="#FFF478"
/>
</svg>
<svg
v-else-if="modelMapping?.slug === 'sora'"
viewBox="0 0 24 24"
:style="{ transform: `scale(${modelAvatar?.multiple})` }"
>
<path
d="M8.968 11.147a.408.408 0 110 .816.408.408 0 010-.816z"
:fill="`url(#${soraGradientId})`"
/>
<path
clip-rule="evenodd"
d="M7.21 8.748c.045-.012.087.003.128.044.195.2.39.398.587.596a.15.15 0 00.061.035l.81.209c.056.014.09.043.102.088.013.046-.002.09-.043.13l-.596.585a.139.139 0 00-.021.03.134.134 0 00-.015.033c-.07.27-.14.54-.208.81-.014.055-.044.09-.09.102-.045.012-.088-.003-.128-.045-.195-.199-.39-.397-.587-.595a.158.158 0 00-.062-.035l-.81-.209c-.056-.014-.09-.044-.103-.09-.011-.044.004-.087.045-.128.2-.194.398-.39.596-.585a.12.12 0 00.022-.03.134.134 0 00.014-.032c.07-.27.14-.54.208-.81.014-.056.044-.09.09-.103z"
:fill="`url(#${soraGradientId})`"
fill-rule="evenodd"
/>
<path
d="M15.827 9.31a.409.409 0 110 .817.409.409 0 010-.818z"
:fill="`url(#${soraGradientId})`"
/>
<path
clip-rule="evenodd"
d="M14.071 6.915c.046-.012.09.003.13.044.194.2.388.398.583.596a.155.155 0 00.062.036l.807.21c.056.014.09.045.103.09.012.045-.003.088-.045.128l-.596.583a.168.168 0 00-.036.061l-.21.808c-.014.056-.044.09-.09.103-.045.011-.088-.004-.128-.045-.194-.2-.389-.398-.583-.596a.12.12 0 00-.03-.022.12.12 0 00-.032-.014l-.808-.21c-.056-.014-.09-.044-.102-.09-.012-.045.003-.087.044-.128.2-.194.398-.388.596-.583a.119.119 0 00.022-.03.132.132 0 00.015-.032l.21-.806c.014-.057.043-.09.088-.103z"
:fill="`url(#${soraGradientId})`"
fill-rule="evenodd"
/>
<path
clip-rule="evenodd"
d="M8.086.457a6.102 6.102 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.116.116 0 00.107.029c1.409-.346 2.762-.224 4.062.366l.061.029.155.077c1.357.703 2.33 1.769 2.918 3.197.278.68.418 1.388.421 2.127a5.65 5.65 0 01-.18 1.631.164.164 0 00.04.154 5.98 5.98 0 011.577 2.892c.386 1.901-.008 3.614-1.182 5.14l-.181.22a6.062 6.062 0 01-2.936 1.85.16.16 0 00-.106.103c-.255.736-.512 1.364-.988 1.992-1.199 1.582-2.962 2.462-4.948 2.45-1.583-.007-2.986-.586-4.21-1.736a.142.142 0 00-.14-.031c-.518.167-1.04.191-1.605.185a5.923 5.923 0 01-2.594-.622 6.057 6.057 0 01-2.146-1.781c-.203-.27-.404-.522-.552-.821a7.742 7.742 0 01-.494-1.283 6.108 6.108 0 01-.017-3.065.163.163 0 00.007-.074.112.112 0 00-.036-.063 5.954 5.954 0 01-1.38-2.202 5.193 5.193 0 01-.333-1.59 6.911 6.911 0 01.188-2.13c.45-1.485 1.309-2.65 2.578-3.494.282-.188.549-.334.8-.439a8.21 8.21 0 01.862-.303.128.128 0 00.087-.087 6.014 6.014 0 011.104-2.155C6.315 1.463 7.132.846 8.086.457zm.965 7.647c-1.154-.82-2.73-.413-3.311.875-.301.666-.36 1.368-.178 2.106l.145.586.26.95c.105.533.31 1.02.612 1.462l.03.043c.16.189.335.362.524.518 1.386 1.139 3.275.379 3.652-1.323l.05-.213.012-.08c.06-.4.042-.792-.053-1.175a47.673 47.673 0 00-.546-2.024c-.217-.738-.616-1.313-1.197-1.725zm7.104-1.646c-.862-.802-2.191-.831-3.047-.026-.334.314-.566.736-.697 1.265a3.47 3.47 0 000 1.635l.014.054.055.18c.127.42.245.834.353 1.241.112.423.202.706.27.85.574 1.206 1.82 2.074 3.177 1.522 1.261-.514 1.641-2.01 1.355-3.22-.043-.183-.09-.365-.14-.546a34.426 34.426 0 00-.428-1.573c-.162-.508-.466-.968-.912-1.382z"
:fill="`url(#${soraGradientId})`"
fill-rule="evenodd"
/>
<defs>
<linearGradient
:id="soraGradientId"
gradientUnits="userSpaceOnUse"
x1="9.145"
x2="14.959"
y1="0"
y2="24.022"
>
<stop stop-color="#fff" />
<stop offset="1" stop-color="#6BB6FE" />
</linearGradient>
</defs>
</svg>
<svg
v-else-if="modelMapping?.slug === 'stepfun'"
fill="currentColor"
fill-rule="evenodd"
viewBox="0 0 24 24"
:style="{ transform: `scale(${modelAvatar?.multiple})` }"
>
<path
d="M1 23h6.335v-6.337H1V23zM8.832 23h6.336v-6.337H8.832V23zM8.832 15.17h6.336V8.835H8.832v6.337zM8.832 7.342h6.336V1.005H8.832v6.337zM16.665 7.337H23V1h-6.335v6.337z"
/>
</svg>
<component
:is="modelComponent"
v-else-if="modelComponent && modelAvatar"
:style="{
height: `${size}px`,
transform: `scale(${modelAvatar.multiple})`,
width: `${size}px`,
}"
/>
<svg
v-else-if="!modelMapping"
class="default-model-icon"
fill="none"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
viewBox="0 0 24 24"
>
<path d="M12 18V5" />
<path d="M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4" />
<path d="M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5" />
<path d="M17.997 5.125a4 4 0 0 1 2.526 5.77" />
<path d="M18 18a4 4 0 0 0 2-7.464" />
<path d="M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517" />
<path d="M6 18a4 4 0 0 1-2-7.464" />
<path d="M6.003 5.125a4 4 0 0 0-2.526 5.77" />
</svg>
</span>
</template>
<style scoped>
.lobe-provider-avatar.white-background,
.model-icon.white-background {
box-shadow: 0 0 0 1px rgb(0 0 0 / 5%) inset;
}
:global(html.dark-mode) .lobe-provider-avatar.black-background,
:global(html.dark-mode) .model-icon.black-background {
box-shadow: 0 0 0 1px rgb(255 255 255 / 10%) inset;
}
.lobe-provider-avatar > :deep(svg),
.lobe-provider-avatar > svg {
display: block;
flex: none;
height: 100%;
width: 100%;
}
.lobe-provider-wordmark {
display: inline-flex;
flex: none;
align-items: center;
justify-content: flex-start;
}
.special-square {
display: block;
flex: none;
object-fit: contain;
}
.model-icon > :deep(svg),
.model-icon > svg {
display: block;
flex: none;
width: 100%;
height: 100%;
}
.default-model-icon {
transform: scale(0.6);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,561 @@
<script setup lang="ts">
import {
CheckIcon,
ChevronDownIcon,
CopyIcon,
EditIcon,
ExternalIcon,
GithubIcon,
GlobeIcon,
HeartHandshakeIcon,
IssuesIcon,
ScaleIcon,
UsersIcon,
} from '@modrinth/assets'
import { Avatar, defineMessages, NewButton as Button, useVIntl } from '@modrinth/ui'
import { getVersion } from '@tauri-apps/api/app'
import { inject, nextTick, onScopeDispose, ref, shallowRef } from 'vue'
import AfdianIcon from '@/assets/external/afdian.png'
import QqIcon from '@/assets/external/qq.svg?component'
import EasterEggContributorsModal from '@/components/ui/easteregg/EasterEggContributorsModal.vue'
import EasterEggGameModal from '@/components/ui/easteregg/EasterEggGameModal.vue'
import { AxolotlBrandConfig } from '@/config'
import { contributors, type TeamMember, teamMembers } from '@/data/about'
import AboutScene from '../AboutScene.vue'
import { type AboutMemberExperience, getAboutMemberExperience } from './about-member-experiences'
import QqChannelIcon from './QqChannelIcon.vue'
const { formatMessage } = useVIntl()
const version = await getVersion()
const copied = ref(false)
const experienceHost = ref<HTMLElement>()
const activeMemberExperience = shallowRef<AboutMemberExperience>()
const pressingMemberName = ref<string>()
let longPressTimer: ReturnType<typeof window.setTimeout> | undefined
let pressStart = { x: 0, y: 0 }
let suppressNextMemberClick = false
const replayOnboarding = inject<(mode: 'main' | 'instance') => Promise<void>>('replayOnboarding')
const licenseUrl = `${AxolotlBrandConfig.repositoryUrl}/blob/main/LICENSE`
const thirdPartyLicensesUrl = `${AxolotlBrandConfig.repositoryUrl}/tree/main/third-party/licenses`
async function copyQqGroupNumber() {
await navigator.clipboard.writeText(AxolotlBrandConfig.qqGroupNumber)
copied.value = true
setTimeout(() => {
copied.value = false
}, 3000)
}
function cancelMemberLongPress() {
if (longPressTimer) window.clearTimeout(longPressTimer)
longPressTimer = undefined
pressingMemberName.value = undefined
}
function startMemberLongPress(member: TeamMember, event: PointerEvent) {
const experience = getAboutMemberExperience(member.experience)
if (!experience || event.button !== 0) return
cancelMemberLongPress()
pressStart = { x: event.clientX, y: event.clientY }
pressingMemberName.value = member.name
longPressTimer = window.setTimeout(async () => {
activeMemberExperience.value = experience
suppressNextMemberClick = true
cancelMemberLongPress()
await nextTick()
experienceHost.value?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}, experience.longPressDuration)
}
function moveMemberLongPress(event: PointerEvent) {
if (!longPressTimer) return
if (Math.hypot(event.clientX - pressStart.x, event.clientY - pressStart.y) > 8) {
cancelMemberLongPress()
}
}
function handleMemberClick(event: MouseEvent) {
if (!suppressNextMemberClick) return
suppressNextMemberClick = false
event.preventDefault()
event.stopPropagation()
}
function handleMemberContextMenu(member: TeamMember, event: MouseEvent) {
if (getAboutMemberExperience(member.experience)) event.preventDefault()
}
function closeMemberExperience() {
activeMemberExperience.value = undefined
}
const gameModal = ref<InstanceType<typeof EasterEggGameModal> | null>(null)
const contributorsModal = ref<InstanceType<typeof EasterEggContributorsModal> | null>(null)
let typedBuffer = ''
const secretCodes = ['cyf112233', 'cxkcxkckx']
const konamiSequence = [
'ArrowUp', 'ArrowUp', 'ArrowDown', 'ArrowDown',
'ArrowLeft', 'ArrowRight', 'ArrowLeft', 'ArrowRight',
'KeyB', 'KeyA',
]
let konamiIndex = 0
function handleEasterEggKeydown(event: KeyboardEvent) {
typedBuffer = (typedBuffer + event.key).toLowerCase()
const maxCodeLen = Math.max(...secretCodes.map((c) => c.length))
if (typedBuffer.length > maxCodeLen) {
typedBuffer = typedBuffer.slice(-maxCodeLen)
}
if (secretCodes.some((code) => typedBuffer.endsWith(code))) {
typedBuffer = ''
gameModal.value?.show()
return
}
const expected = konamiSequence[konamiIndex]
if (event.code === expected) {
konamiIndex++
if (konamiIndex === konamiSequence.length) {
konamiIndex = 0
contributorsModal.value?.show()
}
} else {
konamiIndex = event.code === konamiSequence[0] ? 1 : 0
}
}
function onEasterEggOpenGame() {
gameModal.value?.show()
}
document.addEventListener('keydown', handleEasterEggKeydown)
onScopeDispose(() => document.removeEventListener('keydown', handleEasterEggKeydown))
onScopeDispose(cancelMemberLongPress)
const messages = defineMessages({
productTitle: {
id: 'app.settings.about.product-title',
defaultMessage: 'About {productName}',
},
productDescription: {
id: 'app.settings.about.description',
defaultMessage: 'Your last launcher.',
},
version: {
id: 'app.settings.about.version',
defaultMessage: 'Version {version}',
},
replayOnboarding: {
id: 'app.settings.about.replay-onboarding',
defaultMessage: 'Replay tour',
},
developmentTeam: {
id: 'app.settings.about.development-team',
defaultMessage: 'Development team',
},
communitySupport: {
id: 'app.settings.about.community-support',
defaultMessage: 'Project & community',
},
projectWebsite: {
id: 'app.settings.about.project-website',
defaultMessage: 'Project website',
},
repository: {
id: 'app.settings.about.repository',
defaultMessage: 'Source code',
},
reportIssue: {
id: 'app.settings.about.report-issue',
defaultMessage: 'Issues & feedback',
},
qqGroup: {
id: 'app.settings.about.qq-group',
defaultMessage: 'Player QQ group',
},
qqChannel: {
id: 'app.settings.about.qq-channel',
defaultMessage: 'QQ channel',
},
copyQqGroup: {
id: 'app.settings.about.copy-qq-group',
defaultMessage: 'Copy group number',
},
copiedQqGroup: {
id: 'app.settings.about.copied-qq-group',
defaultMessage: 'Group number copied',
},
afdian: {
id: 'app.settings.about.afdian',
defaultMessage: 'Support on Afdian',
},
afdianDescription: {
id: 'app.settings.about.afdian-description',
defaultMessage: 'Help support continued development',
},
survey: {
id: 'app.settings.about.survey',
defaultMessage: 'Community survey',
},
surveyDescription: {
id: 'app.settings.about.survey-description',
defaultMessage: 'Help us improve Axolotl Launcher',
},
licenseAttribution: {
id: 'app.settings.about.license-attribution',
defaultMessage: 'License & attribution',
},
attribution: {
id: 'app.settings.about.attribution',
defaultMessage: 'Axolotl Launcher is a modified version of the open-source Modrinth codebase.',
},
notAffiliated: {
id: 'app.settings.about.not-affiliated',
defaultMessage:
'Modrinth is a trademark of Rinth, Inc. Axolotl Launcher is not affiliated with or endorsed by Rinth, Inc.',
},
originalSource: {
id: 'app.settings.about.original-source',
defaultMessage: 'Original Modrinth source',
},
projectLicense: {
id: 'app.settings.about.project-license',
defaultMessage: 'Project license (GPL-3.0)',
},
thirdPartyLicenses: {
id: 'app.settings.about.third-party-licenses',
defaultMessage: 'Third-party licenses',
},
contributors: {
id: 'app.settings.about.contributors',
defaultMessage: 'Contributors',
},
contributorsCount: {
id: 'app.settings.about.contributors-count',
defaultMessage: '{count, plural, one {# contributor} other {# contributors}}',
},
})
const projectLinks = [
{
href: AxolotlBrandConfig.website,
label: messages.projectWebsite,
icon: GlobeIcon,
},
{
href: AxolotlBrandConfig.repositoryUrl,
label: messages.repository,
icon: GithubIcon,
},
{
href: AxolotlBrandConfig.supportUrl,
label: messages.reportIssue,
icon: IssuesIcon,
},
{
href: AxolotlBrandConfig.qqChannelUrl,
label: messages.qqChannel,
icon: QqChannelIcon,
},
]
</script>
<template>
<div class="about-page flex flex-col gap-6">
<section id="settings-target-about-product" tabindex="-1" class="about-panel">
<div class="flex flex-col items-center gap-4">
<div
ref="experienceHost"
class="relative m-0 w-full overflow-hidden h-64 rounded-xl"
style="
mask-image: linear-gradient(to bottom, black 97%, transparent 100%);
-webkit-mask-image: linear-gradient(to bottom, black 97%, transparent 100%);
"
>
<AboutScene />
<component
:is="activeMemberExperience?.component"
v-if="activeMemberExperience"
@exit="closeMemberExperience"
/>
</div>
<div class="min-w-0 text-center">
<h2 class="m-0 text-xl font-semibold text-contrast">
{{
formatMessage(messages.productTitle, {
productName: AxolotlBrandConfig.productName,
})
}}
</h2>
<p class="m-0 mt-1 text-secondary">
{{ formatMessage(messages.version, { version }) }}
</p>
</div>
</div>
<p class="m-0 mt-3 text-center text-primary">
{{ formatMessage(messages.productDescription) }}
</p>
</section>
<section>
<h3 class="m-0 mb-3 flex items-center gap-2 text-base font-semibold text-contrast">
<UsersIcon class="size-5 text-secondary" />
{{ formatMessage(messages.developmentTeam) }}
</h3>
<ul class="m-0 grid list-none grid-cols-2 gap-3 p-0 sm:grid-cols-3">
<li v-for="member in teamMembers" :key="member.name" class="min-w-0">
<component
:is="member.url ? 'a' : 'div'"
:href="member.url"
:target="member.url ? '_blank' : undefined"
:rel="member.url ? 'noopener noreferrer' : undefined"
class="flex min-w-0 select-none flex-col items-center gap-3 rounded-xl bg-surface-4 p-4"
:class="[
member.url ? 'transition-colors hover:bg-surface-5' : 'cursor-default',
pressingMemberName === member.name ? 'ring-4 ring-brand-shadow' : '',
]"
@pointerdown="startMemberLongPress(member, $event)"
@pointermove="moveMemberLongPress"
@pointerup="cancelMemberLongPress"
@pointercancel="cancelMemberLongPress"
@dragstart="cancelMemberLongPress"
@click="handleMemberClick"
@contextmenu="handleMemberContextMenu(member, $event)"
>
<Avatar :src="member.avatarUrl" :alt="member.name" size="4rem" circle no-shadow />
<span class="block truncate text-center font-semibold text-contrast">{{
member.name
}}</span>
</component>
</li>
</ul>
</section>
<section>
<h3 class="m-0 mb-3 flex items-center gap-2 text-base font-semibold text-contrast">
<HeartHandshakeIcon class="size-5 text-secondary" />
{{ formatMessage(messages.communitySupport) }}
</h3>
<div class="grid gap-3 sm:grid-cols-2">
<a
v-for="link in projectLinks"
:key="link.label"
:href="link.href"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-4 transition-colors hover:bg-surface-5"
>
<span
class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-surface-2 text-contrast"
>
<component :is="link.icon" class="size-6" />
</span>
<span class="min-w-0 flex-1 font-semibold text-contrast">
{{ formatMessage(link.label) }}
</span>
<ExternalIcon class="size-5 shrink-0 text-secondary" />
</a>
<button
type="button"
:disabled="copied"
:aria-label="
copied ? formatMessage(messages.copiedQqGroup) : formatMessage(messages.copyQqGroup)
"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-4 text-left transition-colors hover:bg-surface-5 disabled:cursor-default"
@click="copyQqGroupNumber"
>
<span
class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-surface-2 text-contrast"
>
<QqIcon class="size-6" />
</span>
<span class="min-w-0 flex-1">
<span class="block font-semibold text-contrast">
{{ formatMessage(messages.qqGroup) }}
</span>
<span class="block text-sm text-secondary">
{{ AxolotlBrandConfig.qqGroupNumber }}
</span>
</span>
<span class="shrink-0" aria-live="polite">
<CheckIcon v-if="copied" class="size-5 text-green" />
<CopyIcon v-else class="size-5 text-secondary" />
<span class="sr-only">
{{
copied ? formatMessage(messages.copiedQqGroup) : formatMessage(messages.copyQqGroup)
}}
</span>
</span>
</button>
<a
:href="AxolotlBrandConfig.sponsorUrl"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-4 transition-colors hover:bg-surface-5"
>
<span class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-surface-2">
<img :src="AfdianIcon" alt="" class="size-7 object-contain" />
</span>
<span class="min-w-0 flex-1">
<span class="block font-semibold text-contrast">
{{ formatMessage(messages.afdian) }}
</span>
<span class="block text-sm text-secondary">
{{ formatMessage(messages.afdianDescription) }}
</span>
</span>
<ExternalIcon class="size-5 shrink-0 text-secondary" />
</a>
<a
:href="AxolotlBrandConfig.surveyUrl"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 items-center gap-3 rounded-xl bg-surface-4 p-4 transition-colors hover:bg-surface-5 sm:col-span-2"
>
<span
class="flex size-10 shrink-0 items-center justify-center rounded-xl bg-surface-2 text-contrast"
>
<EditIcon class="size-6" />
</span>
<span class="min-w-0 flex-1">
<span class="block font-semibold text-contrast">
{{ formatMessage(messages.survey) }}
</span>
<span class="block text-sm text-secondary">
{{ formatMessage(messages.surveyDescription) }}
</span>
</span>
<ExternalIcon class="size-5 shrink-0 text-secondary" />
</a>
</div>
</section>
<section>
<h3 class="m-0 mb-3 flex items-center gap-2 text-base font-semibold text-contrast">
<ScaleIcon class="size-5 text-secondary" />
{{ formatMessage(messages.licenseAttribution) }}
</h3>
<div class="about-panel about-panel-compact">
<p class="m-0 text-primary">
{{ formatMessage(messages.attribution) }}
</p>
<p class="m-0 mt-2 text-sm text-secondary">
{{ formatMessage(messages.notAffiliated) }}
</p>
</div>
<div class="mt-3 flex flex-wrap gap-2">
<a
:href="licenseUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg bg-surface-4 px-3 py-2 text-sm font-semibold text-contrast transition-colors hover:bg-surface-5"
>
{{ formatMessage(messages.projectLicense) }}
<ExternalIcon class="size-4 text-secondary" />
</a>
<a
:href="thirdPartyLicensesUrl"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg bg-surface-4 px-3 py-2 text-sm font-semibold text-contrast transition-colors hover:bg-surface-5"
>
{{ formatMessage(messages.thirdPartyLicenses) }}
<ExternalIcon class="size-4 text-secondary" />
</a>
<a
href="https://github.com/modrinth/code"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 rounded-lg bg-surface-4 px-3 py-2 text-sm font-semibold text-contrast transition-colors hover:bg-surface-5"
>
{{ formatMessage(messages.originalSource) }}
<ExternalIcon class="size-4 text-secondary" />
</a>
</div>
</section>
<details class="group pt-4 about-settings-details">
<summary
class="flex cursor-pointer list-none items-center gap-2 text-base font-semibold text-contrast [&::-webkit-details-marker]:hidden"
>
<UsersIcon class="size-5 text-secondary" />
<span>{{ formatMessage(messages.contributors) }}</span>
<span class="rounded-full bg-surface-4 px-2 py-0.5 text-xs text-secondary">
{{ formatMessage(messages.contributorsCount, { count: contributors.length }) }}
</span>
<ChevronDownIcon
class="ml-auto size-5 text-secondary transition-transform group-open:rotate-180"
/>
</summary>
<div class="mt-3 flex flex-wrap gap-2">
<a
v-for="contributor in contributors"
:key="contributor.name"
:href="contributor.url"
target="_blank"
rel="noopener noreferrer"
class="flex min-w-0 items-center gap-1.5 rounded-full bg-surface-4 py-1 pl-1 pr-2.5 transition-colors hover:bg-surface-5"
>
<Avatar
:src="contributor.avatarUrl"
:alt="contributor.name"
size="1.5rem"
circle
no-shadow
loading="lazy"
/>
<span class="truncate text-sm text-primary">{{ contributor.name }}</span>
</a>
</div>
</details>
<div id="settings-target-about-replay-tour" tabindex="-1" class="flex flex-wrap gap-2">
<Button type="base" @click="replayOnboarding?.('main')">
{{ formatMessage(messages.replayOnboarding) }}
</Button>
</div>
</div>
<EasterEggGameModal ref="gameModal" />
<EasterEggContributorsModal ref="contributorsModal" @open-game="onEasterEggOpenGame" />
</template>
<style scoped>
.about-settings-details {
border-top: 1px solid
var(--settings-divider, color-mix(in srgb, var(--surface-4) 55%, transparent));
}
.about-panel {
padding: 1.25rem;
border: 1px solid
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
border-radius: var(--radius-md);
background: var(--surface-2);
}
.about-panel-compact {
padding: var(--gap-lg);
}
.about-page :deep(.rounded-xl.bg-surface-4) {
border: 1px solid
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
border-radius: var(--radius-md);
background: var(--surface-2);
}
.about-page :deep(.rounded-xl.bg-surface-2) {
border-radius: var(--radius-sm);
}
</style>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,77 @@
<script setup lang="ts">
import { useId } from 'vue'
withDefaults(defineProps<{ size?: number }>(), { size: undefined })
const cutsArm1Id = useId()
const cutsArm2Id = useId()
</script>
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 64 64"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
:style="size ? { height: `${size}px`, width: `${size}px` } : undefined"
aria-hidden="true"
>
<defs>
<mask :id="cutsArm1Id" maskUnits="userSpaceOnUse" x="0" y="0" width="64" height="64">
<rect width="64" height="64" fill="#fff" />
<path
fill="none"
stroke="#000"
stroke-width="8.6"
stroke-linecap="round"
stroke-linejoin="round"
d="M47.6 40.7c-3.4 5.8-10.7 7.9-16.3 4.7-3.7-2.1-5.5-5.8-5.4-10.6l.3-12"
/>
<path
fill="none"
stroke="#000"
stroke-width="8.6"
stroke-linecap="round"
stroke-linejoin="round"
d="M16.4 40.6c-3.4-5.8-1.3-13.2 4.4-16.4 3.7-2.1 7.8-1.7 11.9.8l10.4 6.3"
/>
</mask>
<mask :id="cutsArm2Id" maskUnits="userSpaceOnUse" x="0" y="0" width="64" height="64">
<rect width="64" height="64" fill="#fff" />
<path
fill="none"
stroke="#000"
stroke-width="8.6"
stroke-linecap="round"
stroke-linejoin="round"
d="M16.4 40.6c-3.4-5.8-1.3-13.2 4.4-16.4 3.7-2.1 7.8-1.7 11.9.8l10.4 6.3"
/>
</mask>
</defs>
<g
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
transform="translate(-1.91 -1.11) scale(1.0875)"
>
<path
stroke="currentColor"
stroke-width="5.4"
:mask="`url(#${cutsArm1Id})`"
d="M32 14c6.8 0 12.2 5.2 12.2 11.7 0 4.1-2.2 7.6-6.3 10l-10.4 6.1"
/>
<path
stroke="currentColor"
stroke-width="5.4"
:mask="`url(#${cutsArm2Id})`"
d="M47.6 40.7c-3.4 5.8-10.7 7.9-16.3 4.7-3.7-2.1-5.5-5.8-5.4-10.6l.3-12"
/>
<path
stroke="currentColor"
stroke-width="5.4"
d="M16.4 40.6c-3.4-5.8-1.3-13.2 4.4-16.4 3.7-2.1 7.8-1.7 11.9.8l10.4 6.3"
/>
</g>
</svg>
</template>

View File

@ -0,0 +1,11 @@
<script setup lang="ts">
import AppearanceSettings from './AppearanceSettings.vue'
import ResourceManagementSettings from './ResourceManagementSettings.vue'
</script>
<template>
<div class="flex flex-col gap-6">
<AppearanceSettings scope="content-downloads" />
<ResourceManagementSettings scope="content-downloads" />
</div>
</template>

View File

@ -0,0 +1,152 @@
<script setup lang="ts">
import { Combobox, defineMessages, injectNotificationManager, Toggle, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import { type AIState, getAIState, sharedAIState } from '@/helpers/ai'
import { get_crash_analysis_ai_settings, update_crash_analysis_ai_settings } from '@/helpers/logs'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
type CrashAnalysisAISettings = {
enabled: boolean
provider_id: string
model_id: string
}
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const settings = ref<CrashAnalysisAISettings>({ enabled: false, provider_id: '', model_id: '' })
const loading = ref(true)
const saving = ref(false)
const messages = defineMessages({
title: { id: 'app.crash-analysis.ai.settings.title', defaultMessage: 'Crash AI explanation' },
description: {
id: 'app.crash-analysis.ai.settings.description',
defaultMessage:
'Optionally send a sanitized and shortened crash context to a model configured in AI Providers.',
},
enabled: {
id: 'app.crash-analysis.ai.settings.enabled',
defaultMessage: 'Enable AI explanations',
},
enabledDescription: {
id: 'app.crash-analysis.ai.settings.enabled-description',
defaultMessage: 'Local rule-based crash analysis remains available without AI.',
},
provider: { id: 'app.crash-analysis.ai.settings.provider', defaultMessage: 'AI provider' },
model: { id: 'app.crash-analysis.ai.settings.model', defaultMessage: 'Text model' },
noProviders: {
id: 'app.crash-analysis.ai.settings.no-providers',
defaultMessage: 'Enable a provider and a text model above before using AI crash explanations.',
},
})
const emptyState: AIState = { settings: { enabled: false }, catalog_source: '', providers: [] }
const aiState = computed(() => sharedAIState.value ?? emptyState)
const providers = computed(() =>
aiState.value.providers.filter(
(provider) => provider.enabled && provider.models.some((model) => model.enabled),
),
)
const providerOptions = computed(() =>
providers.value.map((provider) => ({
value: provider.provider_id,
label: provider.custom_name || provider.provider_id,
})),
)
const modelOptions = computed(
() =>
providers.value
.find((provider) => provider.provider_id === settings.value.provider_id)
?.models.filter((model) => model.enabled)
.map((model) => ({ value: model.id, label: model.name || model.id })) ?? [],
)
async function save(next: CrashAnalysisAISettings): Promise<void> {
saving.value = true
try {
await update_crash_analysis_ai_settings(next)
settings.value = next
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
function updateEnabled(enabled: boolean): void {
void save({ ...settings.value, enabled })
}
function updateProvider(providerId: string): void {
const provider = providers.value.find((item) => item.provider_id === providerId)
const modelId = provider?.models.find((model) => model.enabled)?.id ?? ''
void save({ ...settings.value, provider_id: providerId, model_id: modelId })
}
function updateModel(modelId: string): void {
void save({ ...settings.value, model_id: modelId })
}
onMounted(async () => {
try {
const [nextSettings] = await Promise.all([get_crash_analysis_ai_settings(), getAIState()])
settings.value = nextSettings
} catch (error) {
handleError(error)
} finally {
loading.value = false
}
})
</script>
<template>
<SettingsSection
v-if="!loading"
id="settings-target-crash-analysis-ai"
:title="formatMessage(messages.title)"
:description="formatMessage(messages.description)"
>
<SettingsRow>
<template #label>{{ formatMessage(messages.enabled) }}</template>
<template #description>{{ formatMessage(messages.enabledDescription) }}</template>
<template #control>
<Toggle
id="crash-analysis-ai-enabled"
:model-value="settings.enabled"
:disabled="saving || !aiState.settings.enabled || !providers.length"
@update:model-value="updateEnabled(!!$event)"
/>
</template>
</SettingsRow>
<template v-if="providers.length">
<SettingsRow>
<template #label>{{ formatMessage(messages.provider) }}</template>
<template #control>
<Combobox
id="crash-analysis-ai-provider"
:model-value="settings.provider_id"
:options="providerOptions"
:disabled="saving"
@update:model-value="updateProvider(String($event))"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.model) }}</template>
<template #control>
<Combobox
id="crash-analysis-ai-model"
:model-value="settings.model_id"
:options="modelOptions"
:disabled="saving || !settings.provider_id"
@update:model-value="updateModel(String($event))"
/>
</template>
</SettingsRow>
</template>
<p v-else class="m-0 p-4 text-sm text-secondary">{{ formatMessage(messages.noProviders) }}</p>
</SettingsSection>
</template>

View File

@ -0,0 +1,318 @@
<script setup lang="ts">
import { defineMessages, StyledInput, Toggle, useVIntl } from '@modrinth/ui'
import { platform } from '@tauri-apps/plugin-os'
import { ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import CrashAnalysisAISettings from './CrashAnalysisAISettings.vue'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
fullscreen: { id: 'app.settings.defaults.fullscreen', defaultMessage: 'Fullscreen' },
fullscreenDescription: {
id: 'app.settings.defaults.fullscreen-description',
defaultMessage: 'Overwrites the options.txt file to start in full screen when launched.',
},
maximizeWindow: {
id: 'app.settings.defaults.maximize-window',
defaultMessage: 'Maximize window',
},
maximizeWindowDescription: {
id: 'app.settings.defaults.maximize-window-description',
defaultMessage: 'Maximize the Minecraft window when it starts.',
},
maximizeWindowUnsupported: {
id: 'app.settings.defaults.maximize-window.unsupported',
defaultMessage: 'Not supported on this operating system.',
},
width: { id: 'app.settings.defaults.width', defaultMessage: 'Width' },
widthDescription: {
id: 'app.settings.defaults.width-description',
defaultMessage: 'The width of the game window when launched.',
},
widthPlaceholder: {
id: 'app.settings.defaults.width-placeholder',
defaultMessage: 'Enter width...',
},
height: { id: 'app.settings.defaults.height', defaultMessage: 'Height' },
heightDescription: {
id: 'app.settings.defaults.height-description',
defaultMessage: 'The height of the game window when launched.',
},
heightPlaceholder: {
id: 'app.settings.defaults.height-placeholder',
defaultMessage: 'Enter height...',
},
environmentVariables: {
id: 'app.settings.defaults.environment-variables',
defaultMessage: 'Environment variables',
},
environmentVariablesPlaceholder: {
id: 'app.settings.defaults.environment-variables-placeholder',
defaultMessage: 'Enter environment variables...',
},
preLaunchHook: {
id: 'app.settings.defaults.pre-launch-hook',
defaultMessage: 'Pre-launch hook',
},
preLaunchPlaceholder: {
id: 'app.settings.defaults.pre-launch-placeholder',
defaultMessage: 'Enter pre-launch command...',
},
preLaunchDescription: {
id: 'app.settings.defaults.pre-launch-description',
defaultMessage: 'Run before the instance is launched.',
},
wrapperHook: { id: 'app.settings.defaults.wrapper-hook', defaultMessage: 'Wrapper hook' },
wrapperPlaceholder: {
id: 'app.settings.defaults.wrapper-placeholder',
defaultMessage: 'Enter wrapper command...',
},
wrapperDescription: {
id: 'app.settings.defaults.wrapper-description',
defaultMessage: 'Wrapper command for launching Minecraft.',
},
postExitHook: { id: 'app.settings.defaults.post-exit-hook', defaultMessage: 'Post-exit hook' },
postExitPlaceholder: {
id: 'app.settings.defaults.post-exit-placeholder',
defaultMessage: 'Enter post-exit command...',
},
postExitDescription: {
id: 'app.settings.defaults.post-exit-description',
defaultMessage: 'Run after the game closes.',
},
lightweightMode: {
id: 'app.appearance-settings.lightweight-mode.title',
defaultMessage: 'Enter lightweight mode after launching a game',
},
lightweightModeDescription: {
id: 'app.appearance-settings.lightweight-mode.description',
defaultMessage:
'Closes the launcher webview after Minecraft starts to reduce memory use. Restore it from the system tray.',
},
minimizeLauncher: {
id: 'app.appearance-settings.minimize-launcher.title',
defaultMessage: 'Minimize launcher',
},
minimizeLauncherDescription: {
id: 'app.appearance-settings.minimize-launcher.description',
defaultMessage: 'Minimize the launcher when a Minecraft process starts.',
},
})
const fetchSettings = await get()
const supportsMaximizeWindow = (await platform()) === 'windows'
const settings = ref({
...fetchSettings,
envVars: fetchSettings.custom_env_vars.map((x) => x.join('=')).join(' '),
})
watch(
settings,
async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value))
setSettings.custom_env_vars = setSettings.envVars
.trim()
.split(/\s+/)
.filter(Boolean)
.map((x: string) => x.split('=').filter(Boolean))
if (!setSettings.hooks.pre_launch) {
setSettings.hooks.pre_launch = null
}
if (!setSettings.hooks.wrapper) {
setSettings.hooks.wrapper = null
}
if (!setSettings.hooks.post_exit) {
setSettings.hooks.post_exit = null
}
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings)
},
{ deep: true },
)
</script>
<template>
<div class="flex flex-col gap-6">
<SettingsSection>
<SettingsRow>
<template #label>
<span id="settings-target-defaults-window" tabindex="-1">
{{ formatMessage(messages.fullscreen) }}
</span>
</template>
<template #description>{{ formatMessage(messages.fullscreenDescription) }}</template>
<template #control><Toggle id="fullscreen" v-model="settings.force_fullscreen" /></template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.maximizeWindow) }}</template>
<template #description>
<span :class="{ 'text-secondary': !supportsMaximizeWindow }">
{{
formatMessage(
supportsMaximizeWindow
? messages.maximizeWindowDescription
: messages.maximizeWindowUnsupported,
)
}}
</span>
</template>
<template #control>
<Toggle
id="maximize-window"
v-model="settings.maximize_window"
:disabled="settings.force_fullscreen || !supportsMaximizeWindow"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.width) }}</template>
<template #description>{{ formatMessage(messages.widthDescription) }}</template>
<template #control>
<StyledInput
id="width"
v-model="settings.game_resolution[0]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
:placeholder="formatMessage(messages.widthPlaceholder)"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.height) }}</template>
<template #description>{{ formatMessage(messages.heightDescription) }}</template>
<template #control>
<StyledInput
id="height"
v-model="settings.game_resolution[1]"
:disabled="settings.force_fullscreen"
autocomplete="off"
type="number"
:placeholder="formatMessage(messages.heightPlaceholder)"
/>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection>
<SettingsRow stacked>
<template #label>
<span id="settings-target-defaults-environment" tabindex="-1">
{{ formatMessage(messages.environmentVariables) }}
</span>
</template>
<template #control>
<StyledInput
id="env-vars"
v-model="settings.envVars"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.environmentVariablesPlaceholder)"
wrapper-class="w-full"
/>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection>
<SettingsRow stacked>
<template #label>
<span id="settings-target-defaults-launch-hooks" tabindex="-1">
{{ formatMessage(messages.preLaunchHook) }}
</span>
</template>
<template #description>{{ formatMessage(messages.preLaunchDescription) }}</template>
<template #control>
<StyledInput
id="pre-launch"
v-model="settings.hooks.pre_launch"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.preLaunchPlaceholder)"
wrapper-class="w-full"
/>
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>{{ formatMessage(messages.wrapperHook) }}</template>
<template #description>{{ formatMessage(messages.wrapperDescription) }}</template>
<template #control>
<StyledInput
id="wrapper"
v-model="settings.hooks.wrapper"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.wrapperPlaceholder)"
wrapper-class="w-full"
/>
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>{{ formatMessage(messages.postExitHook) }}</template>
<template #description>{{ formatMessage(messages.postExitDescription) }}</template>
<template #control>
<StyledInput
id="post-exit"
v-model="settings.hooks.post_exit"
autocomplete="off"
type="text"
:placeholder="formatMessage(messages.postExitPlaceholder)"
wrapper-class="w-full"
/>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection>
<SettingsRow>
<template #label>
<span id="settings-target-launch-lightweight-mode" tabindex="-1">
{{ formatMessage(messages.lightweightMode) }}
</span>
</template>
<template #description>{{ formatMessage(messages.lightweightModeDescription) }}</template>
<template #control>
<Toggle
id="enter-lightweight-mode-on-game-launch"
:model-value="settings.enter_lightweight_mode_on_game_launch"
@update:model-value="
(value) => {
settings.enter_lightweight_mode_on_game_launch = !!value
if (value) settings.hide_on_process_start = false
}
"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-launch-minimize" tabindex="-1">
{{ formatMessage(messages.minimizeLauncher) }}
</span>
</template>
<template #description>{{ formatMessage(messages.minimizeLauncherDescription) }}</template>
<template #control>
<Toggle
id="minimize-launcher"
:model-value="settings.hide_on_process_start"
:disabled="settings.enter_lightweight_mode_on_game_launch"
@update:model-value="(value) => (settings.hide_on_process_start = !!value)"
/>
</template>
</SettingsRow>
</SettingsSection>
<CrashAnalysisAISettings />
</div>
</template>

View File

@ -0,0 +1,274 @@
<script setup>
import { ArrowLeftIcon, CoffeeIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import {
commonMessages,
defineMessages,
injectNotificationManager,
NewButton as Button,
NewModal,
useVIntl,
} from '@modrinth/ui'
import { ref } from 'vue'
import AlibabaLogo from '@/assets/java-vendors/alibaba.png'
import AmazonLogo from '@/assets/java-vendors/amazon.png'
import AzulLogo from '@/assets/java-vendors/azul.png'
import BellSoftLogo from '@/assets/java-vendors/bellsoft.png'
import EclipseLogo from '@/assets/java-vendors/eclipse.png'
import GraalVmLogo from '@/assets/java-vendors/graalvm.png'
import IbmLogo from '@/assets/java-vendors/ibm.png'
import JetBrainsLogo from '@/assets/java-vendors/jetbrains.png'
import MicrosoftLogo from '@/assets/java-vendors/microsoft.png'
import OracleLogo from '@/assets/java-vendors/oracle.png'
import SapLogo from '@/assets/java-vendors/sap.png'
import { trackEvent } from '@/helpers/analytics'
import { download_java, list_java_feed_vendors, list_java_feed_versions } from '@/helpers/jre'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
downloadJava: { id: 'app.settings.java.download.title', defaultMessage: 'Download Java' },
selectVendor: {
id: 'app.settings.java.download.select-vendor',
defaultMessage: 'Choose a distribution:',
},
selectVersion: {
id: 'app.settings.java.download.select-version-feed',
defaultMessage: 'Select a version of {vendor}:',
},
back: { id: 'app.settings.java.download.back', defaultMessage: 'Back to distributions' },
loading: { id: 'app.settings.java.download.loading', defaultMessage: 'Loading...' },
noVendors: {
id: 'app.settings.java.download.no-vendors',
defaultMessage: 'No distributions available.',
},
noVersions: {
id: 'app.settings.java.download.no-versions',
defaultMessage: 'No versions available.',
},
versionLabel: {
id: 'app.settings.java.download.version-label',
defaultMessage: 'Java {version}',
},
})
const vendorBranding = {
Alibaba: { logo: AlibabaLogo, product: 'Dragonwell' },
Amazon: { logo: AmazonLogo, product: 'Corretto' },
Azul: { logo: AzulLogo, product: 'Zulu' },
BellSoft: { logo: BellSoftLogo, product: 'Liberica JDK' },
Eclipse: { logo: EclipseLogo, product: 'Temurin' },
GraalVM: { logo: GraalVmLogo, product: 'Community Edition' },
IBM: { logo: IbmLogo, product: 'Semeru' },
JetBrains: { logo: JetBrainsLogo, product: 'Runtime' },
Microsoft: { logo: MicrosoftLogo, product: 'OpenJDK' },
Oracle: { logo: OracleLogo, product: 'OpenJDK / GraalVM' },
SAP: { logo: SapLogo, product: 'SapMachine' },
}
const emit = defineEmits(['downloaded'])
const modal = ref(null)
const loading = ref(false)
const vendors = ref([])
const selectedVendor = ref(null)
const versions = ref([])
const downloading = ref(null)
let requestId = 0
async function show() {
const currentRequestId = ++requestId
selectedVendor.value = null
versions.value = []
downloading.value = null
loading.value = true
vendors.value = []
modal.value?.show()
const result = await list_java_feed_vendors().catch(handleError)
if (currentRequestId !== requestId) return
vendors.value = result || []
loading.value = false
}
defineExpose({ show })
async function selectVendor(vendor) {
const currentRequestId = ++requestId
selectedVendor.value = vendor
loading.value = true
versions.value = []
const result = await list_java_feed_versions(vendor).catch(handleError)
if (currentRequestId !== requestId || selectedVendor.value !== vendor) return
versions.value = result || []
loading.value = false
}
function backToVendors() {
requestId += 1
selectedVendor.value = null
versions.value = []
loading.value = false
}
async function downloadVersion(info) {
downloading.value = info.major_version
trackEvent('JavaDownload', { vendor: info.vendor, version: info.major_version })
modal.value?.hide()
const job = await download_java(info.vendor, info.major_version).catch(handleError)
downloading.value = null
if (job) {
emit('downloaded', job)
}
}
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.downloadJava)"
:closable="downloading === null"
max-width="720px"
width="min(720px, calc(100vw - 2rem))"
max-content-height="min(34rem, 70vh)"
scrollable
actions-divider
>
<div class="flex min-h-40 flex-col gap-4">
<template v-if="!selectedVendor">
<span class="font-semibold text-contrast">{{ formatMessage(messages.selectVendor) }}</span>
<div
v-if="loading"
class="flex min-h-32 items-center justify-center gap-2 text-sm text-secondary"
role="status"
>
<SpinnerIcon class="size-4 animate-spin" aria-hidden="true" />
{{ formatMessage(messages.loading) }}
</div>
<div
v-else-if="vendors.length === 0"
class="flex min-h-32 items-center justify-center text-sm text-secondary"
>
{{ formatMessage(messages.noVendors) }}
</div>
<div v-else class="grid grid-cols-2 gap-2 sm:grid-cols-3">
<Button
v-for="vendor in vendors"
:key="vendor"
type="base"
class="!h-16 !w-full !min-w-0 !justify-start !gap-3 !rounded-lg !px-3 !py-2 !text-left !shadow-none"
native-type="button"
@click="selectVendor(vendor)"
>
<span
class="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-md p-1"
>
<img
v-if="vendorBranding[vendor]"
:src="vendorBranding[vendor].logo"
alt=""
class="size-full object-contain"
/>
<CoffeeIcon v-else class="size-5 text-secondary" aria-hidden="true" />
</span>
<span class="flex min-w-0 flex-1 flex-col items-start text-left leading-tight">
<span class="w-full truncate text-left text-sm font-semibold text-contrast">{{
vendor
}}</span>
<span
v-if="vendorBranding[vendor]"
class="w-full truncate text-left text-xs font-normal text-secondary"
>
{{ vendorBranding[vendor].product }}
</span>
</span>
</Button>
</div>
</template>
<template v-else>
<div class="flex items-center gap-3">
<span
class="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-md p-1"
>
<img
v-if="vendorBranding[selectedVendor]"
:src="vendorBranding[selectedVendor].logo"
alt=""
class="size-full object-contain"
/>
<CoffeeIcon v-else class="size-5 text-secondary" aria-hidden="true" />
</span>
<span class="min-w-0 font-semibold text-contrast">
{{ formatMessage(messages.selectVersion, { vendor: selectedVendor }) }}
</span>
</div>
<div
v-if="loading"
class="flex min-h-32 items-center justify-center gap-2 text-sm text-secondary"
role="status"
>
<SpinnerIcon class="size-4 animate-spin" aria-hidden="true" />
{{ formatMessage(messages.loading) }}
</div>
<div
v-else-if="versions.length === 0"
class="flex min-h-32 items-center justify-center text-sm text-secondary"
>
{{ formatMessage(messages.noVersions) }}
</div>
<div v-else class="grid grid-cols-2 gap-2 sm:grid-cols-4">
<Button
v-for="info in versions"
:key="info.major_version"
type="base"
class="!h-12 !w-full !min-w-0 !rounded-lg !px-3 !shadow-none"
:disabled="downloading !== null"
native-type="button"
@click="downloadVersion(info)"
>
<SpinnerIcon
v-if="downloading === info.major_version"
class="animate-spin"
aria-hidden="true"
/>
<CoffeeIcon v-else aria-hidden="true" />
<span class="truncate text-sm font-semibold tabular-nums">
{{ formatMessage(messages.versionLabel, { version: info.major_version }) }}
</span>
</Button>
</div>
</template>
</div>
<template #actions>
<div class="flex flex-wrap justify-end gap-2">
<Button
v-if="selectedVendor"
type="outlined"
:disabled="downloading !== null"
native-type="button"
@click="backToVendors"
>
<ArrowLeftIcon aria-hidden="true" />
{{ formatMessage(messages.back) }}
</Button>
<Button
type="outlined"
:disabled="downloading !== null"
native-type="button"
@click="modal?.hide()"
>
<XIcon aria-hidden="true" />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
</div>
</template>
</NewModal>
</template>

View File

@ -0,0 +1,135 @@
<script setup lang="ts">
import { WrenchIcon } from '@modrinth/assets'
import {
defineMessages,
injectNotificationManager,
NewButton as Button,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { inject, watch } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings.ts'
import { isDev } from '@/helpers/utils'
import { handleSevereError } from '@/store/error.js'
import { useTheming } from '@/store/state'
import { DEFAULT_FEATURE_FLAGS, type FeatureFlag } from '@/store/theme.ts'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
const themeStore = useTheming()
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const isDevEnvironment = await isDev()
const previewMinecraftCrashModal = inject<() => void>('previewMinecraftCrashModal')
const previewPrivacyConsentModal = inject<() => Promise<void>>('previewPrivacyConsentModal')
const messages = defineMessages({
resetToDefault: {
id: 'app.settings.feature-flags.reset-to-default',
defaultMessage: 'Reset to default',
},
developerTools: {
id: 'app.settings.about.developer-tools',
defaultMessage: 'Developer tools',
},
testError: {
id: 'app.settings.about.test-error',
defaultMessage: 'Trigger test error',
},
testErrorMessage: {
id: 'app.settings.about.test-error-message',
defaultMessage: 'Test error triggered from the development settings.',
},
testNotificationError: {
id: 'app.settings.about.test-notification-error',
defaultMessage: 'Trigger notification test error',
},
testNotificationErrorTitle: {
id: 'app.settings.about.test-notification-error-title',
defaultMessage: 'Test notification error',
},
previewMinecraftCrashModal: {
id: 'app.settings.about.preview-minecraft-crash-modal',
defaultMessage: 'Preview Minecraft crash window',
},
previewPrivacyConsentModal: {
id: 'app.settings.about.preview-privacy-consent-modal',
defaultMessage: 'Preview privacy & security modal',
},
})
const settings = ref(await getSettings())
const options = ref<FeatureFlag[]>(Object.keys(DEFAULT_FEATURE_FLAGS))
function setFeatureFlag(key: string, value: boolean) {
themeStore.featureFlags[key] = value
settings.value.feature_flags[key] = value
}
function triggerTestError() {
handleSevereError(new Error(formatMessage(messages.testErrorMessage)))
}
function triggerTestNotificationError() {
addNotification({
title: formatMessage(messages.testNotificationErrorTitle),
text: formatMessage(messages.testErrorMessage),
type: 'error',
})
}
watch(
settings,
async () => {
await setSettings(settings.value)
},
{ deep: true },
)
</script>
<template>
<SettingsSection>
<SettingsRow v-for="option in options" :key="option">
<template #label>{{ option.replaceAll('_', ' ') }}</template>
<template #control>
<div class="flex items-center gap-2">
<Button
type="quiet"
:disabled="themeStore.getFeatureFlag(option) === DEFAULT_FEATURE_FLAGS[option]"
@click="setFeatureFlag(option, DEFAULT_FEATURE_FLAGS[option])"
>
{{ formatMessage(messages.resetToDefault) }}
</Button>
<Toggle
:id="`feature-flag-${option}`"
:model-value="themeStore.getFeatureFlag(option)"
@update:model-value="() => setFeatureFlag(option, !themeStore.getFeatureFlag(option))"
/>
</div>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection v-if="isDevEnvironment">
<template #header>
<h2 class="m-0 flex items-center gap-2 text-lg font-semibold text-contrast">
<WrenchIcon class="size-5 text-secondary" />
{{ formatMessage(messages.developerTools) }}
</h2>
</template>
<div class="flex flex-wrap gap-2 p-4">
<Button type="base" @click="triggerTestError">
<WrenchIcon /> {{ formatMessage(messages.testError) }}
</Button>
<Button type="base" @click="triggerTestNotificationError">
<WrenchIcon /> {{ formatMessage(messages.testNotificationError) }}
</Button>
<Button type="base" @click="previewMinecraftCrashModal?.()">
<WrenchIcon /> {{ formatMessage(messages.previewMinecraftCrashModal) }}
</Button>
<Button type="base" @click="previewPrivacyConsentModal?.()">
<WrenchIcon /> {{ formatMessage(messages.previewPrivacyConsentModal) }}
</Button>
</div>
</SettingsSection>
</template>

View File

@ -0,0 +1,66 @@
<script setup lang="ts">
import { useId } from 'vue'
const firstGradientId = useId()
const secondGradientId = useId()
</script>
<template>
<svg
fill="currentColor"
fill-rule="evenodd"
viewBox="0 0 84 24"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11.798 11.785a3.883 3.883 0 01-.819.083h-2.42a6.522 6.522 0 01-3.366-.935 6.738 6.738 0 01-.51-.336 6.64 6.64 0 01-2.1-2.607 8.042 8.042 0 01-.227-.58 6.619 6.619 0 01-.336-2.084V0h2.683v5.326c0 .127.004.247.02.367.014.176.04.342.084.509a3.857 3.857 0 003.735 2.976h.02a3.342 3.342 0 013.24 2.607h-.004z"
:fill="`url(#${firstGradientId})`"
/>
<path
d="M4.683 11.206v6.645H2V7.734c.087.323.226.69.3.832a7.005 7.005 0 002.383 2.644v-.004z"
fill="#1E8CFE"
/>
<path
d="M15.08 12.524v5.327h-2.683v-5.327c0-.126-.004-.25-.02-.37a3.871 3.871 0 00-1.918-2.976 3.889 3.889 0 00-1.9-.51h-.02a3.346 3.346 0 01-3.24-2.6c.263-.056.536-.082.819-.082h2.42c1.225 0 2.38.342 3.366.935.176.103.346.216.51.336a6.578 6.578 0 012.097 2.607 6.487 6.487 0 01.57 2.66z"
:fill="`url(#${secondGradientId})`"
/>
<path
d="M35.635 17.585c0 .533-.017.999-.053 1.405a9.743 9.743 0 01-.143 1.069c-.187.849-.546 1.574-1.086 2.184C33.335 23.414 31.936 24 30.155 24c-1.505 0-2.74-.406-3.712-1.215-1.002-.832-1.581-1.984-1.734-3.455h2.613c.1.556.266.982.496 1.281.54.703 1.325 1.052 2.36 1.052 1.905 0 2.857-1.168 2.857-3.502V16.59c-1.032 1.055-2.224 1.584-3.572 1.584-1.535 0-2.79-.556-3.769-1.664-.985-1.126-1.481-2.534-1.481-4.222s.46-3.036 1.375-4.184c.985-1.219 2.29-1.828 3.908-1.828 1.418 0 2.597.53 3.539 1.585V6.589h2.6v10.996zm-2.497-5.34c0-1.095-.293-1.97-.879-2.623-.592-.67-1.351-1.006-2.277-1.006-.985 0-1.764.366-2.337 1.099-.516.656-.776 1.501-.776 2.543 0 1.042.26 1.865.776 2.52.563.716 1.342 1.076 2.337 1.076s1.781-.363 2.357-1.089c.533-.656.8-1.495.8-2.52zM38.917 6.588h2.603v1.006c.476-.5.903-.842 1.272-1.029.376-.193.826-.29 1.342-.29.686 0 1.401.224 2.15.67l-1.191 2.38c-.493-.353-.976-.533-1.445-.533-1.418 0-2.128 1.072-2.128 3.213v5.84h-2.603V6.587z"
/>
<path
d="M18.453 2.37c0-.456.166-.849.496-1.178.33-.333.73-.496 1.192-.496.463 0 .872.166 1.202.496.333.323.496.722.496 1.192s-.167.872-.496 1.202a1.59 1.59 0 01-1.192.496c-.473 0-.872-.167-1.202-.496a1.647 1.647 0 01-.496-1.216zm3.083 4.222V17.85h-2.777V6.588h2.777v.003z"
fill="#0418FF"
/>
<path
d="M57.867 12.831h-8.07c.07.925.37 1.661.902 2.207.533.54 1.215.81 2.048.81.645 0 1.181-.154 1.608-.464.3-.223.626-.582.865-1.072h2.68c-.173.82-.679 1.605-1.039 2.038-.359.433-.745.789-1.178 1.052-.433.266-.899.46-1.398.583a6.834 6.834 0 01-1.631.186c-1.682 0-3.03-.54-4.046-1.618-1.018-1.089-1.528-2.527-1.528-4.325 0-1.797.493-3.222 1.482-4.324.995-1.089 2.314-1.631 3.955-1.631s2.966.529 3.932 1.584c.955 1.049 1.435 2.5 1.435 4.358l-.014.613-.003.003zm-2.67-2.127c-.363-1.389-1.239-2.081-2.623-2.081-.317 0-.613.047-.89.143-.276.097-.529.236-.758.416-.227.18-.423.4-.583.653a2.942 2.942 0 00-.37.865H55.2l-.003.004zM65.501 18.154c-3 0-4.76-1.655-5.204-3.852h2.787c.38.846 1.328 1.425 2.524 1.425s1.907-.573 1.907-1.255c0-.546-.639-1.085-2.21-1.342-2.248-.37-3.263-.835-3.895-1.661-.563-.73-.693-1.628-.533-2.434.313-1.608 1.87-2.96 4.75-2.776 2.587.163 4.129 2.234 4.272 3.858h-2.554c-.22-.792-.785-1.355-1.884-1.521-.942-.143-1.92.153-1.974.949-.053.795.736 1.055 2.19 1.295 3.806.626 4.558 2.36 4.522 3.709-.06 2.197-2.038 3.612-4.701 3.612l.003-.007zM77.199 18.154c-3 0-4.76-1.655-5.204-3.852h2.787c.38.846 1.328 1.425 2.523 1.425s1.908-.573 1.908-1.255c0-.546-.64-1.085-2.21-1.342-2.248-.37-3.263-.835-3.896-1.661-.562-.73-.692-1.628-.532-2.434.313-1.608 1.87-2.96 4.75-2.776 2.587.163 4.129 2.234 4.272 3.858h-2.554c-.22-.792-.785-1.355-1.884-1.521-.942-.143-1.92.153-1.974.949-.054.795.736 1.055 2.19 1.295 3.806.626 4.558 2.36 4.521 3.709-.06 2.197-2.037 3.612-4.7 3.612l.003-.007z"
/>
<path
d="M12.397 6.645V0h2.683v10.117a5.284 5.284 0 00-.3-.832 7.006 7.006 0 00-2.383-2.644v.004z"
fill="#1E8CFE"
/>
<defs>
<linearGradient
:id="firstGradientId"
gradientUnits="userSpaceOnUse"
x1="2.02"
x2="11.798"
y1="5.933"
y2="5.933"
>
<stop offset="0" stop-color="#0418FF" />
<stop offset="1" stop-color="#1E8CFE" />
</linearGradient>
<linearGradient
:id="secondGradientId"
gradientUnits="userSpaceOnUse"
x1="15.08"
x2="5.299"
y1="11.918"
y2="11.918"
>
<stop offset="0" stop-color="#0418FF" />
<stop offset="1" stop-color="#1E8CFE" />
</linearGradient>
</defs>
</svg>
</template>

View File

@ -0,0 +1,7 @@
<script setup lang="ts">
import AppearanceSettings from './AppearanceSettings.vue'
</script>
<template>
<AppearanceSettings scope="home-navigation" />
</template>

View File

@ -0,0 +1,158 @@
<script setup>
import { SpinnerIcon, TrashIcon } from '@modrinth/assets'
import {
commonMessages,
defineMessages,
IconButton,
injectNotificationManager,
NewButton as Button,
NewModal,
Table,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { get_java_versions, remove_java_version } from '@/helpers/jre'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: {
id: 'app.settings.java.installed.title',
defaultMessage: 'Installed Java versions',
},
version: {
id: 'app.settings.java.table.version',
defaultMessage: 'Java Version',
},
distribution: {
id: 'app.settings.java.table.distribution',
defaultMessage: 'Distribution',
},
path: {
id: 'app.settings.java.table.path',
defaultMessage: 'Path',
},
actions: {
id: 'app.settings.java.table.actions',
defaultMessage: '',
},
loading: {
id: 'app.settings.java.installed.loading',
defaultMessage: 'Loading installed Java versions...',
},
empty: {
id: 'app.settings.java.installed.empty',
defaultMessage: 'No Java installations found.',
},
remove: {
id: 'app.settings.java.installed.remove',
defaultMessage: 'Remove Java installation',
},
})
const emit = defineEmits(['changed'])
const modal = ref(null)
const loading = ref(false)
const javaVersions = ref([])
const columns = [
{ key: 'parsed_version', label: formatMessage(messages.version), width: '8rem' },
{ key: 'distribution', label: formatMessage(messages.distribution) },
{ key: 'path', label: formatMessage(messages.path) },
{ key: 'actions', label: formatMessage(messages.actions), align: 'right', width: '3rem' },
]
const tableData = computed(() =>
javaVersions.value
.map((javaVersion) => ({
...javaVersion,
distribution: javaVersion.distribution || null,
}))
.sort((a, b) => b.parsed_version - a.parsed_version || a.path.localeCompare(b.path)),
)
async function reload() {
loading.value = true
const versions = await get_java_versions().catch(handleError)
if (versions) javaVersions.value = versions
loading.value = false
}
async function show() {
modal.value?.show()
await reload()
}
async function removeEntry(javaVersion) {
const removed = await remove_java_version(javaVersion.path)
.then(() => true)
.catch((error) => {
handleError(error)
return false
})
if (!removed) return
javaVersions.value = javaVersions.value.filter((item) => item.path !== javaVersion.path)
emit('changed')
}
defineExpose({ show })
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
max-width="860px"
width="min(860px, calc(100vw - 2rem))"
max-content-height="min(34rem, 70vh)"
scrollable
actions-divider
>
<div v-if="loading" class="flex min-h-40 items-center justify-center gap-2 text-secondary">
<SpinnerIcon class="size-4 animate-spin" aria-hidden="true" />
{{ formatMessage(messages.loading) }}
</div>
<Table v-else :columns="columns" :data="tableData" row-key="path">
<template #cell-parsed_version="{ value }">
<span class="font-semibold tabular-nums">Java {{ value }}</span>
</template>
<template #cell-distribution="{ value }">
<span class="text-sm">{{ value || '-' }}</span>
</template>
<template #cell-path="{ value }">
<span v-tooltip="value" class="block max-w-96 truncate font-mono text-xs">
{{ value }}
</span>
</template>
<template #cell-actions="{ row }">
<IconButton
v-tooltip="formatMessage(messages.remove)"
type="quiet"
color="red"
interaction="filled"
:label="formatMessage(messages.remove)"
native-type="button"
@click="removeEntry(row)"
>
<TrashIcon aria-hidden="true" />
</IconButton>
</template>
<template #empty-state>
<div class="py-8 text-center text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</div>
</template>
</Table>
<template #actions>
<div class="flex justify-end">
<Button type="outlined" native-type="button" @click="modal?.hide()">
{{ formatMessage(commonMessages.closeButton) }}
</Button>
</div>
</template>
</NewModal>
</template>

View File

@ -0,0 +1,491 @@
<script setup>
import { DownloadIcon, FolderSearchIcon, ListIcon, ScanEyeIcon, SearchIcon } from '@modrinth/assets'
import {
Checkbox,
defineMessages,
injectNotificationManager,
NewButton as Button,
Slider,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { platform } from '@tauri-apps/plugin-os'
import { ref, watch } from 'vue'
import JavaArgumentsInput from '@/components/ui/JavaArgumentsInput.vue'
import JavaSelector from '@/components/ui/JavaSelector.vue'
import MemoryAllocationDisplay from '@/components/ui/MemoryAllocationDisplay.vue'
import DownloadJavaModal from '@/components/ui/settings/DownloadJavaModal.vue'
import InstalledJavaModal from '@/components/ui/settings/InstalledJavaModal.vue'
import useMemorySlider from '@/composables/useMemorySlider'
import { trackEvent } from '@/helpers/analytics'
import { collectGcContext } from '@/helpers/gc/context'
import { wait_for_install_job } from '@/helpers/install'
import { getJavaArgumentPresets } from '@/helpers/java-argument-presets'
import {
find_filtered_jres,
get_java_default_versions,
get_jre,
remove_java_default_version,
set_java_default_version,
set_java_version,
} from '@/helpers/jre'
import { get, set } from '@/helpers/settings.ts'
const { handleError } = injectNotificationManager()
const { formatMessage } = useVIntl()
const messages = defineMessages({
javaLocation: {
id: 'app.settings.java.location',
defaultMessage: 'Java {version} location',
},
findJava: {
id: 'app.settings.java.find-java',
defaultMessage: 'Find Java',
},
deepScan: {
id: 'app.settings.java.deep-scan',
defaultMessage: 'Deep Scan',
},
manualAdd: {
id: 'app.settings.java.manual-add',
defaultMessage: 'Manual Add',
},
downloadJava: {
id: 'app.settings.java.download-java',
defaultMessage: 'Download Java',
},
viewInstalled: {
id: 'app.settings.java.view-installed',
defaultMessage: 'View installed Java',
},
autoHighPerformanceMode: {
id: 'app.settings.java.auto-high-performance-mode',
defaultMessage: 'Automatically use high-performance GPU for Java',
},
autoHighPerformanceModeDescription: {
id: 'app.settings.java.auto-high-performance-mode-description',
defaultMessage:
'Uses the high-performance GPU for Minecraft when it launches. Supported on Windows and Linux.',
},
scanning: {
id: 'app.settings.java.scanning',
defaultMessage: 'Scanning...',
},
deepScanConfirm: {
id: 'app.settings.java.deep-scan-confirm',
defaultMessage: 'This will scan ALL directories on ALL drives. May take several minutes.',
},
scanAnyway: {
id: 'app.settings.java.scan-anyway',
defaultMessage: 'Scan Anyway',
},
cancel: {
id: 'app.settings.java.cancel',
defaultMessage: 'Cancel',
},
memory: {
id: 'app.settings.defaults.memory',
defaultMessage: 'Memory allocated',
},
memoryDescription: {
id: 'app.settings.defaults.memory-description',
defaultMessage: 'The memory allocated to each instance when it is run.',
},
automaticMemory: {
id: 'app.settings.defaults.automatic-memory',
defaultMessage: 'Automatically allocate memory at launch',
},
automaticMemoryDescription: {
id: 'app.settings.defaults.automatic-memory-description',
defaultMessage: 'Adjusts memory for each launch based on available RAM and installed mods.',
},
optimizeMemoryBeforeLaunch: {
id: 'app.settings.defaults.optimize-memory-before-launch',
defaultMessage: 'Optimize memory before launching the game',
},
optimizeMemoryBeforeLaunchDescription: {
id: 'app.settings.defaults.optimize-memory-before-launch-description',
defaultMessage: 'Waits for Windows memory optimization to finish before starting the game.',
},
javaArguments: {
id: 'app.settings.defaults.java-arguments',
defaultMessage: 'Java arguments',
},
javaArgumentsPlaceholder: {
id: 'app.settings.defaults.java-arguments-placeholder',
defaultMessage: 'Enter Java arguments...',
},
})
const supportedJavaVersions = [25, 21, 17, 8]
const javaDefaults = ref({})
const scanning = ref(false)
const scanMode = ref('')
const showDeepScanConfirm = ref(false)
const downloadJavaModal = ref(null)
const installedJavaModal = ref(null)
const defaultSaveQueues = new Map()
const currentPlatform = await platform()
const supportsHighPerformanceMode = ['windows', 'linux'].includes(currentPlatform)
const supportsMemoryOptimization = currentPlatform === 'windows'
const settings = ref(await get().catch(handleError))
const autoHighPerformanceMode = ref(settings.value?.auto_set_java_high_performance_mode ?? false)
const javaArgs = ref((settings.value?.extra_launch_args ?? []).join(' '))
const memory = ref(
settings.value?.memory
? { optimize_before_launch: false, ...settings.value.memory }
: { maximum: 2048, automatic: true, optimize_before_launch: false },
)
let shouldApplyDefaultAuto = (settings.value?.extra_launch_args?.length ?? 0) === 0
const memorySlider = await useMemorySlider().catch(handleError)
const maxMemory = memorySlider?.maxMemory ?? 4096
const snapPoints = memorySlider?.snapPoints ?? []
const gcContext = ref(undefined)
async function updateGcContext() {
gcContext.value = await collectGcContext(memory.value.maximum, null, null, 0)
if (shouldApplyDefaultAuto) {
const autoPreset = getJavaArgumentPresets(gcContext.value).find(
(preset) => preset.id === 'gc-auto',
)
if (autoPreset) {
javaArgs.value = autoPreset.resolveArgs
? autoPreset.resolveArgs(gcContext.value)
: autoPreset.args
}
shouldApplyDefaultAuto = false
}
}
await updateGcContext()
watch(() => memory.value.maximum, updateGcContext)
watch(
[autoHighPerformanceMode, memory, javaArgs],
async () => {
if (!settings.value) return
settings.value = {
...settings.value,
auto_set_java_high_performance_mode: autoHighPerformanceMode.value,
memory: memory.value,
extra_launch_args: javaArgs.value.trim().split(/\s+/).filter(Boolean),
}
await set(settings.value).catch(handleError)
},
{ deep: true },
)
async function reloadDefaults() {
const defaults = await get_java_default_versions().catch(handleError)
if (!defaults) return
javaDefaults.value = Object.fromEntries(
defaults.map((javaVersion) => [javaVersion.parsed_version, javaVersion]),
)
}
await reloadDefaults()
async function persistDefault(majorVersion, javaVersion) {
const path = javaVersion?.path?.trim()
if (!path) {
const removed = await remove_java_default_version(majorVersion)
.then(() => true)
.catch((error) => {
handleError(error)
return false
})
if (removed) {
javaDefaults.value[majorVersion] = undefined
} else {
await reloadDefaults()
}
return
}
const validated = await set_java_default_version(majorVersion, path).catch((error) => {
handleError(error)
return null
})
if (validated) {
javaDefaults.value[majorVersion] = validated
} else {
await reloadDefaults()
}
}
function saveDefault(majorVersion, javaVersion) {
const previous = defaultSaveQueues.get(majorVersion) ?? Promise.resolve()
const operation = previous.then(() => persistDefault(majorVersion, javaVersion))
defaultSaveQueues.set(majorVersion, operation)
return operation.finally(() => {
if (defaultSaveQueues.get(majorVersion) === operation) {
defaultSaveQueues.delete(majorVersion)
}
})
}
async function runScan(exhaustive) {
if (exhaustive) {
showDeepScanConfirm.value = true
return
}
scanning.value = true
scanMode.value = 'quick'
trackEvent('JavaQuickScan', { source: 'settings' })
try {
await find_filtered_jres(null, false, true, false).catch(handleError)
} finally {
scanning.value = false
scanMode.value = ''
}
}
async function confirmDeepScan() {
showDeepScanConfirm.value = false
scanning.value = true
scanMode.value = 'deep'
trackEvent('JavaDeepScan', { source: 'settings' })
try {
await find_filtered_jres(null, true, true, true).catch(handleError)
} finally {
scanning.value = false
scanMode.value = ''
}
}
async function handleManualAdd() {
const result = await open({ multiple: false })
if (!result) return
const filePath = result.path ?? result
const javaInfo = await get_jre(filePath).catch(handleError)
if (!javaInfo) return
await set_java_version(javaInfo).catch(handleError)
trackEvent('JavaManualSelect', { path: filePath })
}
async function onJavaDownloaded(job) {
if (job?.job_id) {
await wait_for_install_job(job.job_id).catch(handleError)
}
await reloadDefaults()
}
</script>
<template>
<DownloadJavaModal ref="downloadJavaModal" @downloaded="onJavaDownloaded" />
<InstalledJavaModal ref="installedJavaModal" @changed="reloadDefaults" />
<div class="settings-page flex flex-col gap-6">
<div
v-for="(javaVersion, index) in supportedJavaVersions"
:key="`java-${javaVersion}`"
class="flex flex-col gap-2.5"
>
<h2 class="m-0 text-lg font-semibold text-contrast" :class="{ 'mt-2': index !== 0 }">
{{ formatMessage(messages.javaLocation, { version: javaVersion }) }}
</h2>
<JavaSelector
:id="`java-selector-${javaVersion}`"
v-model="javaDefaults[javaVersion]"
:version="javaVersion"
@commit="saveDefault(javaVersion, $event)"
/>
</div>
<div class="flex flex-wrap gap-2 border-0 border-t border-solid border-button-border pt-5">
<Button
type="base"
native-type="button"
class="!shadow-none"
:disabled="scanning"
@click="runScan(false)"
>
<SearchIcon aria-hidden="true" />
{{
scanning && scanMode === 'quick'
? formatMessage(messages.scanning)
: formatMessage(messages.findJava)
}}
</Button>
<Button
type="base"
native-type="button"
class="!shadow-none"
:disabled="scanning"
@click="runScan(true)"
>
<ScanEyeIcon aria-hidden="true" />
{{
scanning && scanMode === 'deep'
? formatMessage(messages.scanning)
: formatMessage(messages.deepScan)
}}
</Button>
<Button
type="base"
native-type="button"
class="!shadow-none"
:disabled="scanning"
@click="handleManualAdd"
>
<FolderSearchIcon aria-hidden="true" />
{{ formatMessage(messages.manualAdd) }}
</Button>
<Button
type="base"
native-type="button"
class="!shadow-none"
:disabled="scanning"
@click="downloadJavaModal?.show()"
>
<DownloadIcon aria-hidden="true" />
{{ formatMessage(messages.downloadJava) }}
</Button>
<Button
type="base"
native-type="button"
class="!shadow-none"
@click="installedJavaModal?.show()"
>
<ListIcon aria-hidden="true" />
{{ formatMessage(messages.viewInstalled) }}
</Button>
</div>
<div
v-if="showDeepScanConfirm"
class="flex flex-col gap-2 rounded-lg border border-warning bg-warning/10 p-3 text-sm"
>
<span>{{ formatMessage(messages.deepScanConfirm) }}</span>
<div class="flex flex-wrap gap-2">
<Button type="colored" color="red" native-type="button" @click="confirmDeepScan">
{{ formatMessage(messages.scanAnyway) }}
</Button>
<Button type="outlined" native-type="button" @click="showDeepScanConfirm = false">
{{ formatMessage(messages.cancel) }}
</Button>
</div>
</div>
<div
v-if="supportsHighPerformanceMode"
class="border-0 border-t border-solid border-button-border pt-5"
>
<div class="flex items-center justify-between gap-4">
<div class="flex min-w-0 flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.autoHighPerformanceMode) }}
</span>
<span class="text-xs text-secondary">
{{ formatMessage(messages.autoHighPerformanceModeDescription) }}
</span>
</div>
<Toggle id="auto-java-high-performance-mode" v-model="autoHighPerformanceMode" />
</div>
</div>
<div class="flex flex-col gap-6 border-0 border-t border-solid border-button-border pt-5">
<div class="flex flex-col gap-2.5">
<h2
id="settings-target-java-memory"
tabindex="-1"
class="m-0 text-lg font-semibold text-contrast"
>
{{ formatMessage(messages.memory) }}
</h2>
<Checkbox v-model="memory.automatic" :label="formatMessage(messages.automaticMemory)" />
<div v-if="supportsMemoryOptimization" class="flex flex-col gap-1">
<Checkbox
v-model="memory.optimize_before_launch"
:label="formatMessage(messages.optimizeMemoryBeforeLaunch)"
/>
<p class="m-0 text-xs leading-tight text-secondary">
{{ formatMessage(messages.optimizeMemoryBeforeLaunchDescription) }}
</p>
</div>
<Slider
id="max-memory"
v-model="memory.maximum"
:disabled="memory.automatic"
:min="512"
:max="maxMemory"
:step="64"
:snap-points="snapPoints"
:snap-range="512"
unit="MB"
/>
<p class="m-0 mt-1 leading-tight">
{{
formatMessage(
memory.automatic ? messages.automaticMemoryDescription : messages.memoryDescription,
)
}}
</p>
<MemoryAllocationDisplay :memory="memory" show-optimize-button />
</div>
<div class="flex flex-col gap-2.5">
<h2
id="settings-target-java-arguments"
tabindex="-1"
class="m-0 text-lg font-semibold text-contrast"
>
{{ formatMessage(messages.javaArguments) }}
</h2>
<JavaArgumentsInput
id="java-args"
v-model="javaArgs"
:gc-context="gcContext"
:placeholder="formatMessage(messages.javaArgumentsPlaceholder)"
/>
</div>
</div>
</div>
</template>
<style scoped>
.settings-page > div {
padding: var(--gap-lg);
border: 1px solid
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
border-radius: var(--radius-md);
background: var(--surface-2);
}
.settings-page > div:has(.border-warning) {
padding: var(--gap-md);
background: var(--color-orange-bg);
}
@media (max-width: 700px) {
.settings-page :deep(.flex.items-center.justify-between) {
align-items: flex-start;
flex-direction: column;
}
}
</style>

View File

@ -0,0 +1,114 @@
<script setup lang="ts">
import {
Admonition,
AutoLink,
commonSettingsMessages,
IntlFormatted,
LanguageSelector,
languageSelectorMessages,
LOCALES,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import i18n from '@/i18n.config'
import SettingsSaveStatus from './SettingsSaveStatus.vue'
const { formatMessage } = useVIntl()
const platform = computed(() => formatMessage(languageSelectorMessages.platformApp))
const settings = ref(await get())
const $isChanging = ref(false)
const saveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle')
const retryLocale = ref<string | null>(null)
async function onLocaleChange(newLocale: string) {
if (settings.value.locale === newLocale) return
const previousLocale = settings.value.locale
$isChanging.value = true
saveStatus.value = 'saving'
retryLocale.value = null
try {
i18n.global.locale.value = newLocale
settings.value.locale = newLocale
await set(settings.value)
saveStatus.value = 'saved'
} catch {
i18n.global.locale.value = previousLocale
settings.value.locale = previousLocale
retryLocale.value = newLocale
saveStatus.value = 'error'
} finally {
$isChanging.value = false
}
}
function retrySave() {
if (retryLocale.value) void onLocaleChange(retryLocale.value)
}
</script>
<template>
<div class="flex flex-col gap-3">
<header class="settings-page-header">
<h2 id="settings-target-language" tabindex="-1" class="m-0 text-lg font-semibold text-contrast">
{{ formatMessage(commonSettingsMessages.language) }}
</h2>
<SettingsSaveStatus :status="saveStatus" :retry="retrySave" />
</header>
<div class="settings-page-card">
<Admonition type="warning">
{{ formatMessage(languageSelectorMessages.languageWarning, { platform }) }}
</Admonition>
<p class="settings-page-description">
<IntlFormatted
:message-id="languageSelectorMessages.languagesDescription"
:values="{ platform }"
>
<template #~crowdin-link="{ children }">
<AutoLink to="https://translate.modrinth.com">
<component :is="() => children" />
</AutoLink>
</template>
</IntlFormatted>
</p>
<LanguageSelector
:current-locale="settings.locale"
:locales="LOCALES"
:on-locale-change="onLocaleChange"
:is-changing="$isChanging"
/>
</div>
</div>
</template>
<style scoped>
.settings-page-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gap-md);
}
.settings-page-card {
display: flex;
flex-direction: column;
gap: var(--gap-lg);
padding: var(--gap-lg);
border: 1px solid
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
border-radius: var(--radius-md);
background: var(--surface-2);
}
.settings-page-description {
margin: 0;
color: var(--color-secondary);
font-size: 0.875rem;
line-height: 1.5;
}
</style>

View File

@ -0,0 +1,11 @@
<script setup lang="ts">
import LanguageSettings from './LanguageSettings.vue'
import TranslationSettings from './TranslationSettings.vue'
</script>
<template>
<div class="flex flex-col gap-6">
<LanguageSettings />
<TranslationSettings />
</div>
</template>

View File

@ -0,0 +1,136 @@
<script setup lang="ts">
import { type Component, computed } from 'vue'
import { lobeAvatarBrands, lobeCombineBrands } from '@/data/lobeProviderIcons'
import HigressTextColor from './HigressTextColor.vue'
const props = withDefaults(
defineProps<{
brand: string
extra?: string
extraFontSize?: number
extraMarginLeft?: number
size: number
}>(),
{ extra: '', extraFontSize: undefined, extraMarginLeft: undefined },
)
const iconModules = import.meta.glob(
'../../../../node_modules/@lobehub/icons-static-svg/icons/*.svg',
{
eager: true,
import: 'default',
query: '?component',
},
) as Record<string, Component>
const iconComponents = Object.fromEntries(
Object.entries(iconModules).map(([path, component]) => [
path.split('/').pop()?.replace('.svg', ''),
component,
]),
) as Record<string, Component>
const config = computed(() => lobeCombineBrands[props.brand])
const brandAvatar = computed(() => lobeAvatarBrands[props.brand])
const standaloneComponent = computed(() =>
config.value?.standalone ? iconComponents[config.value.standalone] : undefined,
)
const logoComponent = computed(() =>
config.value?.logo ? iconComponents[config.value.logo] : undefined,
)
const textComponent = computed(() =>
config.value?.text ? iconComponents[config.value.text] : undefined,
)
const avatarComponent = computed(() => {
if (!config.value?.avatar || !brandAvatar.value) return undefined
const suffix = brandAvatar.value.asset === 'color' ? '-color' : ''
return iconComponents[`${props.brand}${suffix}`] ?? iconComponents[props.brand]
})
const standaloneSize = computed(() => props.size * (config.value?.textMultiple ?? 1))
const textSize = computed(() => props.size * (config.value?.textMultiple ?? 1))
const logoMargin = computed(() => props.size * (config.value?.spaceMultiple ?? 1))
const extraStyle = computed(() => ({
fontSize: `${props.extraFontSize ?? textSize.value * 0.95}px`,
marginLeft: props.extraMarginLeft === undefined ? undefined : `${props.extraMarginLeft}px`,
}))
</script>
<template>
<span
v-if="config"
class="lobe-brand-combine inline-flex min-w-0 flex-none items-center justify-start"
:class="{ inverse: config.inverse }"
:style="{ color: config.color }"
>
<HigressTextColor
v-if="brand === 'higress'"
class="lobe-brand-standalone"
:style="{ height: `${standaloneSize}px` }"
/>
<component
:is="standaloneComponent"
v-else-if="standaloneComponent"
class="lobe-brand-standalone"
:style="{ height: `${standaloneSize}px` }"
/>
<template v-else>
<span
v-if="avatarComponent && brandAvatar"
class="lobe-brand-avatar inline-flex flex-none items-center justify-center overflow-hidden"
:style="{
background: brandAvatar.background,
borderRadius: `${Math.floor(size * 0.1)}px`,
color: brandAvatar.color,
height: `${size}px`,
marginLeft: config.inverse ? `${logoMargin}px` : undefined,
marginRight: config.inverse ? undefined : `${logoMargin}px`,
width: `${size}px`,
}"
>
<component
:is="avatarComponent"
:style="{
height: `${size}px`,
transform: `scale(${brandAvatar.multiple})`,
width: `${size}px`,
}"
/>
</span>
<component
:is="logoComponent"
v-else-if="logoComponent"
class="lobe-brand-logo object-contain"
:style="{
height: `${size}px`,
marginLeft: config.inverse ? `${logoMargin}px` : undefined,
marginRight: config.inverse ? undefined : `${logoMargin}px`,
width: `${size}px`,
}"
/>
<component
:is="textComponent"
v-if="textComponent"
class="lobe-brand-text"
:style="{ height: `${textSize}px` }"
/>
</template>
<span v-if="extra" class="flex-none leading-none" :style="extraStyle">{{ extra }}</span>
</span>
</template>
<style scoped>
.lobe-brand-combine.inverse {
flex-direction: row-reverse;
}
.lobe-brand-avatar > :deep(svg),
.lobe-brand-logo,
.lobe-brand-text,
.lobe-brand-standalone {
display: block;
flex: none;
width: auto;
}
</style>

View File

@ -0,0 +1,151 @@
<script setup lang="ts">
import { SaveIcon, SpinnerIcon } from '@modrinth/assets'
import {
defineMessages,
injectNotificationManager,
NewButton as Button,
StyledInput,
useVIntl,
} from '@modrinth/ui'
import { computed, ref } from 'vue'
import { get as getSettings, set as setSettings } from '@/helpers/settings'
import { parseTerracottaPublicNodes } from '@/helpers/terracotta'
import SettingsSection from './SettingsSection.vue'
const { formatMessage } = useVIntl()
const { addNotification, handleError } = injectNotificationManager()
const messages = defineMessages({
publicNodes: {
id: 'app.multiplayer.terracotta.public-nodes',
defaultMessage: 'Terracotta public nodes',
},
publicNodesDescription: {
id: 'app.multiplayer.terracotta.public-nodes-description',
defaultMessage:
'Enter one node URI per line. Changes apply the next time you host or join a room. Leave empty to use only Terracotta defaults.',
},
publicNodesPlaceholder: {
id: 'app.multiplayer.terracotta.public-nodes-placeholder',
defaultMessage: 'wss://center.node.1tmc.top',
},
publicNodeInvalid: {
id: 'app.multiplayer.terracotta.public-node-invalid',
defaultMessage: 'Invalid node URI: {node}. Use http, https, tcp, tls, udp, ws, or wss.',
},
savePublicNodes: {
id: 'app.multiplayer.terracotta.save-public-nodes',
defaultMessage: 'Save nodes',
},
publicNodesSaved: {
id: 'app.multiplayer.terracotta.public-nodes-saved',
defaultMessage: 'Terracotta public nodes saved',
},
})
const initialSettings = await getSettings()
const savedPublicNodes = ref(initialSettings.terracotta_public_nodes)
const publicNodesInput = ref(savedPublicNodes.value.join('\n'))
const publicNodesTouched = ref(false)
const isSavingPublicNodes = ref(false)
const parsedPublicNodes = computed(() => parseTerracottaPublicNodes(publicNodesInput.value))
const publicNodesChanged = computed(
() => publicNodesInput.value !== savedPublicNodes.value.join('\n'),
)
const showPublicNodesError = computed(
() => publicNodesTouched.value && parsedPublicNodes.value.invalidNode !== null,
)
async function savePublicNodes() {
publicNodesTouched.value = true
if (parsedPublicNodes.value.invalidNode || isSavingPublicNodes.value) return
isSavingPublicNodes.value = true
try {
const settings = await getSettings()
settings.terracotta_public_nodes = parsedPublicNodes.value.nodes
await setSettings(settings)
savedPublicNodes.value = [...parsedPublicNodes.value.nodes]
publicNodesInput.value = savedPublicNodes.value.join('\n')
addNotification({
type: 'success',
title: formatMessage(messages.publicNodesSaved),
})
} catch (error) {
handleError(error)
} finally {
isSavingPublicNodes.value = false
}
}
</script>
<template>
<SettingsSection>
<template #header>
<h2
id="terracotta-public-nodes-title"
tabindex="-1"
class="m-0 text-lg font-semibold text-contrast"
>
{{ formatMessage(messages.publicNodes) }}
</h2>
<p
id="terracotta-public-nodes-description"
class="m-0 mt-1 text-sm leading-relaxed text-secondary"
>
{{ formatMessage(messages.publicNodesDescription) }}
</p>
</template>
<div class="flex flex-col gap-3 p-4">
<StyledInput
id="terracotta-public-nodes"
v-model="publicNodesInput"
multiline
resize="vertical"
:rows="5"
:error="showPublicNodesError"
:placeholder="formatMessage(messages.publicNodesPlaceholder)"
:input-attrs="{
'aria-labelledby': 'terracotta-public-nodes-title',
'aria-invalid': showPublicNodesError,
'aria-describedby': showPublicNodesError
? 'terracotta-public-nodes-description terracotta-public-nodes-error'
: 'terracotta-public-nodes-description',
}"
:spellcheck="false"
wrapper-class="w-full"
@input="publicNodesTouched = true"
/>
<p
v-if="showPublicNodesError"
id="terracotta-public-nodes-error"
class="m-0 text-sm text-red"
>
{{ formatMessage(messages.publicNodeInvalid, { node: parsedPublicNodes.invalidNode }) }}
</p>
<div class="settings-actions flex justify-end pt-3">
<Button
type="colored"
color="brand"
native-type="button"
:disabled="!publicNodesChanged || !!parsedPublicNodes.invalidNode || isSavingPublicNodes"
@click="savePublicNodes"
>
<SpinnerIcon v-if="isSavingPublicNodes" class="animate-spin" />
<SaveIcon v-else />
{{ formatMessage(messages.savePublicNodes) }}
</Button>
</div>
</div>
</SettingsSection>
</template>
<style scoped>
.settings-actions {
border-top: 1px solid
var(--settings-divider, color-mix(in srgb, var(--surface-4) 55%, transparent));
}
</style>

View File

@ -0,0 +1,11 @@
<script setup lang="ts">
import MultiplayerSettings from './MultiplayerSettings.vue'
import ResourceManagementSettings from './ResourceManagementSettings.vue'
</script>
<template>
<div class="flex flex-col gap-6">
<ResourceManagementSettings scope="network-multiplayer" />
<MultiplayerSettings />
</div>
</template>

View File

@ -0,0 +1,156 @@
<script setup lang="ts">
import { defineMessages, injectNotificationManager, Toggle, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { getPrivacySettings, setDiscordRpcEnabled, setTelemetryEnabled } from '@/helpers/settings'
import SettingsRow from './SettingsRow.vue'
import SettingsSaveStatus from './SettingsSaveStatus.vue'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const privacy = ref(await getPrivacySettings())
const telemetrySaving = ref(false)
const discordSaving = ref(false)
const lastSaveState = ref<'idle' | 'saved' | 'error'>('idle')
const retrySave = ref<(() => void) | undefined>()
const messages = defineMessages({
telemetry: {
id: 'app.settings.privacy.telemetry',
defaultMessage: 'Allow telemetry',
},
telemetryDescription: {
id: 'app.settings.privacy.telemetry-description',
defaultMessage:
'Send one anonymous daily activity signal to improve usage statistics. Minecraft logs and account credentials are never uploaded.',
},
discordRpc: {
id: 'app.settings.privacy.discord-rpc',
defaultMessage: 'Discord Rich Presence',
},
discordRpcDescription: {
id: 'app.settings.privacy.discord-rpc-description',
defaultMessage: 'Show your current launcher or game activity in Discord.',
},
dataHandling: {
id: 'app.settings.privacy.data-handling',
defaultMessage:
'Telemetry uses a random installation identifier and sends only a daily activity signal. Turning telemetry off clears pending data immediately.',
},
})
const saveStatus = computed(() => {
if (telemetrySaving.value || discordSaving.value) return 'saving'
return lastSaveState.value
})
async function updateTelemetry(value: boolean) {
if (telemetrySaving.value) return
const previous = privacy.value.telemetry
privacy.value.telemetry = value
telemetrySaving.value = true
lastSaveState.value = 'idle'
retrySave.value = undefined
try {
const saved = await setTelemetryEnabled(value)
privacy.value.telemetry = saved.telemetry
privacy.value.consent_version = saved.consent_version
lastSaveState.value = 'saved'
} catch (error) {
privacy.value.telemetry = previous
retrySave.value = () => void updateTelemetry(value)
lastSaveState.value = 'error'
handleError(error)
} finally {
telemetrySaving.value = false
}
}
async function updateDiscordRpc(value: boolean) {
if (discordSaving.value) return
const previous = privacy.value.discord_rpc
privacy.value.discord_rpc = value
discordSaving.value = true
lastSaveState.value = 'idle'
retrySave.value = undefined
try {
const saved = await setDiscordRpcEnabled(value)
privacy.value.discord_rpc = saved.discord_rpc
lastSaveState.value = 'saved'
} catch (error) {
privacy.value.discord_rpc = previous
retrySave.value = () => void updateDiscordRpc(value)
lastSaveState.value = 'error'
handleError(error)
} finally {
discordSaving.value = false
}
}
</script>
<template>
<div class="flex w-full flex-col gap-4">
<header class="settings-page-header">
<SettingsSaveStatus :status="saveStatus" :retry="retrySave" />
</header>
<div class="settings-page-card">
<SettingsRow>
<template #label>
<span id="settings-target-privacy-telemetry" tabindex="-1">
{{ formatMessage(messages.telemetry) }}
</span>
</template>
<template #description>{{ formatMessage(messages.telemetryDescription) }}</template>
<template #control>
<Toggle
id="privacy-telemetry"
:model-value="privacy.telemetry"
:disabled="telemetrySaving"
@update:model-value="(value) => updateTelemetry(!!value)"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-privacy-discord-rpc" tabindex="-1">
{{ formatMessage(messages.discordRpc) }}
</span>
</template>
<template #description>{{ formatMessage(messages.discordRpcDescription) }}</template>
<template #control>
<Toggle
id="privacy-discord-rpc"
:model-value="privacy.discord_rpc"
:disabled="discordSaving"
@update:model-value="(value) => updateDiscordRpc(!!value)"
/>
</template>
</SettingsRow>
</div>
<p class="settings-page-note">{{ formatMessage(messages.dataHandling) }}</p>
</div>
</template>
<style scoped>
.settings-page-card {
overflow: hidden;
border: 1px solid
var(--settings-card-border, color-mix(in srgb, var(--surface-4) 72%, transparent));
border-radius: var(--radius-md);
background: var(--surface-2);
}
.settings-page-header {
display: flex;
min-height: 0;
justify-content: flex-end;
}
.settings-page-note {
margin: 0;
color: var(--color-secondary);
font-size: 0.8125rem;
line-height: 1.5;
}
</style>

View File

@ -0,0 +1,75 @@
<template>
<!-- QQ 频道官方图标pd.qq.com favicon.svg与官网一致 -->
<svg
class="qq-channel-icon"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 128 128"
fill="none"
aria-hidden="true"
>
<path
class="qq-channel-glyph"
d="M105.534 43.3071H90.7396L92.1344 33.8696C92.2086 33.3651 91.8228 32.9199 91.3183 32.9199H81.7918C81.3763 32.9199 81.035 33.2167 80.9756 33.6322L79.5363 43.3071H54.696L56.0909 33.8696C56.1651 33.3651 55.7792 32.9199 55.2747 32.9199H45.763C45.3475 32.9199 45.0062 33.2167 44.9469 33.6322L43.5075 43.3071H28.4757C28.0603 43.3071 27.719 43.6039 27.6596 44.0194L26.3686 52.7446C26.2944 53.2492 26.6802 53.6943 27.1848 53.6943H41.9643L40.1984 65.5802C35.4055 64.6602 30.4642 64.126 25.4041 64.0815C24.9886 64.0815 24.6325 64.3931 24.5731 64.7938L23.2821 73.519C23.2079 74.0087 23.6086 74.4539 24.0983 74.4539C30.479 74.4984 34.7823 75.0326 38.67 75.8487L35.9397 94.2638C35.8655 94.7683 36.2513 95.2135 36.7558 95.2135H46.2675C46.683 95.2135 47.0243 94.9167 47.0837 94.5012L49.4134 78.8462C58.9844 82.3778 67.6058 87.8979 74.7433 94.9018C77.7704 97.8696 82.8898 96.1038 83.5131 91.9192L83.884 89.4115L84.5666 84.8263H99.5984C100.014 84.8263 100.355 84.5295 100.415 84.114L101.706 75.3887C101.78 74.8842 101.394 74.439 100.889 74.439H86.095L89.1815 53.6795H104.213C104.629 53.6795 104.97 53.3827 105.029 52.9672L106.32 44.242C106.395 43.7375 106.009 43.2923 105.504 43.2923L105.534 43.3071ZM77.7111 55.6234L74.402 77.8668C74.2091 79.1281 72.7697 79.7068 71.731 78.9797C65.7806 74.7803 59.2367 71.3674 52.2624 68.8745C51.5057 68.6074 51.0456 67.8506 51.1644 67.0493L52.9302 55.1337C53.0489 54.3027 53.7612 53.6943 54.607 53.6943H76.0343C77.073 53.6943 77.8595 54.6143 77.7111 55.6382V55.6234Z"
/>
<path
d="M126.783 45.5479C124.721 34.2258 120.269 28.1716 114.333 23.2451C108.249 18.4373 100.786 14.8314 86.7925 13.1695C80.4563 12.4127 72.7401 12.1901 64 12.1901C55.2599 12.1901 47.5437 12.4127 41.2075 13.1695C27.2293 14.8463 19.7505 18.4521 13.6666 23.2451C7.7459 28.1716 3.2794 34.2258 1.2168 45.5479C0.281954 50.6821 1.58835e-05 56.9293 1.52647e-05 64.0074C1.46459e-05 71.0855 0.281952 77.3475 1.2168 82.4669C3.2794 93.789 7.73106 99.8432 13.6666 104.77C19.7505 109.578 27.2145 113.183 41.2075 114.845C47.5437 115.602 55.2599 115.825 64 115.825C72.7401 115.825 80.4563 115.602 86.7925 114.845C100.771 113.169 108.249 109.563 114.333 104.77C120.254 99.8433 124.721 93.789 126.783 82.467C127.718 77.3327 128 71.0856 128 64.0074C128 56.9293 127.718 50.6673 126.783 45.5479ZM117.331 80.7605C115.728 89.5599 112.686 93.6406 108.294 97.3207C104.139 100.585 98.4855 103.79 85.6647 105.334C80.4266 105.957 73.5414 106.254 64.0148 106.254C54.4883 106.254 47.6031 105.957 42.365 105.334C29.5442 103.805 23.8906 100.585 19.7357 97.3206C15.3434 93.6406 12.3014 89.5599 10.6988 80.7605C9.97173 76.7837 9.63044 71.4714 9.63044 64.0222C9.63044 56.5731 9.97173 51.2608 10.6988 47.284C12.3014 38.4846 15.3434 34.4039 19.7357 30.7239C23.8906 27.4593 29.5442 24.2541 42.365 22.7109C47.6031 22.0876 54.4883 21.7909 64.0149 21.7909C73.5414 21.7909 80.4266 22.0876 85.6647 22.7109C98.4855 24.2393 104.139 27.4593 108.294 30.7239C112.686 34.4039 115.728 38.4846 117.331 47.284C118.058 51.2608 118.399 56.5731 118.399 64.0223C118.399 71.4714 118.058 76.7837 117.331 80.7605Z"
fill="#2B64F5"
/>
<path
d="M126.783 45.5479C124.721 34.2258 120.269 28.1716 114.333 23.2451C108.249 18.4373 100.786 14.8314 86.7925 13.1695C80.4563 12.4127 72.7401 12.1901 64 12.1901C55.2599 12.1901 47.5437 12.4127 41.2075 13.1695C27.2293 14.8463 19.7505 18.4521 13.6666 23.2451C7.7459 28.1716 3.2794 34.2258 1.2168 45.5479C0.281954 50.6821 1.58835e-05 56.9293 1.52647e-05 64.0074C1.46459e-05 71.0855 0.281952 77.3475 1.2168 82.4669C3.2794 93.789 7.73106 99.8432 13.6666 104.77C19.7505 109.578 27.2145 113.183 41.2075 114.845C47.5437 115.602 55.2599 115.825 64 115.825C72.7401 115.825 80.4563 115.602 86.7925 114.845C100.771 113.169 108.249 109.563 114.333 104.77C120.254 99.8433 124.721 93.789 126.783 82.467C127.718 77.3327 128 71.0856 128 64.0074C128 56.9293 127.718 50.6673 126.783 45.5479ZM117.331 80.7605C115.728 89.5599 112.686 93.6406 108.294 97.3207C104.139 100.585 98.4855 103.79 85.6647 105.334C80.4266 105.957 73.5414 106.254 64.0148 106.254C54.4883 106.254 47.6031 105.957 42.365 105.334C29.5442 103.805 23.8906 100.585 19.7357 97.3206C15.3434 93.6406 12.3014 89.5599 10.6988 80.7605C9.97173 76.7837 9.63044 71.4714 9.63044 64.0222C9.63044 56.5731 9.97173 51.2608 10.6988 47.284C12.3014 38.4846 15.3434 34.4039 19.7357 30.7239C23.8906 27.4593 29.5442 24.2541 42.365 22.7109C47.6031 22.0876 54.4883 21.7909 64.0149 21.7909C73.5414 21.7909 80.4266 22.0876 85.6647 22.7109C98.4855 24.2393 104.139 27.4593 108.294 30.7239C112.686 34.4039 115.728 38.4846 117.331 47.284C118.058 51.2608 118.399 56.5731 118.399 64.0223C118.399 71.4714 118.058 76.7837 117.331 80.7605Z"
fill="url(#qq-channel-glow-teal)"
/>
<path
d="M126.783 45.5479C124.721 34.2258 120.269 28.1716 114.333 23.2451C108.249 18.4373 100.786 14.8314 86.7925 13.1695C80.4563 12.4127 72.7401 12.1901 64 12.1901C55.2599 12.1901 47.5437 12.4127 41.2075 13.1695C27.2293 14.8463 19.7505 18.4521 13.6666 23.2451C7.7459 28.1716 3.2794 34.2258 1.2168 45.5479C0.281954 50.6821 1.58835e-05 56.9293 1.52647e-05 64.0074C1.46459e-05 71.0855 0.281952 77.3475 1.2168 82.4669C3.2794 93.789 7.73106 99.8432 13.6666 104.77C19.7505 109.578 27.2145 113.183 41.2075 114.845C47.5437 115.602 55.2599 115.825 64 115.825C72.7401 115.825 80.4563 115.602 86.7925 114.845C100.771 113.169 108.249 109.563 114.333 104.77C120.254 99.8433 124.721 93.789 126.783 82.467C127.718 77.3327 128 71.0856 128 64.0074C128 56.9293 127.718 50.6673 126.783 45.5479ZM117.331 80.7605C115.728 89.5599 112.686 93.6406 108.294 97.3207C104.139 100.585 98.4855 103.79 85.6647 105.334C80.4266 105.957 73.5414 106.254 64.0148 106.254C54.4883 106.254 47.6031 105.957 42.365 105.334C29.5442 103.805 23.8906 100.585 19.7357 97.3206C15.3434 93.6406 12.3014 89.5599 10.6988 80.7605C9.97173 76.7837 9.63044 71.4714 9.63044 64.0222C9.63044 56.5731 9.97173 51.2608 10.6988 47.284C12.3014 38.4846 15.3434 34.4039 19.7357 30.7239C23.8906 27.4593 29.5442 24.2541 42.365 22.7109C47.6031 22.0876 54.4883 21.7909 64.0149 21.7909C73.5414 21.7909 80.4266 22.0876 85.6647 22.7109C98.4855 24.2393 104.139 27.4593 108.294 30.7239C112.686 34.4039 115.728 38.4846 117.331 47.284C118.058 51.2608 118.399 56.5731 118.399 64.0223C118.399 71.4714 118.058 76.7837 117.331 80.7605Z"
fill="url(#qq-channel-glow-purple)"
/>
<path
d="M126.783 45.5479C124.721 34.2258 120.269 28.1716 114.333 23.2451C108.249 18.4373 100.786 14.8314 86.7925 13.1695C80.4563 12.4127 72.7401 12.1901 64 12.1901C55.2599 12.1901 47.5437 12.4127 41.2075 13.1695C27.2293 14.8463 19.7505 18.4521 13.6666 23.2451C7.7459 28.1716 3.2794 34.2258 1.2168 45.5479C0.281954 50.6821 1.58835e-05 56.9293 1.52647e-05 64.0074C1.46459e-05 71.0855 0.281952 77.3475 1.2168 82.4669C3.2794 93.789 7.73106 99.8432 13.6666 104.77C19.7505 109.578 27.2145 113.183 41.2075 114.845C47.5437 115.602 55.2599 115.825 64 115.825C72.7401 115.825 80.4563 115.602 86.7925 114.845C100.771 113.169 108.249 109.563 114.333 104.77C120.254 99.8433 124.721 93.789 126.783 82.467C127.718 77.3327 128 71.0856 128 64.0074C128 56.9293 127.718 50.6673 126.783 45.5479ZM117.331 80.7605C115.728 89.5599 112.686 93.6406 108.294 97.3207C104.139 100.585 98.4855 103.79 85.6647 105.334C80.4266 105.957 73.5414 106.254 64.0148 106.254C54.4883 106.254 47.6031 105.957 42.365 105.334C29.5442 103.805 23.8906 100.585 19.7357 97.3206C15.3434 93.6406 12.3014 89.5599 10.6988 80.7605C9.97173 76.7837 9.63044 71.4714 9.63044 64.0222C9.63044 56.5731 9.97173 51.2608 10.6988 47.284C12.3014 38.4846 15.3434 34.4039 19.7357 30.7239C23.8906 27.4593 29.5442 24.2541 42.365 22.7109C47.6031 22.0876 54.4883 21.7909 64.0149 21.7909C73.5414 21.7909 80.4266 22.0876 85.6647 22.7109C98.4855 24.2393 104.139 27.4593 108.294 30.7239C112.686 34.4039 115.728 38.4846 117.331 47.284C118.058 51.2608 118.399 56.5731 118.399 64.0223C118.399 71.4714 118.058 76.7837 117.331 80.7605Z"
fill="url(#qq-channel-glow-yellow)"
fill-opacity="0.8"
/>
<defs>
<radialGradient
id="qq-channel-glow-teal"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(95.7003 29.4625) rotate(105.19) scale(76.7612 86.9467)"
>
<stop stop-color="#49E0BC" />
<stop offset="0.71" stop-color="#49E0BC" stop-opacity="0.185" />
<stop offset="1" stop-color="#49E0BC" stop-opacity="0" />
</radialGradient>
<radialGradient
id="qq-channel-glow-purple"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(-16.1165 148.834) rotate(-60.1482) scale(97.8038 238.027)"
>
<stop stop-color="#BA76FF" />
<stop offset="1" stop-color="#BA76FF" stop-opacity="0" />
</radialGradient>
<radialGradient
id="qq-channel-glow-yellow"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(103.892 4.32155) rotate(107.684) scale(86.8182 95.2138)"
>
<stop stop-color="#FFF15B" />
<stop offset="0.566778" stop-color="#FFF15B" stop-opacity="0.145" />
<stop offset="1" stop-color="#FFF15B" stop-opacity="0" />
</radialGradient>
</defs>
</svg>
</template>
<style scoped>
.qq-channel-glyph {
fill: currentColor;
}
</style>

File diff suppressed because it is too large Load Diff

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