Files
Starlight_Lancher/apps/app-frontend/src/components/ui/settings/ResourceManagementSettings.vue
T
AxTps ead942abb2
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled
feat: 移植忽略 SSL 证书错误选项(设置开关 + 重建 HTTP 客户端)
2026-10-02 21:20:26 +08:00

1160 lines
36 KiB
Vue

<script setup>
import { BoxIcon, FolderOpenIcon, FolderSearchIcon, PlusIcon, TrashIcon } from '@modrinth/assets'
import {
Combobox,
defineMessages,
IconButton,
injectNotificationManager,
Slider,
StyledInput,
Toggle,
useVIntl,
} from '@modrinth/ui'
import { invoke } from '@tauri-apps/api/core'
import { open } from '@tauri-apps/plugin-dialog'
import { computed, ref, watch } from 'vue'
import ConfirmModalWrapper from '@/components/ui/modal/ConfirmModalWrapper.vue'
import { purge_cache_types } from '@/helpers/cache.js'
import { configureCurseForgeManualDownloadWatcher } from '@/helpers/curseforge'
import { syncConfiguredDirectLinks } from '@/helpers/direct-link-sync'
import {
getMissingContentScannerSettings,
setMissingContentScannerSettings,
} from '@/helpers/downloads-scanner'
import { get, getProxyConfig, set, setProxyConfig, testProxyConfig } from '@/helpers/settings.ts'
import { showAppDbBackupsFolder } from '@/helpers/utils.js'
import { useTheming } from '@/store/state'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
const props = defineProps({
scope: {
type: String,
default: 'content-downloads',
validator: (value) =>
['content-downloads', 'network-multiplayer', 'storage-backups'].includes(value),
},
})
const { handleError } = injectNotificationManager()
const themeStore = useTheming()
const settings = ref(await get())
const missingContentScannerSettings = ref(getMissingContentScannerSettings())
const minecraftDirectories = ref(loadMinecraftDirectories())
const minecraftDirectoryError = ref(null)
const purgeCacheConfirmModal = ref(null)
const { formatMessage } = useVIntl()
const isPortable = ref(false)
try {
isPortable.value = await invoke('is_portable_mode')
} catch (error) {
console.error('Failed to determine portable mode', error)
}
// Proxy settings
/** @type {import('@/helpers/settings.ts').ProxyConfig} */
const proxyConfig = ref({ mode: 'none', url: '', username: '', password: '' })
const proxyTesting = ref(false)
/** @type {import('vue').Ref<string>} */
const proxyTestResult = ref('')
try {
proxyConfig.value = await getProxyConfig()
} catch (error) {
console.error('Failed to load proxy configuration', error)
}
async function saveProxyConfig(silent = true) {
try {
await setProxyConfig(proxyConfig.value)
} catch (error) {
if (!silent) {
handleError(error)
}
}
}
async function testProxy() {
if (proxyConfig.value.mode === 'custom' && !proxyConfig.value.url?.trim()) {
proxyTestResult.value = formatMessage(messages.proxyUrlRequired)
return
}
proxyTesting.value = true
proxyTestResult.value = ''
try {
const result = await testProxyConfig(proxyConfig.value)
if (result.success) {
proxyTestResult.value =
result.latency_ms != null
? formatMessage(messages.proxyTestSuccessWithLatency, { latency: result.latency_ms })
: formatMessage(messages.proxyTestSuccess)
} else {
proxyTestResult.value = result.message || formatMessage(messages.proxyTestFailed)
}
} catch (error) {
const message =
error instanceof Error
? error.message
: typeof error === 'object' && error !== null && 'message' in error
? String(error.message)
: typeof error === 'string'
? error
: JSON.stringify(error)
proxyTestResult.value = message
} finally {
proxyTesting.value = false
}
}
const messages = defineMessages({
selectDirectory: {
id: 'app.settings.resources.select-directory',
defaultMessage: 'Select a new app directory',
},
appDirectory: { id: 'app.settings.resources.app-directory', defaultMessage: 'App directory' },
axolotlDataDirectory: {
id: 'app.settings.resources.axolotl-data-directory',
defaultMessage: 'Starlight data directory',
},
appDirectoryDescription: {
id: 'app.settings.resources.app-directory-description',
defaultMessage:
'The directory where the launcher stores all of its files. Changes apply after restarting the launcher.',
},
appDirectoryDescriptionPortable: {
id: 'app.settings.resources.app-directory-description-portable',
defaultMessage:
'You are currently running in portable mode. The app directory is fixed and cannot be changed.',
},
minecraftDirectories: {
id: 'app.settings.resources.minecraft-directories',
defaultMessage: 'Minecraft directories',
},
minecraftDirectoriesDescription: {
id: 'app.settings.resources.minecraft-directories-description',
defaultMessage:
'Add one or more .minecraft folders for instances that use an external game directory. These folders are kept separate from Starlight data.',
},
addMinecraftDirectory: {
id: 'app.settings.resources.add-minecraft-directory',
defaultMessage: 'Add .minecraft directory',
},
selectMinecraftDirectory: {
id: 'app.settings.resources.select-minecraft-directory',
defaultMessage: 'Select a .minecraft directory',
},
removeMinecraftDirectory: {
id: 'app.settings.resources.remove-minecraft-directory',
defaultMessage: 'Remove .minecraft directory',
},
minecraftDirectoryMustEndWith: {
id: 'app.settings.resources.minecraft-directory-must-end-with',
defaultMessage: 'The selected folder must be named .minecraft.',
},
minecraftDirectoryMode: {
id: 'app.settings.resources.minecraft-directory-mode',
defaultMessage: 'Game directory mode',
},
minecraftDirectoryModeIsolated: {
id: 'app.settings.resources.minecraft-directory-mode.isolated',
defaultMessage: 'Version-isolated',
},
minecraftDirectoryModeShared: {
id: 'app.settings.resources.minecraft-directory-mode.shared',
defaultMessage: 'Shared .minecraft directory',
},
purgeConfirmTitle: {
id: 'app.settings.resources.purge-confirm-title',
defaultMessage: 'Are you sure you want to purge the cache?',
},
purgeConfirmDescription: {
id: 'app.settings.resources.purge-confirm-description',
defaultMessage:
'If you proceed, your entire cache will be purged. This may slow down the app temporarily.',
},
appCache: { id: 'app.settings.resources.app-cache', defaultMessage: 'App cache' },
purgeCache: { id: 'app.settings.resources.purge-cache', defaultMessage: 'Purge cache' },
appCacheDescription: {
id: 'app.settings.resources.app-cache-description',
defaultMessage:
'Starlight Launcher caches data to speed up loading. Purging it forces the app to reload data and may temporarily slow the app down.',
},
downloadMirrors: {
id: 'app.settings.resources.download-mirrors',
defaultMessage: 'Download sources',
},
downloadMirrorsDescription: {
id: 'app.settings.resources.download-mirrors-description',
defaultMessage:
'Automatic mode chooses between official and mirror sources based on your local environment and recent connection quality.',
},
automaticSource: {
id: 'app.settings.resources.source.automatic',
defaultMessage: 'Automatic (recommended)',
},
officialPreferredSource: {
id: 'app.settings.resources.source.official-preferred',
defaultMessage: 'Prefer official sources',
},
officialOnlySource: {
id: 'app.settings.resources.source.official-only',
defaultMessage: 'Original sources only (no mirrors)',
},
openBmclApiSource: {
id: 'app.settings.resources.source.open-bmcl-api',
defaultMessage: 'Prefer OpenBMCLAPI',
},
tianpaoSource: {
id: 'app.settings.resources.source.tianpao',
defaultMessage: 'Prefer Tianpao',
},
minecraftMetadataSource: {
id: 'app.settings.resources.minecraft-metadata-source',
defaultMessage: 'Minecraft metadata',
},
minecraftMetadataSourceDescription: {
id: 'app.settings.resources.minecraft-metadata-source-description',
defaultMessage: 'Version manifests and metadata for Minecraft and supported mod loaders.',
},
minecraftFileSource: {
id: 'app.settings.resources.minecraft-file-source',
defaultMessage: 'Minecraft files, loaders, and Java',
},
minecraftFileSourceDescription: {
id: 'app.settings.resources.minecraft-file-source-description',
defaultMessage: 'Game files, assets, libraries, mod loaders, and Java runtimes.',
},
modrinthMirror: {
id: 'app.settings.resources.modrinth-mirror',
defaultMessage: 'Modrinth',
},
modrinthMirrorDescription: {
id: 'app.settings.resources.modrinth-mirror-description',
defaultMessage: 'Modrinth file downloads.',
},
curseforgeMirror: {
id: 'app.settings.resources.curseforge-mirror',
defaultMessage: 'CurseForge',
},
curseforgeMirrorDescription: {
id: 'app.settings.resources.curseforge-mirror-description',
defaultMessage: 'CurseForge file downloads.',
},
curseforgeRestrictionBypass: {
id: 'app.settings.resources.curseforge-restriction-bypass',
defaultMessage: 'Automatically download restricted CurseForge files',
},
curseforgeRestrictionBypassDescription: {
id: 'app.settings.resources.curseforge-restriction-bypass-description',
defaultMessage:
'When CurseForge does not provide a download address, derive its CDN address and try downloading the file automatically. Disable this to use the manual download workflow.',
},
ignoreSslErrors: {
id: 'app.settings.resources.ignore-ssl-errors',
defaultMessage: 'Ignore SSL certificate errors',
},
ignoreSslErrorsDescription: {
id: 'app.settings.resources.ignore-ssl-errors-description',
defaultMessage:
'Skip TLS certificate validation for launcher network requests. Only enable this if you trust your network and understand the security risk.',
},
mojangAuthService: {
id: 'app.settings.resources.mojang-auth-service',
defaultMessage: 'Mojang authentication service',
},
mojangAuthServiceDescription: {
id: 'app.settings.resources.mojang-auth-service-description',
defaultMessage:
'Mojang login, profile, skin, cape, and session verification requests used by the launcher and the game.',
},
mojangAuthOfficialPreferred: {
id: 'app.settings.resources.source.mojang-official-preferred',
defaultMessage: 'Prefer official source',
},
mojangAuthMirrorPreferred: {
id: 'app.settings.resources.source.mojang-mirror-preferred',
defaultMessage: 'Prefer Fallen-Proxy',
},
mojangAuthOfficialOnly: {
id: 'app.settings.resources.source.mojang-official-only',
defaultMessage: 'Official source only',
},
maximumDownloads: {
id: 'app.settings.resources.maximum-downloads',
defaultMessage: 'Maximum concurrent downloads',
},
downloadEngine: {
id: 'app.settings.resources.download-engine',
defaultMessage: 'Download engine',
},
downloadEngineDescription: {
id: 'app.settings.resources.download-engine-description',
defaultMessage: 'Choose which download engine the launcher uses.',
},
legacyEngine: {
id: 'app.settings.resources.download-engine.legacy',
defaultMessage: 'Native engine',
},
xmclEngine: {
id: 'app.settings.resources.download-engine.xmcl',
defaultMessage: 'XMCL-compatible',
},
maximumDownloadsDescription: {
id: 'app.settings.resources.maximum-downloads-description',
defaultMessage:
'Automatic mode uses 64 concurrent downloads. Manual changes apply immediately.',
},
manualConcurrency: {
id: 'app.settings.resources.concurrency.manual',
defaultMessage: 'Manual',
},
maximumWrites: {
id: 'app.settings.resources.maximum-writes',
defaultMessage: 'Maximum concurrent writes',
},
maximumWritesDescription: {
id: 'app.settings.resources.maximum-writes-description',
defaultMessage:
'The maximum number of files the launcher can write to disk at once. Use a lower value if you frequently get I/O errors. An app restart is required.',
},
proxySettings: {
id: 'app.settings.resources.proxy-settings',
defaultMessage: 'Proxy settings',
},
proxySettingsDescription: {
id: 'app.settings.resources.proxy-settings-description',
defaultMessage:
'Configure how the launcher connects to the internet. System proxy follows your OS settings. Custom proxy lets you specify a URL with optional authentication.',
},
proxyMode: {
id: 'app.settings.resources.proxy-mode',
defaultMessage: 'Proxy mode',
},
proxyModeNone: {
id: 'app.settings.resources.proxy-mode.none',
defaultMessage: 'No proxy (direct connection)',
},
proxyModeSystem: {
id: 'app.settings.resources.proxy-mode.system',
defaultMessage: 'Use system proxy',
},
proxyModeCustom: {
id: 'app.settings.resources.proxy-mode.custom',
defaultMessage: 'Custom proxy',
},
proxyUrl: {
id: 'app.settings.resources.proxy-url',
defaultMessage: 'Proxy URL',
},
proxyUrlRequired: {
id: 'app.settings.resources.proxy-url-required',
defaultMessage: 'Proxy URL is required',
},
proxyUrlPlaceholder: {
id: 'app.settings.resources.proxy-url-placeholder',
defaultMessage: 'http/https/socks5://ip:port',
},
proxyUsername: {
id: 'app.settings.resources.proxy-username',
defaultMessage: 'Username',
},
proxyUsernamePlaceholder: {
id: 'app.settings.resources.proxy-username-placeholder',
defaultMessage: 'Username (optional)',
},
proxyPassword: {
id: 'app.settings.resources.proxy-password',
defaultMessage: 'Password',
},
proxyPasswordPlaceholder: {
id: 'app.settings.resources.proxy-password-placeholder',
defaultMessage: 'Password (optional)',
},
proxyTest: {
id: 'app.settings.resources.proxy-test',
defaultMessage: 'Test connection',
},
proxyTesting: {
id: 'app.settings.resources.proxy-testing',
defaultMessage: 'Testing...',
},
proxyTestSuccess: {
id: 'app.settings.resources.proxy-test-success',
defaultMessage: 'Connection successful',
},
proxyTestSuccessWithLatency: {
id: 'app.settings.resources.proxy-test-success-latency',
defaultMessage: 'Connection successful ({latency} ms)',
},
proxyTestFailed: {
id: 'app.settings.resources.proxy-test-failed',
defaultMessage: 'Connection failed',
},
missingContentAutoImport: {
id: 'app.settings.resources.missing-content-auto-import',
defaultMessage: 'Automatically import missing modpack files',
},
missingContentAutoImportDescription: {
id: 'app.settings.resources.missing-content-auto-import-description',
defaultMessage:
'Watch one folder while resolving missing modpack files, then verify and import matching files automatically.',
},
missingContentImportDirectory: {
id: 'app.settings.resources.missing-content-import-directory',
defaultMessage: 'Monitored import folder',
},
missingContentImportDirectoryDescription: {
id: 'app.settings.resources.missing-content-import-directory-description',
defaultMessage:
'The system Downloads folder is used when no custom folder is selected. Subfolders are not scanned.',
},
systemDownloadsDirectory: {
id: 'app.settings.resources.system-downloads-directory',
defaultMessage: 'System Downloads folder',
},
selectImportDirectory: {
id: 'app.settings.resources.select-import-directory',
defaultMessage: 'Select monitored folder',
},
resetImportDirectory: {
id: 'app.settings.resources.reset-import-directory',
defaultMessage: 'Use system Downloads folder',
},
databaseBackups: {
id: 'app.settings.resources.database-backups',
defaultMessage: 'App database backups',
},
openBackupsFolder: {
id: 'app.settings.resources.open-backups-folder',
defaultMessage: 'Open backups folder',
},
databaseBackupsDescription: {
id: 'app.settings.resources.database-backups-description',
defaultMessage:
'Backups of important app data are stored here in case you need to recover them later.',
},
})
const MINECRAFT_DIRECTORIES_STORAGE_KEY = 'axolotl-minecraft-directories'
/** @typedef {import('@/helpers/instance').ExternalMinecraftRoot} ExternalMinecraftRoot */
function isMinecraftDirectoryPath(value) {
const normalized = value.trim().replace(/[\\/]+$/, '')
return normalized.length > 0 && normalized.split(/[\\/]/).at(-1)?.toLowerCase() === '.minecraft'
}
/** @returns {ExternalMinecraftRoot[]} */
function loadMinecraftDirectories() {
try {
const raw = localStorage.getItem(MINECRAFT_DIRECTORIES_STORAGE_KEY)
if (!raw) return []
const parsed = JSON.parse(raw)
if (!Array.isArray(parsed)) return []
const directories = parsed.flatMap((value) => {
if (typeof value === 'string') return [{ path: value, mode: 'isolated' }]
if (value && typeof value === 'object' && typeof value.path === 'string') {
return [{
path: value.path,
mode: value.mode === 'shared' ? 'shared' : 'isolated',
}]
}
return []
})
return [...new Map(
directories
.filter((directory) => isMinecraftDirectoryPath(directory.path))
.map((directory) => [directory.path.trim(), directory]),
).values()]
} catch {
return []
}
}
/** @param {ExternalMinecraftRoot[]} values */
function persistMinecraftDirectories(values) {
try {
const validValues = [...new Map(
values
.map((value) => ({
path: value.path.trim(),
mode: value.mode === 'shared' ? 'shared' : 'isolated',
}))
.filter((value) => isMinecraftDirectoryPath(value.path))
.map((value) => [value.path, value]),
).values()]
localStorage.setItem(MINECRAFT_DIRECTORIES_STORAGE_KEY, JSON.stringify(validValues))
} catch {
// Local storage may be unavailable in an embedded or restricted webview.
}
}
function downloadSourceModel(setting) {
return computed({
get: () => settings.value[setting],
set: (source) => {
settings.value[setting] = source
},
})
}
const minecraftMetadataSource = downloadSourceModel('minecraft_metadata_source')
const minecraftFileSource = downloadSourceModel('minecraft_file_source')
const modrinthDownloadSource = downloadSourceModel('modrinth_source')
const curseforgeDownloadSource = downloadSourceModel('curseforge_source')
const automaticSourceOption = computed(() => ({
value: 'auto',
label: formatMessage(messages.automaticSource),
}))
const officialPreferredSourceOption = computed(() => ({
value: 'official_preferred',
label: formatMessage(messages.officialPreferredSource),
}))
const officialOnlySourceOption = computed(() => ({
value: 'official_only',
label: formatMessage(messages.officialOnlySource),
}))
const minecraftSourceOptions = computed(() => [
automaticSourceOption.value,
officialPreferredSourceOption.value,
{ value: 'mirror_preferred', label: formatMessage(messages.openBmclApiSource) },
officialOnlySourceOption.value,
])
const minecraftDirectoryModeOptions = computed(() => [
{ value: 'isolated', label: formatMessage(messages.minecraftDirectoryModeIsolated) },
{ value: 'shared', label: formatMessage(messages.minecraftDirectoryModeShared) },
])
const modrinthSourceOptions = computed(() => [
automaticSourceOption.value,
officialPreferredSourceOption.value,
{ value: 'mirror_preferred', label: formatMessage(messages.tianpaoSource) },
officialOnlySourceOption.value,
])
const curseforgeSourceOptions = computed(() => [
automaticSourceOption.value,
officialPreferredSourceOption.value,
{ value: 'mirror_preferred', label: formatMessage(messages.tianpaoSource) },
officialOnlySourceOption.value,
])
const mojangAuthSource = downloadSourceModel('mojang_auth_source')
const mojangAuthSourceOptions = computed(() => [
automaticSourceOption.value,
{ value: 'official_preferred', label: formatMessage(messages.mojangAuthOfficialPreferred) },
{ value: 'mirror_preferred', label: formatMessage(messages.mojangAuthMirrorPreferred) },
{ value: 'official_only', label: formatMessage(messages.mojangAuthOfficialOnly) },
])
const downloadConcurrencyMode = computed({
get: () => (settings.value.auto_concurrent_downloads ? 'auto' : 'manual'),
set: (mode) => {
settings.value.auto_concurrent_downloads = mode === 'auto'
},
})
const downloadConcurrencyOptions = computed(() => [
{
value: 'auto',
label: formatMessage(messages.automaticSource),
},
{
value: 'manual',
label: formatMessage(messages.manualConcurrency),
},
])
const downloadEngine = computed({
get: () => settings.value.download_engine,
set: (engine) => {
settings.value.download_engine = engine
},
})
const downloadEngineOptions = computed(() => [
{
value: 'legacy',
label: formatMessage(messages.legacyEngine),
},
{
value: 'xmcl',
label: formatMessage(messages.xmclEngine),
},
])
const proxyModeOptions = computed(() => [
{
value: 'none',
label: formatMessage(messages.proxyModeNone),
},
{
value: 'system',
label: formatMessage(messages.proxyModeSystem),
},
{
value: 'custom',
label: formatMessage(messages.proxyModeCustom),
},
])
const appDirectoryDescriptionText = computed(() =>
isPortable.value
? formatMessage(messages.appDirectoryDescriptionPortable)
: formatMessage(messages.appDirectoryDescription),
)
watch(
settings,
async () => {
const setSettings = JSON.parse(JSON.stringify(settings.value))
if (!setSettings.custom_dir) {
setSettings.custom_dir = null
}
await set(setSettings)
},
{ deep: true },
)
watch(
minecraftDirectories,
(value) => {
persistMinecraftDirectories(value)
void syncDirectLinkInstances(true)
},
{ deep: true },
)
async function syncDirectLinkInstances(allowEmpty = false) {
if (!allowEmpty && minecraftDirectories.value.length === 0) return
try {
await syncConfiguredDirectLinks(minecraftDirectories.value)
} catch (error) {
handleError(error)
}
}
void syncDirectLinkInstances()
watch(
missingContentScannerSettings,
(value) => {
setMissingContentScannerSettings(value)
void configureCurseForgeManualDownloadWatcher(value.enabled, value.directory).catch(handleError)
},
{ deep: true },
)
watch(
proxyConfig,
async () => {
await saveProxyConfig(true)
},
{ deep: true },
)
async function purgeCache() {
await purge_cache_types([
'project',
'project_v3',
'curseforge_project',
'version',
'user',
'team',
'organization',
'file',
'loader_manifest',
'minecraft_manifest',
'categories',
'report_types',
'loaders',
'game_versions',
'donation_platforms',
'file_hash',
'file_update',
'search_results',
'search_results_v3',
]).catch(handleError)
}
function handlePurgeCacheClick() {
if (themeStore.getFeatureFlag('skip_non_essential_warnings')) {
void purgeCache()
return
}
purgeCacheConfirmModal.value?.show()
}
async function openDbBackupsFolder() {
await showAppDbBackupsFolder().catch(handleError)
}
async function findLauncherDir() {
const newDir = await open({
multiple: false,
directory: true,
title: formatMessage(messages.selectDirectory),
})
if (newDir) {
settings.value.custom_dir = newDir
}
}
async function findMissingContentImportDirectory() {
const directory = await open({
multiple: false,
directory: true,
title: formatMessage(messages.selectImportDirectory),
})
if (typeof directory === 'string') {
missingContentScannerSettings.value.directory = directory
}
}
function resetMissingContentImportDirectory() {
missingContentScannerSettings.value.directory = null
}
async function addMinecraftDirectory() {
minecraftDirectoryError.value = null
const directory = await open({
multiple: false,
directory: true,
title: formatMessage(messages.selectMinecraftDirectory),
})
if (typeof directory !== 'string') return
const normalized = directory.trim().replace(/[\\/]+$/, '')
if (!isMinecraftDirectoryPath(normalized)) {
minecraftDirectoryError.value = formatMessage(messages.minecraftDirectoryMustEndWith)
return
}
if (!minecraftDirectories.value.some((entry) => entry.path === normalized)) {
minecraftDirectories.value.push({ path: normalized, mode: 'isolated' })
}
}
function removeMinecraftDirectory(index) {
minecraftDirectories.value.splice(index, 1)
if (minecraftDirectoryError.value) minecraftDirectoryError.value = null
}
function validateMinecraftDirectory(value) {
minecraftDirectoryError.value =
value.trim() && !isMinecraftDirectoryPath(value)
? formatMessage(messages.minecraftDirectoryMustEndWith)
: null
}
</script>
<template>
<div class="flex flex-col gap-6">
<ConfirmModalWrapper
ref="purgeCacheConfirmModal"
:title="formatMessage(messages.purgeConfirmTitle)"
:description="formatMessage(messages.purgeConfirmDescription)"
:has-to-type="false"
:proceed-label="formatMessage(messages.purgeCache)"
:show-ad-on-close="false"
@proceed="purgeCache"
/>
<SettingsSection v-if="props.scope === 'storage-backups'">
<SettingsRow stacked>
<template #label>
<span id="settings-target-storage-app-directory" tabindex="-1">
{{ formatMessage(messages.axolotlDataDirectory) }}
</span>
</template>
<template #description>{{ appDirectoryDescriptionText }}</template>
<template #control>
<StyledInput
id="appDir"
v-model="settings.custom_dir"
:icon="BoxIcon"
type="text"
:disabled="isPortable"
wrapper-class="w-full"
>
<template #right>
<IconButton
:label="formatMessage(messages.appDirectory)"
class="ml-1.5"
:disabled="isPortable"
@click="findLauncherDir"
>
<FolderSearchIcon />
</IconButton>
</template>
</StyledInput>
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>
<span id="settings-target-storage-minecraft-directories" tabindex="-1">
{{ formatMessage(messages.minecraftDirectories) }}
</span>
</template>
<template #description>{{
formatMessage(messages.minecraftDirectoriesDescription)
}}</template>
<template #control>
<div class="flex w-full flex-col gap-2">
<div
v-for="(directory, index) in minecraftDirectories"
:key="`${directory}-${index}`"
class="flex min-w-0 items-center gap-2"
>
<StyledInput
:id="`minecraft-directory-${index}`"
v-model="minecraftDirectories[index].path"
:icon="BoxIcon"
type="text"
wrapper-class="min-w-0 flex-1"
@change="validateMinecraftDirectory(minecraftDirectories[index].path)"
/>
<Combobox
v-model="minecraftDirectories[index].mode"
:aria-label="formatMessage(messages.minecraftDirectoryMode)"
:options="minecraftDirectoryModeOptions"
class="w-[200px] max-w-[45%]"
/>
<IconButton
:label="formatMessage(messages.removeMinecraftDirectory)"
@click="removeMinecraftDirectory(index)"
>
<TrashIcon />
</IconButton>
</div>
<p v-if="minecraftDirectoryError" class="m-0 text-sm text-red">
{{ minecraftDirectoryError }}
</p>
<button class="btn min-w-max self-start" @click="addMinecraftDirectory">
<PlusIcon />
{{ formatMessage(messages.addMinecraftDirectory) }}
</button>
</div>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-storage-cache" tabindex="-1">
{{ formatMessage(messages.appCache) }}
</span>
</template>
<template #description>{{ formatMessage(messages.appCacheDescription) }}</template>
<template #control>
<button id="purge-cache" class="btn min-w-max" @click="handlePurgeCacheClick">
<TrashIcon />
{{ formatMessage(messages.purgeCache) }}
</button>
</template>
</SettingsRow>
<SettingsRow>
<template #label>
<span id="settings-target-resources-database-backups" tabindex="-1">
{{ formatMessage(messages.databaseBackups) }}
</span>
</template>
<template #description>{{ formatMessage(messages.databaseBackupsDescription) }}</template>
<template #control>
<button id="open-db-backups-folder" class="btn min-w-max" @click="openDbBackupsFolder">
<FolderOpenIcon />
{{ formatMessage(messages.openBackupsFolder) }}
</button>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection v-if="props.scope === 'content-downloads'">
<template #header>
<h2
id="settings-target-resources-download-mirrors"
tabindex="-1"
class="m-0 text-lg font-semibold text-contrast"
>
{{ formatMessage(messages.downloadMirrors) }}
</h2>
<p class="m-0 mt-1 text-sm leading-relaxed text-secondary">
{{ formatMessage(messages.downloadMirrorsDescription) }}
</p>
</template>
<SettingsRow>
<template #label>{{ formatMessage(messages.minecraftMetadataSource) }}</template>
<template #description>{{
formatMessage(messages.minecraftMetadataSourceDescription)
}}</template>
<template #control>
<div class="w-full">
<Combobox v-model="minecraftMetadataSource" :options="minecraftSourceOptions" />
</div>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.minecraftFileSource) }}</template>
<template #description>{{
formatMessage(messages.minecraftFileSourceDescription)
}}</template>
<template #control>
<div class="w-full">
<Combobox v-model="minecraftFileSource" :options="minecraftSourceOptions" />
</div>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.modrinthMirror) }}</template>
<template #description>{{ formatMessage(messages.modrinthMirrorDescription) }}</template>
<template #control>
<div class="w-full">
<Combobox v-model="modrinthDownloadSource" :options="modrinthSourceOptions" />
</div>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.curseforgeMirror) }}</template>
<template #description>{{ formatMessage(messages.curseforgeMirrorDescription) }}</template>
<template #control>
<div class="w-full">
<Combobox v-model="curseforgeDownloadSource" :options="curseforgeSourceOptions" />
</div>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.curseforgeRestrictionBypass) }}</template>
<template #description>
{{ formatMessage(messages.curseforgeRestrictionBypassDescription) }}
</template>
<template #control>
<Toggle
id="curseforge-restriction-bypass"
v-model="settings.bypass_curseforge_download_restrictions"
/>
</template>
</SettingsRow>
<SettingsRow>
<template #label>{{ formatMessage(messages.ignoreSslErrors) }}</template>
<template #description>
{{ formatMessage(messages.ignoreSslErrorsDescription) }}
</template>
<template #control>
<Toggle id="ignore-ssl-errors" v-model="settings.ignore_ssl_errors" />
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection v-if="props.scope === 'content-downloads'">
<SettingsRow>
<template #label>
<span id="settings-target-resources-download-engine" tabindex="-1">
{{ formatMessage(messages.downloadEngine) }}
</span>
</template>
<template #description>{{ formatMessage(messages.downloadEngineDescription) }}</template>
<template #control>
<div class="w-full">
<Combobox v-model="downloadEngine" :options="downloadEngineOptions" />
</div>
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>
<span id="settings-target-resources-maximum-downloads" tabindex="-1">
{{ formatMessage(messages.maximumDownloads) }}
</span>
</template>
<template #description>{{ formatMessage(messages.maximumDownloadsDescription) }}</template>
<template #control>
<div class="flex w-full flex-col gap-3">
<div class="w-48 max-w-full">
<Combobox v-model="downloadConcurrencyMode" :options="downloadConcurrencyOptions" />
</div>
<Slider
v-if="!settings.auto_concurrent_downloads"
id="max-downloads"
v-model="settings.max_concurrent_downloads"
:min="1"
:max="256"
:step="1"
/>
</div>
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>{{ formatMessage(messages.maximumWrites) }}</template>
<template #description>{{ formatMessage(messages.maximumWritesDescription) }}</template>
<template #control>
<div class="w-full">
<Slider
id="max-writes"
v-model="settings.max_concurrent_writes"
:min="1"
:max="50"
:step="1"
/>
</div>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection v-if="props.scope === 'network-multiplayer'">
<SettingsRow>
<template #label>
<span id="settings-target-network-mojang-auth-source" tabindex="-1">
{{ formatMessage(messages.mojangAuthService) }}
</span>
</template>
<template #description>{{ formatMessage(messages.mojangAuthServiceDescription) }}</template>
<template #control>
<div class="w-full">
<Combobox v-model="mojangAuthSource" :options="mojangAuthSourceOptions" />
</div>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection v-if="props.scope === 'network-multiplayer'">
<template #header>
<h2
id="settings-target-resources-proxy"
tabindex="-1"
class="m-0 text-lg font-semibold text-contrast"
>
{{ formatMessage(messages.proxySettings) }}
</h2>
<p class="m-0 mt-1 text-sm leading-relaxed text-secondary">
{{ formatMessage(messages.proxySettingsDescription) }}
</p>
</template>
<SettingsRow>
<template #label>{{ formatMessage(messages.proxyMode) }}</template>
<template #control>
<div class="w-full">
<Combobox v-model="proxyConfig.mode" :options="proxyModeOptions" />
</div>
</template>
</SettingsRow>
<SettingsRow v-if="proxyConfig.mode === 'custom'" stacked>
<template #label>{{ formatMessage(messages.proxyUrl) }}</template>
<template #control>
<StyledInput
id="proxy-url"
v-model="proxyConfig.url"
type="text"
:placeholder="formatMessage(messages.proxyUrlPlaceholder)"
wrapper-class="w-full"
@blur="saveProxyConfig(false)"
/>
</template>
</SettingsRow>
<SettingsRow v-if="proxyConfig.mode === 'custom'" stacked>
<template #label>{{ formatMessage(messages.proxyUsername) }}</template>
<template #control>
<StyledInput
id="proxy-username"
v-model="proxyConfig.username"
type="text"
:placeholder="formatMessage(messages.proxyUsernamePlaceholder)"
wrapper-class="w-full"
@blur="saveProxyConfig(false)"
/>
</template>
</SettingsRow>
<SettingsRow v-if="proxyConfig.mode === 'custom'" stacked>
<template #label>{{ formatMessage(messages.proxyPassword) }}</template>
<template #control>
<StyledInput
id="proxy-password"
v-model="proxyConfig.password"
type="password"
:placeholder="formatMessage(messages.proxyPasswordPlaceholder)"
wrapper-class="w-full"
@blur="saveProxyConfig(false)"
/>
</template>
</SettingsRow>
<SettingsRow compact>
<template #label>{{ formatMessage(messages.proxyTest) }}</template>
<template #control>
<div class="flex flex-wrap items-center justify-end gap-3">
<span v-if="proxyTestResult" class="text-sm text-secondary">{{ proxyTestResult }}</span>
<button
:disabled="
proxyTesting ||
proxyConfig.mode === 'none' ||
(proxyConfig.mode === 'custom' && !proxyConfig.url?.trim())
"
class="btn min-w-max"
@click="testProxy"
>
{{ formatMessage(proxyTesting ? messages.proxyTesting : messages.proxyTest) }}
</button>
</div>
</template>
</SettingsRow>
</SettingsSection>
<SettingsSection v-if="props.scope === 'content-downloads'">
<SettingsRow>
<template #label>
<span id="settings-target-resources-missing-content-import" tabindex="-1">
{{ formatMessage(messages.missingContentAutoImport) }}
</span>
</template>
<template #description>
{{ formatMessage(messages.missingContentAutoImportDescription) }}
</template>
<template #control>
<Toggle
id="missing-content-auto-import"
v-model="missingContentScannerSettings.enabled"
/>
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>{{ formatMessage(messages.missingContentImportDirectory) }}</template>
<template #description>
{{ formatMessage(messages.missingContentImportDirectoryDescription) }}
</template>
<template #control>
<div class="flex w-full flex-wrap items-center gap-2">
<div class="min-w-0 flex-1">
<StyledInput
id="missing-content-import-directory"
:model-value="
missingContentScannerSettings.directory ??
formatMessage(messages.systemDownloadsDirectory)
"
:icon="FolderOpenIcon"
type="text"
readonly
wrapper-class="w-full"
>
<template #right>
<IconButton
type="base"
:label="formatMessage(messages.selectImportDirectory)"
class="ml-1.5"
:disabled="!missingContentScannerSettings.enabled"
@click="findMissingContentImportDirectory"
>
<FolderSearchIcon />
</IconButton>
</template>
</StyledInput>
</div>
<button
v-if="missingContentScannerSettings.directory"
class="btn min-w-max"
:disabled="!missingContentScannerSettings.enabled"
@click="resetMissingContentImportDirectory"
>
{{ formatMessage(messages.resetImportDirectory) }}
</button>
</div>
</template>
</SettingsRow>
</SettingsSection>
</div>
</template>