Merge pull request #6 from ApTx2012/feat/axolotl-port

Feat/axolotl port
This commit is contained in:
Ap_Tx authored and GitHub committed 2026-10-03 12:55:16 +08:00
commit eec6b47ee7
47 files changed
+8148 -19

No files matched your search

+5
View File
@@ -19,6 +19,11 @@ jobs:
name: Post changelog comment
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: read
contents: read
steps:
- name: Post or update changelog comment
uses: actions/github-script@d746ffe35508b1917358783b479e04febd2b8f71 # v9.0.0
Generated
+48
View File
@@ -1889,6 +1889,28 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "fontconfig-parser"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbc773e24e02d4ddd8395fd30dc147524273a83e54e0f312d986ea30de5f5646"
dependencies = [
"roxmltree",
]
[[package]]
name = "fontdb"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2660c5e9157bf76d2db1294e4a9feba604ef610819a3b591088d0d8392a3290f"
dependencies = [
"fontconfig-parser",
"log",
"memmap2",
"slotmap",
"tinyvec",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -3477,6 +3499,15 @@ version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memmap2"
version = "0.9.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
dependencies = [
"libc",
]
[[package]]
name = "memoffset"
version = "0.9.1"
@@ -5102,6 +5133,12 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "roxmltree"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c20b6793b5c2fa6553b250154b78d6d0db37e72700ae35fad9387a46f487c97"
[[package]]
name = "rsa"
version = "0.9.10"
@@ -5758,6 +5795,15 @@ version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "slotmap"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
dependencies = [
"version_check",
]
[[package]]
name = "smallvec"
version = "1.15.2"
@@ -6912,6 +6958,7 @@ dependencies = [
"either",
"enumset",
"flate2",
"fontdb",
"futures",
"hyper",
"hyper-util",
@@ -6926,6 +6973,7 @@ dependencies = [
"path-util",
"quartz_nbt",
"regex",
"semver",
"serde",
"serde_json",
"serde_with",
+2
View File
@@ -44,6 +44,7 @@ encoding_rs = "0.8.35"
enumset = "1.1.10"
eyre = "0.6.12"
flate2 = "1.1.4"
fontdb = "0.24.0"
fs4 = { version = "0.13.1", default-features = false }
futures = "0.3.31"
futures-lite = "2.6.1"
@@ -83,6 +84,7 @@ rgb = "0.8.52"
rustls = { version = "0.23.42", default-features = false, features = ["ring", "std", "tls12"] }
rustls-native-certs = "0.8.4"
rustls-pki-types = "1.15.0"
semver = "1.0.28"
serde = "1.0.228"
serde_bytes = "0.11.19"
serde_cbor = "0.11.2"
@@ -0,0 +1,132 @@
<script setup lang="ts">
import { FileIcon, FolderIcon, MinusIcon, PlusIcon } from '@modrinth/assets'
import { Button, defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { open } from '@tauri-apps/plugin-dialog'
import { ref } from 'vue'
import {
type BackupExclusion,
type BackupExclusionKind,
canonicalizeBackupExclusions,
formatBackupExclusionPath,
normalizeBackupExclusion,
} from '@/helpers/instance-backup'
const props = defineProps<{
instanceId: string
instanceRoot: string
modelValue: BackupExclusion[]
disabled?: boolean
}>()
const emit = defineEmits<{ 'update:modelValue': [value: BackupExclusion[]] }>()
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const picking = ref<BackupExclusionKind | null>(null)
const messages = defineMessages({
empty: {
id: 'instance.backups.exclusions.empty',
defaultMessage: 'No files or folders are excluded.',
},
addFile: {
id: 'instance.backups.exclusions.add-file',
defaultMessage: 'Add excluded file',
},
addFolder: {
id: 'instance.backups.exclusions.add-folder',
defaultMessage: 'Add excluded folder',
},
remove: {
id: 'instance.backups.exclusions.remove',
defaultMessage: 'Remove {path} from exclusions',
},
})
function pickedPath(result: unknown): string | null {
if (typeof result === 'string') return result
if (result && typeof result === 'object' && 'path' in result) {
const path = (result as { path?: unknown }).path
return typeof path === 'string' ? path : null
}
return null
}
async function addExclusion(kind: BackupExclusionKind) {
if (props.disabled || picking.value) return
picking.value = kind
try {
const result = await open({
directory: kind === 'directory',
multiple: false,
defaultPath: props.instanceRoot,
})
const selectedPath = pickedPath(result)
if (!selectedPath) return
const exclusion = await normalizeBackupExclusion(props.instanceId, selectedPath, kind)
emit('update:modelValue', canonicalizeBackupExclusions([...props.modelValue, exclusion]))
} catch (error) {
handleError(error)
} finally {
picking.value = null
}
}
function removeExclusion(path: string) {
emit(
'update:modelValue',
props.modelValue.filter((exclusion) => exclusion.path !== path),
)
}
</script>
<template>
<div class="flex flex-col gap-3">
<p v-if="modelValue.length === 0" class="m-0 text-sm text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.empty) }}
</p>
<div
v-else
class="divide-y divide-solid divide-surface-4 rounded-lg border border-solid border-surface-4"
>
<div
v-for="exclusion in modelValue"
:key="exclusion.path"
class="flex min-h-11 items-center gap-3 px-3 py-2"
>
<FolderIcon
v-if="exclusion.kind === 'directory'"
class="size-5 shrink-0 text-[var(--color-text-tertiary)]"
/>
<FileIcon v-else class="size-5 shrink-0 text-[var(--color-text-tertiary)]" />
<code class="min-w-0 flex-1 truncate text-sm text-[var(--color-text-primary)]">{{
formatBackupExclusionPath(exclusion)
}}</code>
<Button
type="quiet"
size="2xs"
circular
icon-only
:disabled="disabled || picking !== null"
:aria-label="
formatMessage(messages.remove, { path: formatBackupExclusionPath(exclusion) })
"
@click="removeExclusion(exclusion.path)"
><MinusIcon />
</Button>
</div>
</div>
<div class="flex flex-wrap gap-2">
<Button type="outlined" :disabled="disabled || picking !== null" @click="addExclusion('file')"
><PlusIcon />
{{ formatMessage(messages.addFile) }}
</Button>
<Button
type="outlined"
:disabled="disabled || picking !== null"
@click="addExclusion('directory')"
><PlusIcon />
{{ formatMessage(messages.addFolder) }}
</Button>
</div>
</div>
</template>
@@ -0,0 +1,675 @@
<script setup lang="ts">
import { FileArchiveIcon, SaveIcon, TrashIcon, UndoIcon, XIcon } from '@modrinth/assets'
import {
Admonition,
Button,
commonMessages,
defineMessages,
injectNotificationManager,
NewModal,
useFormatBytes,
useFormatDateTime,
useVIntl,
} from '@modrinth/ui'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { get_full_path } from '@/helpers/instance'
import {
type BackupExclusion,
type BackupOperation,
type BackupOperationState,
type BackupProgressStage,
type BackupRestorePreview,
type BackupSnapshot,
cancelBackup,
deleteBackup,
disableBackups,
enableBackups,
getBackupConfig,
type InstanceBackupConfig,
type InstanceBackupEligibility,
listBackupOperations,
listBackups,
listenBackupProgress,
restoreBackup,
startBackup,
startBackupRestorePreview,
updateBackupExclusions,
} from '@/helpers/instance-backup'
import { injectInstanceSettings } from '@/providers/instance-settings'
import BackupExclusionSelector from './BackupExclusionSelector.vue'
const { instance } = injectInstanceSettings()
const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const formatDate = useFormatDateTime({ dateStyle: 'medium', timeStyle: 'short' })
const { handleError } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'instance.backups.title', defaultMessage: 'Instance backups' },
description: {
id: 'instance.backups.description',
defaultMessage: 'Create deduplicated snapshots of the whole instance while it is closed.',
},
loading: { id: 'instance.backups.loading', defaultMessage: 'Loading backup settings...' },
unavailable: { id: 'instance.backups.unavailable', defaultMessage: 'Backups are unavailable' },
notInstalled: {
id: 'instance.backups.unavailable.not-installed',
defaultMessage: 'Finish installing this instance before enabling backups.',
},
symlinkInstance: {
id: 'instance.backups.unavailable.symlink',
defaultMessage: 'Imported symbolic-link instances cannot be backed up.',
},
directLinked: {
id: 'instance.backups.unavailable.direct-linked',
defaultMessage: 'Instances linked directly to another launcher cannot be backed up.',
},
externalDirectory: {
id: 'instance.backups.unavailable.external-directory',
defaultMessage: 'Instances that use an external game directory cannot be backed up.',
},
rootMissing: {
id: 'instance.backups.unavailable.root-missing',
defaultMessage: 'The instance folder is missing.',
},
rootLink: {
id: 'instance.backups.unavailable.root-link',
defaultMessage: 'Instances whose root folder is a link cannot be backed up.',
},
choose: {
id: 'instance.backups.choose',
defaultMessage:
'Everything is backed up by default. Add files or folders that should be excluded.',
},
enable: { id: 'instance.backups.enable', defaultMessage: 'Enable backups' },
editExclusions: { id: 'instance.backups.edit-exclusions', defaultMessage: 'Edit exclusions' },
saveExclusions: {
id: 'instance.backups.save-exclusions',
defaultMessage: 'Save exclusions',
},
allIncluded: {
id: 'instance.backups.all-included',
defaultMessage: 'All instance files and folders are included.',
},
excludedPaths: {
id: 'instance.backups.excluded-paths',
defaultMessage: 'Excluded: {paths}',
},
backupNow: { id: 'instance.backups.backup-now', defaultMessage: 'Back up now' },
cancelBackup: { id: 'instance.backups.cancel', defaultMessage: 'Cancel backup' },
stageScanning: {
id: 'instance.backups.stage.scanning',
defaultMessage: 'Scanning files',
},
stageHashing: { id: 'instance.backups.stage.hashing', defaultMessage: 'Hashing files' },
stageSaving: { id: 'instance.backups.stage.saving', defaultMessage: 'Saving snapshot index' },
stageQueued: { id: 'instance.backups.stage.queued', defaultMessage: 'Waiting to start' },
stageValidating: {
id: 'instance.backups.stage.validating',
defaultMessage: 'Validating restore',
},
stageMaterializing: {
id: 'instance.backups.stage.materializing',
defaultMessage: 'Preparing changed files',
},
stageApplying: {
id: 'instance.backups.stage.applying',
defaultMessage: 'Applying changed files',
},
stagePreview: {
id: 'instance.backups.stage.preview',
defaultMessage: 'Analyzing restore changes',
},
progressBytes: {
id: 'instance.backups.progress-bytes',
defaultMessage: '{processed} of {total}',
},
stageFailed: { id: 'instance.backups.stage.failed', defaultMessage: 'Backup failed' },
stageInterrupted: {
id: 'instance.backups.stage.interrupted',
defaultMessage: 'Backup operation was interrupted',
},
snapshots: { id: 'instance.backups.snapshots', defaultMessage: 'Snapshots' },
empty: { id: 'instance.backups.empty', defaultMessage: 'No backups have been created yet.' },
snapshotMeta: {
id: 'instance.backups.snapshot-meta',
defaultMessage: '{files, plural, one {# file} other {# files}} · {size}',
},
addedSize: { id: 'instance.backups.added-size', defaultMessage: '{size} newly stored' },
restore: { id: 'instance.backups.restore', defaultMessage: 'Restore backup' },
delete: { id: 'instance.backups.delete', defaultMessage: 'Delete backup' },
disable: { id: 'instance.backups.disable', defaultMessage: 'Disable backups' },
disableBlocked: {
id: 'instance.backups.disable-blocked',
defaultMessage: 'Delete every snapshot before disabling backups.',
},
restoreTitle: { id: 'instance.backups.restore-title', defaultMessage: 'Restore this backup?' },
restoreBody: {
id: 'instance.backups.restore-body',
defaultMessage:
'Upcoming operations: add {added, plural, one {# file} other {# files}}, modify {modified, plural, one {# file} other {# files}}, and delete {deleted, plural, one {# file} other {# files}}. Continue?',
},
deleteTitle: { id: 'instance.backups.delete-title', defaultMessage: 'Delete this backup?' },
deleteBody: {
id: 'instance.backups.delete-body',
defaultMessage:
'This snapshot will be permanently deleted. Unused stored files will also be removed.',
},
})
const config = ref<InstanceBackupConfig | null>(null)
const snapshots = ref<BackupSnapshot[]>([])
const instanceRoot = ref('')
const loading = ref(true)
const editingExclusions = ref(false)
const excludedPaths = ref<BackupExclusion[]>([])
const action = ref<string | null>(null)
const operation = ref<BackupOperation | null>(null)
const selectedSnapshot = ref<BackupSnapshot | null>(null)
const restorePreview = ref<BackupRestorePreview | null>(null)
const deleteModal = ref<InstanceType<typeof NewModal>>()
const restoreModal = ref<InstanceType<typeof NewModal>>()
let unlisten: (() => void) | null = null
const eligible = computed(() => config.value?.eligibility === 'eligible')
const terminalStates: BackupOperationState[] = ['completed', 'cancelled', 'failed', 'interrupted']
const isRunning = computed(
() => operation.value !== null && !terminalStates.includes(operation.value.state),
)
const canCancel = computed(() => operation.value?.cancellable === true)
const operationLabel = computed(() => {
if (operation.value?.operation_type === 'restore_preview') {
return formatMessage(messages.stagePreview)
}
switch (operation.value?.state) {
case 'queued':
return formatMessage(messages.stageQueued)
case 'hashing':
return formatMessage(messages.stageHashing)
case 'saving':
return formatMessage(messages.stageSaving)
case 'validating':
return formatMessage(messages.stageValidating)
case 'materializing':
return formatMessage(messages.stageMaterializing)
case 'applying':
return formatMessage(messages.stageApplying)
default:
return formatMessage(messages.stageScanning)
}
})
function operationFromEvent(event: {
operationId: string
operationType: BackupOperation['operation_type']
instanceId?: string
stage: BackupProgressStage
processedBytes: number
totalBytes: number
snapshotId?: string
message?: string
}): BackupOperation {
const now = Date.now()
const state: BackupOperationState =
event.stage === 'restoring' || event.stage === 'copying'
? 'materializing'
: event.stage === 'deleting'
? 'queued'
: event.stage
return {
id: event.operationId,
operation_type: event.operationType,
instance_id: event.instanceId ?? instance.value.id,
snapshot_id: event.snapshotId,
state,
processed_bytes: event.processedBytes,
total_bytes: event.totalBytes,
cancellable: event.stage === 'scanning' || event.stage === 'hashing',
cancel_requested: false,
error: event.message,
created_at: operation.value?.id === event.operationId ? operation.value.created_at : now,
updated_at: now,
finished_at: ['completed', 'cancelled', 'failed'].includes(event.stage) ? now : undefined,
}
}
async function refreshOperation() {
const operations = await listBackupOperations(instance.value.id)
const latest = operations[0] ?? null
operation.value =
latest && (isRunningState(latest.state) || ['failed', 'interrupted'].includes(latest.state))
? latest
: null
if (operation.value?.operation_type === 'restore_preview' && operation.value.snapshot_id) {
selectedSnapshot.value =
snapshots.value.find((snapshot) => snapshot.id === operation.value?.snapshot_id) ?? null
}
}
function isRunningState(state: BackupOperationState) {
return !terminalStates.includes(state)
}
const eligibilityMessages: Record<
Exclude<InstanceBackupEligibility, 'eligible'>,
keyof typeof messages
> = {
not_installed: 'notInstalled',
symlink_instance: 'symlinkInstance',
direct_linked_instance: 'directLinked',
external_game_directory: 'externalDirectory',
root_missing: 'rootMissing',
root_is_link: 'rootLink',
}
const unavailableReason = computed(() => {
const eligibility = config.value?.eligibility
if (!eligibility || eligibility === 'eligible') return ''
return formatMessage(messages[eligibilityMessages[eligibility]])
})
async function refresh() {
loading.value = true
try {
const nextConfig = await getBackupConfig(instance.value.id)
config.value = nextConfig
excludedPaths.value = nextConfig.enabled ? [...nextConfig.excluded_paths] : []
const [nextRoot, nextSnapshots] = await Promise.all([
nextConfig.eligibility === 'eligible' ? get_full_path(instance.value.id) : '',
listBackups(instance.value.id),
])
instanceRoot.value = nextRoot
snapshots.value = nextSnapshots
await refreshOperation()
} catch (error) {
handleError(error)
} finally {
loading.value = false
}
}
async function runAction(name: string, task: () => Promise<void>) {
action.value = name
try {
await task()
} catch (error) {
handleError(error)
} finally {
action.value = null
}
}
function enable() {
void runAction('enable', async () => {
config.value = await enableBackups(instance.value.id, excludedPaths.value)
editingExclusions.value = false
})
}
function beginEditingExclusions() {
excludedPaths.value = [...(config.value?.excluded_paths ?? [])]
editingExclusions.value = true
}
function saveExclusions() {
void runAction('exclusions', async () => {
config.value = await updateBackupExclusions(instance.value.id, excludedPaths.value)
editingExclusions.value = false
})
}
function start() {
void runAction('start', async () => {
const id = await startBackup(instance.value.id)
operation.value = {
id,
operation_type: 'create',
instance_id: instance.value.id,
state: 'queued',
processed_bytes: 0,
total_bytes: 0,
cancellable: true,
cancel_requested: false,
created_at: Date.now(),
updated_at: Date.now(),
}
})
}
function cancel() {
if (!operation.value || !canCancel.value) return
void runAction('cancel', async () => {
if (await cancelBackup(operation.value!.id)) {
operation.value = {
...operation.value!,
cancellable: false,
cancel_requested: true,
}
}
})
}
function confirmDelete(snapshot: BackupSnapshot) {
selectedSnapshot.value = snapshot
deleteModal.value?.show()
}
function confirmRestore(snapshot: BackupSnapshot) {
void runAction(`preview:${snapshot.id}`, async () => {
selectedSnapshot.value = snapshot
const id = await startBackupRestorePreview(snapshot.id)
operation.value = {
id,
operation_type: 'restore_preview',
instance_id: instance.value.id,
snapshot_id: snapshot.id,
state: 'queued',
processed_bytes: 0,
total_bytes: snapshot.logical_size,
cancellable: true,
cancel_requested: false,
created_at: Date.now(),
updated_at: Date.now(),
}
})
}
function removeSnapshot() {
if (!selectedSnapshot.value) return
void runAction(`delete:${selectedSnapshot.value.id}`, async () => {
await deleteBackup(selectedSnapshot.value!.id)
await refresh()
deleteModal.value?.hide()
selectedSnapshot.value = null
})
}
function restoreSnapshot() {
if (!selectedSnapshot.value || !restorePreview.value) return
void runAction(`restore:${selectedSnapshot.value.id}`, async () => {
const id = await restoreBackup(selectedSnapshot.value!.id, restorePreview.value!.plan_token)
operation.value = {
id,
operation_type: 'restore',
instance_id: instance.value.id,
snapshot_id: selectedSnapshot.value!.id,
state: 'queued',
processed_bytes: 0,
total_bytes: selectedSnapshot.value!.logical_size,
cancellable: true,
cancel_requested: false,
created_at: Date.now(),
updated_at: Date.now(),
}
restoreModal.value?.hide()
selectedSnapshot.value = null
restorePreview.value = null
})
}
function disable() {
void runAction('disable', async () => {
await disableBackups(instance.value.id)
await refresh()
})
}
onMounted(async () => {
unlisten = await listenBackupProgress(async (event) => {
if (
!['create', 'restore', 'restore_preview'].includes(event.operationType) ||
event.instanceId !== instance.value.id
)
return
operation.value = operationFromEvent(event)
if (event.operationType === 'restore_preview' && event.stage === 'completed') {
const completed = (await listBackupOperations(instance.value.id)).find(
(candidate) => candidate.id === event.operationId,
)
const snapshot = snapshots.value.find((candidate) => candidate.id === event.snapshotId)
if (completed?.restore_preview && snapshot) {
selectedSnapshot.value = snapshot
restorePreview.value = completed.restore_preview
operation.value = null
restoreModal.value?.show()
return
}
}
if (['completed', 'cancelled', 'failed'].includes(event.stage)) {
void refresh()
}
})
await refresh()
})
onUnmounted(() => unlisten?.())
watch(
() => instance.value.id,
() => void refresh(),
)
</script>
<template>
<div class="flex flex-col gap-5">
<header>
<h2 class="m-0 text-lg font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.title) }}
</h2>
<p class="m-0 mt-1 text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.description) }}
</p>
</header>
<p v-if="loading" class="m-0 text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.loading) }}
</p>
<Admonition v-else-if="!eligible" type="warning" :header="formatMessage(messages.unavailable)">
{{ unavailableReason }}
</Admonition>
<template v-else-if="config">
<div v-if="!config.enabled || editingExclusions" class="flex flex-col gap-4">
<p class="m-0 text-[var(--color-text-tertiary)]">{{ formatMessage(messages.choose) }}</p>
<BackupExclusionSelector
v-model="excludedPaths"
:instance-id="instance.id"
:instance-root="instanceRoot"
:disabled="action !== null"
/>
<div class="flex flex-wrap gap-2">
<Button :disabled="action !== null" @click="config.enabled ? saveExclusions() : enable()"
><SaveIcon />
{{ formatMessage(config.enabled ? messages.saveExclusions : messages.enable) }}
</Button>
<Button
v-if="config.enabled"
type="outlined"
:disabled="action !== null"
@click="editingExclusions = false"
><XIcon />
{{ formatMessage(commonMessages.cancelButton) }}
</Button>
</div>
</div>
<template v-else>
<div class="flex flex-col gap-2 rounded-lg border border-solid border-surface-4 p-3">
<p class="m-0 text-sm text-[var(--color-text-tertiary)]">
{{
config.excluded_paths.length === 0
? formatMessage(messages.allIncluded)
: formatMessage(messages.excludedPaths, {
paths: config.excluded_paths.map((entry) => entry.path).join(', '),
})
}}
</p>
<div class="flex flex-wrap gap-2">
<Button :disabled="isRunning || action !== null" @click="start"
><FileArchiveIcon />
{{ formatMessage(messages.backupNow) }}
</Button>
<Button v-if="canCancel" type="outlined" :disabled="action !== null" @click="cancel"
><XIcon />
{{ formatMessage(messages.cancelBackup) }}
</Button>
<Button
type="outlined"
:disabled="isRunning || action !== null"
@click="beginEditingExclusions"
>{{ formatMessage(messages.editExclusions) }}
</Button>
</div>
<p v-if="isRunning" class="m-0 text-sm font-medium text-[var(--color-text-primary)]">
{{ operationLabel }}
<span
v-if="(operation?.total_bytes ?? 0) > 0"
class="ml-2 text-[var(--color-text-tertiary)]"
>
{{
formatMessage(messages.progressBytes, {
processed: formatBytes(operation?.processed_bytes ?? 0),
total: formatBytes(operation?.total_bytes ?? 0),
})
}}
</span>
</p>
<p
v-else-if="operation?.state === 'failed' || operation?.state === 'interrupted'"
class="m-0 text-sm text-red"
>
{{
formatMessage(
operation.state === 'interrupted'
? messages.stageInterrupted
: messages.stageFailed,
)
}}<span v-if="operation.error">: {{ operation.error }}</span>
</p>
</div>
<section class="flex flex-col gap-3">
<h3 class="m-0 text-base font-semibold text-[var(--color-text-primary)]">
{{ formatMessage(messages.snapshots) }}
</h3>
<p v-if="snapshots.length === 0" class="m-0 text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.empty) }}
</p>
<div
v-else
class="divide-y divide-solid divide-surface-4 rounded-lg border border-solid border-surface-4"
>
<div
v-for="snapshot in snapshots"
:key="snapshot.id"
class="flex items-center gap-3 px-3 py-3"
>
<FileArchiveIcon class="size-5 shrink-0 text-[var(--color-text-tertiary)]" />
<div class="min-w-0 flex-1">
<p class="m-0 font-semibold text-[var(--color-text-primary)]">
{{ formatDate(new Date(snapshot.created_at)) }}
</p>
<p class="m-0 text-sm text-[var(--color-text-tertiary)]">
{{
formatMessage(messages.snapshotMeta, {
files: snapshot.file_count,
size: formatBytes(snapshot.logical_size),
})
}}
·
{{
formatMessage(messages.addedSize, { size: formatBytes(snapshot.added_size) })
}}
</p>
</div>
<Button
type="quiet"
size="2xs"
circular
icon-only
:disabled="action !== null || isRunning"
:aria-label="formatMessage(messages.restore)"
@click="confirmRestore(snapshot)"
><UndoIcon />
</Button>
<Button
type="quiet"
color="red"
size="2xs"
circular
icon-only
:disabled="action !== null || isRunning"
:aria-label="formatMessage(messages.delete)"
@click="confirmDelete(snapshot)"
><TrashIcon />
</Button>
</div>
</div>
</section>
<div class="flex flex-col items-start gap-2">
<Button
type="outlined"
color="red"
:disabled="snapshots.length > 0 || action !== null || isRunning"
@click="disable"
>{{ formatMessage(messages.disable) }}
</Button>
<p v-if="snapshots.length > 0" class="m-0 text-sm text-[var(--color-text-tertiary)]">
{{ formatMessage(messages.disableBlocked) }}
</p>
</div>
</template>
</template>
<NewModal
ref="deleteModal"
:header="formatMessage(messages.deleteTitle)"
fade="danger"
max-width="500px"
>
<Admonition type="critical">{{ formatMessage(messages.deleteBody) }}</Admonition>
<template #actions>
<div class="flex items-center justify-end gap-2">
<Button
type="outlined"
:disabled="action?.startsWith('delete:')"
@click="deleteModal?.hide()"
>{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button
type="colored"
color="red"
:disabled="action?.startsWith('delete:')"
@click="removeSnapshot"
><TrashIcon />{{ formatMessage(messages.delete) }}
</Button>
</div>
</template>
</NewModal>
<NewModal ref="restoreModal" :header="formatMessage(messages.restoreTitle)" max-width="500px">
<Admonition v-if="restorePreview" type="warning">
{{
formatMessage(messages.restoreBody, {
added: restorePreview.added_files,
modified: restorePreview.modified_files,
deleted: restorePreview.deleted_files,
})
}}
</Admonition>
<template #actions>
<div class="flex items-center justify-end gap-2">
<Button
type="outlined"
:disabled="action?.startsWith('restore:')"
@click="restoreModal?.hide()"
>{{ formatMessage(commonMessages.cancelButton) }}
</Button>
<Button :disabled="action?.startsWith('restore:')" @click="restoreSnapshot"
><UndoIcon />{{ formatMessage(messages.restore) }}
</Button>
</div>
</template>
</NewModal>
</div>
</template>
@@ -20,6 +20,7 @@ import type { PlatformTag } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, nextTick, ref, watch } from 'vue'
import BackupSettings from '@/components/ui/instance_settings/BackupSettings.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'
@@ -105,6 +106,14 @@ const tabs = computed<TabbedModalTab[]>(() => [
icon: WrenchIcon,
content: InstallationSettings,
},
{
name: defineMessage({
id: 'instance.settings.tabs.backups',
defaultMessage: 'Backups',
}),
icon: FileArchiveIcon,
content: BackupSettings,
},
{
// Core component editing is instance-specific and advanced, so it is intentionally excluded from first-run onboarding.
name: defineMessage({
@@ -22,7 +22,7 @@ import { convertFileSrc } from '@tauri-apps/api/core'
import { appDataDir, join } from '@tauri-apps/api/path'
import { open } from '@tauri-apps/plugin-dialog'
import { exists, mkdir, readFile, remove, writeFile } from '@tauri-apps/plugin-fs'
import { computed, ref, watch } from 'vue'
import { computed, onMounted, ref, watch } from 'vue'
import { get, set } from '@/helpers/settings.ts'
import { getOS } from '@/helpers/utils'
@@ -41,6 +41,19 @@ import {
parseCustomAccentColor,
} from '@/store/theme.ts'
import {
buildFontFamilyOptions,
canonicalFontFamily,
DEFAULT_MONO_FONT_STACK,
DEFAULT_UI_FONT_STACK,
type FontFamilyOption,
type FontFamilyOptionOrDivider,
optionValueToFontSetting,
resolveFontFamily,
type SystemFontFamily,
} from '@/helpers/font-family.ts'
import { getSystemFontFamilies } from '@/helpers/system-fonts.ts'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
@@ -363,6 +376,63 @@ const messages = defineMessages({
defaultMessage:
'Hide the downloads button in the sidebar when there are no active download tasks.',
},
fontsTitle: { id: 'app.appearance-settings.fonts.title', defaultMessage: 'Fonts' },
fontsDescription: {
id: 'app.appearance-settings.fonts.description',
defaultMessage: 'Choose the fonts the launcher interface and its logs are rendered in.',
},
uiFontTitle: { id: 'app.appearance-settings.fonts.ui-font', defaultMessage: 'Interface font' },
uiFontDescription: {
id: 'app.appearance-settings.fonts.ui-font-description',
defaultMessage:
'Used across the launcher interface. Only fonts installed on this system are listed.',
},
monoFontTitle: {
id: 'app.appearance-settings.fonts.mono-font',
defaultMessage: 'Monospace font',
},
monoFontDescription: {
id: 'app.appearance-settings.fonts.mono-font-description',
defaultMessage: 'Used for logs, the server console, and other monospace content.',
},
fontDefault: {
id: 'app.appearance-settings.fonts.default',
defaultMessage: 'Launcher default',
},
fontMonospace: {
id: 'app.appearance-settings.fonts.monospace',
defaultMessage: 'Monospace',
},
fontMissing: {
id: 'app.appearance-settings.fonts.missing',
defaultMessage: 'Not installed',
},
fontSearchPlaceholder: {
id: 'app.appearance-settings.fonts.search-placeholder',
defaultMessage: 'Search installed fonts',
},
fontNoResults: {
id: 'app.appearance-settings.fonts.no-results',
defaultMessage: 'No matching fonts',
},
fontLoadFailed: {
id: 'app.appearance-settings.fonts.load-failed',
defaultMessage: "Couldn't read the list of installed fonts.",
},
fontRetry: { id: 'app.appearance-settings.fonts.retry', defaultMessage: 'Retry' },
fontReset: {
id: 'app.appearance-settings.fonts.reset',
defaultMessage: 'Reset',
},
fontPreviewLabel: { id: 'app.appearance-settings.fonts.preview', defaultMessage: 'Preview' },
fontUiPreviewSample: {
id: 'app.appearance-settings.fonts.ui-preview-sample',
defaultMessage: 'The quick brown fox jumps over the lazy dog.',
},
fontMonoPreviewSample: {
id: 'app.appearance-settings.fonts.mono-preview-sample',
defaultMessage: 'const x = () => 0; // monospace',
},
})
const os = ref(await getOS())
@@ -373,6 +443,78 @@ const customBackgroundPreview = computed(() =>
: null,
)
const systemFonts = ref<SystemFontFamily[]>([])
const systemFontsLoading = ref(true)
const systemFontsFailed = ref(false)
async function loadSystemFonts() {
systemFontsLoading.value = true
systemFontsFailed.value = false
try {
systemFonts.value = await getSystemFontFamilies()
} catch (error) {
systemFontsFailed.value = true
console.warn('Failed to read the list of installed fonts', error)
} finally {
systemFontsLoading.value = false
}
}
function selectedOptionLabel(options: FontFamilyOptionOrDivider[], value: string): string {
const option = options.find(
(entry): entry is FontFamilyOption => 'value' in entry && entry.value === value,
)
return option?.label ?? ''
}
const uiFontOptions = computed<FontFamilyOptionOrDivider[]>(() =>
buildFontFamilyOptions(systemFonts.value, {
defaultLabel: formatMessage(messages.fontDefault),
missingLabel: formatMessage(messages.fontMissing),
selected: settings.value.ui_font,
}),
)
const monoFontOptions = computed<FontFamilyOptionOrDivider[]>(() =>
buildFontFamilyOptions(systemFonts.value, {
defaultLabel: formatMessage(messages.fontDefault),
missingLabel: formatMessage(messages.fontMissing),
monospaceLabel: formatMessage(messages.fontMonospace),
selected: settings.value.mono_font,
groupMonospaced: true,
}),
)
const uiFontSelection = computed({
get: () => canonicalFontFamily(systemFonts.value, settings.value.ui_font),
set: (value: string) => {
settings.value.ui_font = optionValueToFontSetting(value)
},
})
const monoFontSelection = computed({
get: () => canonicalFontFamily(systemFonts.value, settings.value.mono_font),
set: (value: string) => {
settings.value.mono_font = optionValueToFontSetting(value)
},
})
const uiFontSearchValue = computed(() =>
selectedOptionLabel(uiFontOptions.value, uiFontSelection.value),
)
const monoFontSearchValue = computed(() =>
selectedOptionLabel(monoFontOptions.value, monoFontSelection.value),
)
const uiFontPreview = computed(() =>
resolveFontFamily(settings.value.ui_font, DEFAULT_UI_FONT_STACK),
)
const monoFontPreview = computed(() =>
resolveFontFamily(settings.value.mono_font, DEFAULT_MONO_FONT_STACK),
)
function updateUiScale(value: number) {
const clamped = Math.min(200, Math.max(50, Math.round(value)))
setUiScale(clamped)
@@ -489,6 +631,8 @@ watch(
settings.value.transparent_background_blur,
settings.value.sidebar_instance_count,
settings.value.close_behavior,
settings.value.ui_font,
settings.value.mono_font,
] as const,
([
path,
@@ -499,6 +643,8 @@ watch(
transparentBlur,
sidebarInstanceCount,
closeBehavior,
uiFont,
monoFont,
]) => {
themeStore.customBackgroundPath = path
themeStore.customBackgroundBlur = blur
@@ -509,10 +655,19 @@ watch(
themeStore.setTransparentBackgroundClass()
themeStore.sidebarInstanceCount = sidebarInstanceCount
themeStore.closeBehavior = closeBehavior as CloseBehavior
themeStore.uiFont = uiFont
themeStore.monoFont = monoFont
themeStore.setUiFont()
themeStore.setMonoFont()
},
{ immediate: true },
)
onMounted(() => {
if (props.scope === 'interface') {
void loadSystemFonts()
}
})
watch(
settings,
async () => {
@@ -1148,6 +1303,118 @@ watch(
</SettingsRow>
</SettingsSection>
<SettingsSection v-if="props.scope === 'interface'">
<template #header>
<h2
id="settings-target-appearance-fonts"
tabindex="-1"
class="m-0 text-lg font-semibold text-contrast"
>
{{ formatMessage(messages.fontsTitle) }}
</h2>
<p class="m-0 mt-1 text-sm leading-relaxed text-secondary">
{{ formatMessage(messages.fontsDescription) }}
</p>
</template>
<SettingsRow stacked>
<template #label>
<span id="settings-target-appearance-ui-font" tabindex="-1">
{{ formatMessage(messages.uiFontTitle) }}
</span>
</template>
<template #description>{{ formatMessage(messages.uiFontDescription) }}</template>
<template #control>
<div class="flex w-full flex-col gap-3">
<div class="flex items-center gap-2">
<div class="min-w-0 flex-1">
<Combobox
v-model="uiFontSelection"
:options="uiFontOptions"
searchable
:search-value="uiFontSearchValue"
:search-placeholder="formatMessage(messages.fontSearchPlaceholder)"
:no-options-message="formatMessage(messages.fontNoResults)"
show-no-options-when-empty
:disabled="systemFontsLoading"
/>
</div>
<Button
type="quiet"
:disabled="settings.ui_font === null"
@click="settings.ui_font = null"
>
{{ formatMessage(messages.fontReset) }}
</Button>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-secondary">
{{ formatMessage(messages.fontPreviewLabel) }}
</span>
<div
class="rounded-[var(--radius-md)] bg-surface-3 px-3 py-2 text-base text-contrast"
:style="{ fontFamily: uiFontPreview }"
>
{{ formatMessage(messages.fontUiPreviewSample) }}
</div>
</div>
</div>
</template>
</SettingsRow>
<SettingsRow stacked>
<template #label>
<span id="settings-target-appearance-mono-font" tabindex="-1">
{{ formatMessage(messages.monoFontTitle) }}
</span>
</template>
<template #description>{{ formatMessage(messages.monoFontDescription) }}</template>
<template #control>
<div class="flex w-full flex-col gap-3">
<div class="flex items-center gap-2">
<div class="min-w-0 flex-1">
<Combobox
v-model="monoFontSelection"
:options="monoFontOptions"
searchable
:search-value="monoFontSearchValue"
:search-placeholder="formatMessage(messages.fontSearchPlaceholder)"
:no-options-message="formatMessage(messages.fontNoResults)"
show-no-options-when-empty
:disabled="systemFontsLoading"
/>
</div>
<Button
type="quiet"
:disabled="settings.mono_font === null"
@click="settings.mono_font = null"
>
{{ formatMessage(messages.fontReset) }}
</Button>
</div>
<div class="flex flex-col gap-1">
<span class="text-xs text-secondary">
{{ formatMessage(messages.fontPreviewLabel) }}
</span>
<div
class="rounded-[var(--radius-md)] bg-surface-3 px-3 py-2 text-xs text-contrast"
:style="{ fontFamily: monoFontPreview }"
>
{{ formatMessage(messages.fontMonoPreviewSample) }}
</div>
</div>
</div>
</template>
</SettingsRow>
<div
v-if="systemFontsFailed"
class="flex items-center justify-between gap-3 px-4 pb-4 text-sm text-secondary"
>
<span>{{ formatMessage(messages.fontLoadFailed) }}</span>
<Button type="quiet" @click="loadSystemFonts">
{{ formatMessage(messages.fontRetry) }}
</Button>
</div>
</SettingsSection>
<SettingsSection v-if="props.scope === 'content-downloads'">
<SettingsRow>
<template #label>
@@ -0,0 +1,167 @@
<script setup lang="ts">
import { FolderIcon } from '@modrinth/assets'
import {
Admonition,
Button,
defineMessages,
injectFilePicker,
injectNotificationManager,
useFormatBytes,
useVIntl,
} from '@modrinth/ui'
import { onMounted, onUnmounted, ref } from 'vue'
import {
type BackupRepositoryStatus,
getBackupRepositoryStatus,
listBackupOperations,
listenBackupProgress,
moveBackupRepository,
} from '@/helpers/instance-backup'
import SettingsRow from './SettingsRow.vue'
import SettingsSection from './SettingsSection.vue'
const { formatMessage } = useVIntl()
const formatBytes = useFormatBytes()
const filePicker = injectFilePicker()
const { handleError } = injectNotificationManager()
const status = ref<BackupRepositoryStatus | null>(null)
const loading = ref(true)
const moving = ref(false)
const movedBytes = ref(0)
const moveTotalBytes = ref(0)
const movingOperationId = ref<string | null>(null)
let unlisten: (() => void) | null = null
const messages = defineMessages({
title: { id: 'settings.backups.repository.title', defaultMessage: 'Backup repository' },
description: {
id: 'settings.backups.repository.description',
defaultMessage: 'Deduplicated instance backup data and its independent index database.',
},
location: { id: 'settings.backups.repository.location', defaultMessage: 'Location' },
locationDescription: {
id: 'settings.backups.repository.location-description',
defaultMessage:
'Changing the location moves the complete repository. Choose a new or empty folder.',
},
change: { id: 'settings.backups.repository.change', defaultMessage: 'Change folder' },
moving: { id: 'settings.backups.repository.moving', defaultMessage: 'Moving repository...' },
movingProgress: {
id: 'settings.backups.repository.moving-progress',
defaultMessage: 'Moving repository: {processed} of {total}',
},
usage: { id: 'settings.backups.repository.usage', defaultMessage: 'Storage usage' },
usageDescription: {
id: 'settings.backups.repository.usage-description',
defaultMessage:
'{stored} stored for {logical} of backup data across {count, plural, one {# snapshot} other {# snapshots}}.',
},
unavailable: {
id: 'settings.backups.repository.unavailable',
defaultMessage: 'Repository unavailable',
},
})
async function refresh() {
loading.value = true
try {
status.value = await getBackupRepositoryStatus()
} catch (error) {
handleError(error)
} finally {
loading.value = false
}
}
async function changeLocation() {
const selection = await filePicker.pickFolder?.()
if (!selection) return
moving.value = true
movedBytes.value = 0
moveTotalBytes.value = 0
try {
movingOperationId.value = await moveBackupRepository(selection.path)
} catch (error) {
handleError(error)
moving.value = false
}
}
onMounted(async () => {
unlisten = await listenBackupProgress((event) => {
if (event.operationType !== 'repository_move') return
if (movingOperationId.value && event.operationId !== movingOperationId.value) return
movingOperationId.value = event.operationId
moving.value = !event.finalState
movedBytes.value = event.processedBytes
moveTotalBytes.value = event.totalBytes
if (event.finalState) void refresh()
})
const activeMove = (await listBackupOperations(undefined, true)).find(
(operation) => operation.operation_type === 'repository_move',
)
if (activeMove) {
movingOperationId.value = activeMove.id
moving.value = true
movedBytes.value = activeMove.processed_bytes
moveTotalBytes.value = activeMove.total_bytes
}
await refresh()
})
onUnmounted(() => unlisten?.())
</script>
<template>
<SettingsSection
:title="formatMessage(messages.title)"
:description="formatMessage(messages.description)"
>
<SettingsRow>
<template #label>{{ formatMessage(messages.location) }}</template>
<template #description>
<span class="break-all">{{ status?.path ?? '...' }}</span>
<span class="mt-1 block">{{ formatMessage(messages.locationDescription) }}</span>
</template>
<template #control>
<Button type="outlined" :disabled="loading || moving" @click="changeLocation"
><FolderIcon />
{{
formatMessage(
moving && moveTotalBytes > 0
? messages.movingProgress
: moving
? messages.moving
: messages.change,
{
processed: formatBytes(movedBytes),
total: formatBytes(moveTotalBytes),
},
)
}}
</Button>
</template>
</SettingsRow>
<SettingsRow v-if="status">
<template #label>{{ formatMessage(messages.usage) }}</template>
<template #description>
{{
formatMessage(messages.usageDescription, {
stored: formatBytes(status.stored_size),
logical: formatBytes(status.logical_size),
count: status.snapshot_count,
})
}}
</template>
</SettingsRow>
<Admonition
v-if="status && !status.available"
type="critical"
:header="formatMessage(messages.unavailable)"
>
{{ status.error }}
</Admonition>
</SettingsSection>
</template>
@@ -1,4 +1,5 @@
<script setup lang="ts">
import BackupRepositorySettings from './BackupRepositorySettings.vue'
import ResourceManagementSettings from './ResourceManagementSettings.vue'
import StorageSettings from './StorageSettings.vue'
</script>
@@ -6,6 +7,7 @@ import StorageSettings from './StorageSettings.vue'
<template>
<div class="flex flex-col gap-6">
<ResourceManagementSettings scope="storage-backups" />
<BackupRepositorySettings />
<StorageSettings />
</div>
</template>
@@ -0,0 +1,179 @@
/**
* Font family plumbing shared by the appearance settings pickers and the theme
* store. Kept free of imports so `node --test` can load it directly.
*/
/** Mirrors `--font-standard` in `packages/assets/styles/defaults.scss`. */
export const DEFAULT_UI_FONT_STACK =
'Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif'
/** Mirrors `--mono-font` in `packages/assets/styles/defaults.scss`. */
export const DEFAULT_MONO_FONT_STACK =
'ui-monospace, SFMono-Regular, SF Mono, Menlo, Consolas, Liberation Mono, monospace'
/** Picker value for "follow the launcher default"; persisted as `null`. */
export const FOLLOW_DEFAULT_FONT = ''
export interface SystemFontFamily {
family: string
monospaced: boolean
}
export interface FontFamilyOption {
value: string
label: string
subLabel?: string
searchTerms?: string[]
}
export type FontFamilyOptionOrDivider = FontFamilyOption | { type: 'divider' }
export interface FontFamilyOptionsConfig {
defaultLabel: string
selected?: string | null
missingLabel?: string
monospaceLabel?: string
groupMonospaced?: boolean
}
const MAX_FONT_FAMILY_LENGTH = 128
/**
* Quotes and escapes an installed family name so it cannot break out of the
* font-family declaration it is spliced into. Returns null when the name has
* nothing usable left.
*/
export function cssFontFamilyName(name: string): string | null {
const trimmed = name.replace(/\p{Cc}/gu, '').trim()
if (!trimmed || trimmed.length > MAX_FONT_FAMILY_LENGTH) {
return null
}
return `"${trimmed.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
}
/**
* The value written into `--font-standard` / `--mono-font`. The launcher stack
* always trails the chosen family so an unresolvable name degrades to the
* previous default instead of the user agent's own font.
*/
export function resolveFontFamily(
setting: string | null | undefined,
fallbackStack: string,
): string {
if (!setting) {
return fallbackStack
}
const family = cssFontFamilyName(setting)
return family ? `${family}, ${fallbackStack}` : fallbackStack
}
export function fontSettingToOptionValue(setting: string | null | undefined): string {
return setting ?? FOLLOW_DEFAULT_FONT
}
export function optionValueToFontSetting(value: string): string | null {
const trimmed = value.trim()
return trimmed === FOLLOW_DEFAULT_FONT ? null : trimmed
}
function compareFamilies(first: SystemFontFamily, second: SystemFontFamily): number {
const left = first.family.toLowerCase()
const right = second.family.toLowerCase()
if (left === right) {
return 0
}
return left < right ? -1 : 1
}
function toOption(family: SystemFontFamily, monospaceLabel?: string): FontFamilyOption {
return {
value: family.family,
label: family.family,
...(family.monospaced && monospaceLabel
? { subLabel: monospaceLabel, searchTerms: [monospaceLabel] }
: {}),
}
}
/**
* The spelling the font list uses for a stored family. Font names are matched
* case-insensitively so a stored value that only differs in case still selects
* its option instead of leaving the picker blank.
*/
export function canonicalFontFamily(
fonts: SystemFontFamily[],
setting: string | null | undefined,
): string {
const selected = optionValueToFontSetting(setting ?? '')
if (!selected) {
return FOLLOW_DEFAULT_FONT
}
const listed = fonts.find((font) => font.family.trim().toLowerCase() === selected.toLowerCase())
return listed ? listed.family.trim() : selected
}
/**
* Builds the picker options for the installed font collection: the default
* entry first, the current selection re-added when it is no longer installed,
* and — for the monospace picker — monospaced families ahead of the rest.
*/
export function buildFontFamilyOptions(
fonts: SystemFontFamily[],
config: FontFamilyOptionsConfig,
): FontFamilyOptionOrDivider[] {
const families = new Map<string, SystemFontFamily>()
for (const font of fonts) {
const family = font.family.trim()
if (!family) {
continue
}
const key = family.toLowerCase()
const known = families.get(key)
if (known) {
known.monospaced ||= font.monospaced
} else {
families.set(key, { family, monospaced: font.monospaced })
}
}
const sorted = [...families.values()].sort(compareFamilies)
const selected = optionValueToFontSetting(config.selected ?? '')
const options: FontFamilyOptionOrDivider[] = [
{ value: FOLLOW_DEFAULT_FONT, label: config.defaultLabel },
]
if (selected && !families.has(selected.toLowerCase()) && config.missingLabel) {
options.push({ value: selected, label: selected, subLabel: config.missingLabel })
}
if (config.groupMonospaced) {
const monospaced = sorted.filter((font) => font.monospaced)
const proportional = sorted.filter((font) => !font.monospaced)
options.push(...monospaced.map((font) => toOption(font, config.monospaceLabel)))
if (monospaced.length > 0 && proportional.length > 0) {
options.push({ type: 'divider' })
}
options.push(...proportional.map((font) => toOption(font, config.monospaceLabel)))
return options
}
options.push(...sorted.map((font) => toOption(font)))
return options
}
@@ -0,0 +1,38 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { canonicalizeBackupExclusions, formatBackupExclusionPath } from './instance-backup.ts'
test('directory exclusions display with a trailing slash', () => {
assert.equal(formatBackupExclusionPath({ path: 'caches', kind: 'directory' }), 'caches/')
assert.equal(
formatBackupExclusionPath({ path: 'config/somemod/bigdb.sqlite', kind: 'file' }),
'config/somemod/bigdb.sqlite',
)
})
test('directory exclusions cover descendants while file exclusions stay exact', () => {
assert.deepEqual(
canonicalizeBackupExclusions([
{ path: 'saves/world', kind: 'directory' },
{ path: 'config\\bigdb.sqlite', kind: 'file' },
{ path: 'saves', kind: 'directory' },
{ path: 'config', kind: 'file' },
]),
[
{ path: 'config', kind: 'file' },
{ path: 'saves', kind: 'directory' },
{ path: 'config/bigdb.sqlite', kind: 'file' },
],
)
})
test('directory wins when the same exclusion is selected as both kinds', () => {
assert.deepEqual(
canonicalizeBackupExclusions([
{ path: 'cache', kind: 'file' },
{ path: 'cache', kind: 'directory' },
]),
[{ path: 'cache', kind: 'directory' }],
)
})
@@ -0,0 +1,237 @@
import { invoke } from '@tauri-apps/api/core'
import { listen, type UnlistenFn } from '@tauri-apps/api/event'
export type InstanceBackupEligibility =
| 'eligible'
| 'not_installed'
| 'symlink_instance'
| 'direct_linked_instance'
| 'external_game_directory'
| 'root_missing'
| 'root_is_link'
export interface BackupRepositoryStatus {
path: string
initialized: boolean
available: boolean
stored_size: number
logical_size: number
snapshot_count: number
error: string | null
}
export type BackupExclusionKind = 'file' | 'directory'
export interface BackupExclusion {
path: string
kind: BackupExclusionKind
}
export function formatBackupExclusionPath(exclusion: BackupExclusion): string {
return exclusion.kind === 'directory' ? `${exclusion.path}/` : exclusion.path
}
export interface InstanceBackupConfig {
instance_id: string
enabled: boolean
eligibility: InstanceBackupEligibility
excluded_paths: BackupExclusion[]
snapshot_count: number
}
export interface BackupSnapshot {
id: string
instance_id: string
instance_name: string
created_at: number
file_count: number
symlink_count: number
logical_size: number
added_size: number
}
export interface BackupDeleteSummary {
snapshot_count: number
logical_size: number
}
export interface BackupRestorePreview {
added_files: number
modified_files: number
deleted_files: number
plan_token: string
}
export type BackupOperationType =
'create' | 'restore' | 'restore_preview' | 'delete' | 'repository_move'
export type BackupOperationState =
| 'queued'
| 'scanning'
| 'hashing'
| 'saving'
| 'validating'
| 'materializing'
| 'applying'
| 'completed'
| 'cancelled'
| 'failed'
| 'interrupted'
export interface BackupOperation {
id: string
operation_type: BackupOperationType
instance_id: string
snapshot_id?: string
state: BackupOperationState
processed_bytes: number
total_bytes: number
cancellable: boolean
cancel_requested: boolean
error?: string
created_at: number
updated_at: number
finished_at?: number
restore_preview?: BackupRestorePreview
}
export type BackupProgressStage =
| 'scanning'
| 'hashing'
| 'saving'
| 'validating'
| 'copying'
| 'restoring'
| 'deleting'
| 'completed'
| 'cancelled'
| 'failed'
export type BackupOperationFinalState = 'completed' | 'cancelled' | 'failed'
export interface BackupProgressEvent {
operationId: string
instanceId?: string
operationType: BackupOperationType
stage: BackupProgressStage
processedBytes: number
totalBytes: number
finalState?: BackupOperationFinalState
snapshotId?: string
message?: string
}
export function canonicalizeBackupExclusions(values: BackupExclusion[]): BackupExclusion[] {
const normalized = values
.map((value) => ({ ...value, path: value.path.replaceAll('\\', '/') }))
.sort(
(left, right) =>
left.path.split('/').length - right.path.split('/').length ||
left.path.localeCompare(right.path),
)
const result: BackupExclusion[] = []
for (const value of normalized) {
const duplicate = result.find((existing) => existing.path === value.path)
if (duplicate) {
if (value.kind === 'directory') duplicate.kind = 'directory'
continue
}
if (
result.some(
(parent) => parent.kind === 'directory' && value.path.startsWith(`${parent.path}/`),
)
) {
continue
}
result.push(value)
}
return result
}
export function getBackupRepositoryStatus(): Promise<BackupRepositoryStatus> {
return invoke('plugin:instance|instance_get_backup_repository_status')
}
export function moveBackupRepository(destination: string): Promise<string> {
return invoke('plugin:instance|instance_move_backup_repository', { destination })
}
export function getBackupConfig(instanceId: string): Promise<InstanceBackupConfig> {
return invoke('plugin:instance|instance_get_backup_config', { instanceId })
}
export function normalizeBackupExclusion(
instanceId: string,
selectedPath: string,
kind: BackupExclusionKind,
): Promise<BackupExclusion> {
return invoke('plugin:instance|instance_normalize_backup_exclusion', {
instanceId,
selectedPath,
kind,
})
}
export function enableBackups(
instanceId: string,
excludedPaths: BackupExclusion[],
): Promise<InstanceBackupConfig> {
return invoke('plugin:instance|instance_enable_backups', { instanceId, excludedPaths })
}
export function updateBackupExclusions(
instanceId: string,
excludedPaths: BackupExclusion[],
): Promise<InstanceBackupConfig> {
return invoke('plugin:instance|instance_update_backup_exclusions', {
instanceId,
excludedPaths,
})
}
export function disableBackups(instanceId: string): Promise<void> {
return invoke('plugin:instance|instance_disable_backups', { instanceId })
}
export function startBackup(instanceId: string): Promise<string> {
return invoke('plugin:instance|instance_start_backup', { instanceId })
}
export function cancelBackup(operationId: string): Promise<boolean> {
return invoke('plugin:instance|instance_cancel_backup', { operationId })
}
export function listBackupOperations(
instanceId?: string,
activeOnly = false,
): Promise<BackupOperation[]> {
return invoke('plugin:instance|instance_list_backup_operations', {
instanceId,
activeOnly,
})
}
export function listBackups(instanceId: string): Promise<BackupSnapshot[]> {
return invoke('plugin:instance|instance_list_backups', { instanceId })
}
export function deleteBackup(snapshotId: string): Promise<void> {
return invoke('plugin:instance|instance_delete_backup', { snapshotId })
}
export function startBackupRestorePreview(snapshotId: string): Promise<string> {
return invoke('plugin:instance|instance_get_backup_restore_preview', { snapshotId })
}
export function restoreBackup(snapshotId: string, planToken: string): Promise<string> {
return invoke('plugin:instance|instance_restore_backup', { snapshotId, planToken })
}
export function getBackupDeleteSummary(instanceId: string): Promise<BackupDeleteSummary> {
return invoke('plugin:instance|instance_get_backup_delete_summary', { instanceId })
}
export function listenBackupProgress(
handler: (event: BackupProgressEvent) => void,
): Promise<UnlistenFn> {
return listen<BackupProgressEvent>('instance_backup_progress', (event) => handler(event.payload))
}
@@ -174,6 +174,8 @@ export type AppSettings = {
minimal_home_instance_id: string | null
close_behavior: 'ask' | 'close' | 'lightweight'
ui_scale: number
ui_font: string | null
mono_font: string | null
home_widgets: HomeDashboardConfig | null
terracotta_public_nodes: string[]
@@ -216,6 +218,8 @@ type LegacyMirrorSettings = {
function normalizeDownloadSettings(settings: AppSettings & LegacyMirrorSettings): AppSettings {
settings.close_behavior ??= 'ask'
settings.ui_scale ??= 100
settings.ui_font ??= null
settings.mono_font ??= null
const hasLegacySettings =
typeof settings.use_minecraft_mirror === 'boolean' &&
typeof settings.use_modrinth_mirror === 'boolean' &&
@@ -0,0 +1,19 @@
import { invoke } from '@tauri-apps/api/core'
import type { SystemFontFamily } from './font-family.ts'
let cached: Promise<SystemFontFamily[]> | null = null
/**
* Installed families are enumerated by the host once per session; installing a
* font while the launcher runs shows up after a restart.
*/
export function getSystemFontFamilies(): Promise<SystemFontFamily[]> {
cached ??= invoke<SystemFontFamily[]>('plugin:fonts|fonts_get_system_fonts').catch((error) => {
// A failed scan must not poison the session for a later retry.
cached = null
throw error
})
return cached
}
@@ -419,6 +419,57 @@
"app.appearance-settings.auto-hide-downloads-button.description": {
"message": "Hide the downloads button in the sidebar when there are no active download tasks."
},
"app.appearance-settings.fonts.title": {
"message": "Fonts"
},
"app.appearance-settings.fonts.description": {
"message": "Choose the fonts the launcher interface and its logs are rendered in."
},
"app.appearance-settings.fonts.ui-font": {
"message": "Interface font"
},
"app.appearance-settings.fonts.ui-font-description": {
"message": "Used across the launcher interface. Only fonts installed on this system are listed."
},
"app.appearance-settings.fonts.mono-font": {
"message": "Monospace font"
},
"app.appearance-settings.fonts.mono-font-description": {
"message": "Used for logs, the server console, and other monospace content."
},
"app.appearance-settings.fonts.default": {
"message": "Launcher default"
},
"app.appearance-settings.fonts.monospace": {
"message": "Monospace"
},
"app.appearance-settings.fonts.missing": {
"message": "Not installed"
},
"app.appearance-settings.fonts.search-placeholder": {
"message": "Search installed fonts"
},
"app.appearance-settings.fonts.no-results": {
"message": "No matching fonts"
},
"app.appearance-settings.fonts.load-failed": {
"message": "Couldn't read the list of installed fonts."
},
"app.appearance-settings.fonts.retry": {
"message": "Retry"
},
"app.appearance-settings.fonts.reset": {
"message": "Reset"
},
"app.appearance-settings.fonts.preview": {
"message": "Preview"
},
"app.appearance-settings.fonts.ui-preview-sample": {
"message": "The quick brown fox jumps over the lazy dog."
},
"app.appearance-settings.fonts.mono-preview-sample": {
"message": "const x = () => 0; // monospace"
},
"app.appearance-settings.auto-hide-downloads-button.title": {
"message": "Auto-hide downloads button"
},
@@ -9949,5 +10000,170 @@
},
"app.navigation.screenshots": {
"message": "Screenshots"
},
"instance.backups.added-size": {
"message": "{size} newly stored"
},
"instance.backups.all-included": {
"message": "All instance files and folders are included."
},
"instance.backups.backup-now": {
"message": "Back up now"
},
"instance.backups.cancel": {
"message": "Cancel backup"
},
"instance.backups.choose": {
"message": "Everything is backed up by default. Add files or folders that should be excluded."
},
"instance.backups.delete": {
"message": "Delete backup"
},
"instance.backups.delete-body": {
"message": "This snapshot will be permanently deleted. Unused stored files will also be removed."
},
"instance.backups.delete-title": {
"message": "Delete this backup?"
},
"instance.backups.description": {
"message": "Create deduplicated snapshots of the whole instance while it is closed."
},
"instance.backups.disable": {
"message": "Disable backups"
},
"instance.backups.disable-blocked": {
"message": "Delete every snapshot before disabling backups."
},
"instance.backups.edit-exclusions": {
"message": "Edit exclusions"
},
"instance.backups.empty": {
"message": "No backups have been created yet."
},
"instance.backups.enable": {
"message": "Enable backups"
},
"instance.backups.excluded-paths": {
"message": "Excluded: {paths}"
},
"instance.backups.exclusions.add-file": {
"message": "Add excluded file"
},
"instance.backups.exclusions.add-folder": {
"message": "Add excluded folder"
},
"instance.backups.exclusions.empty": {
"message": "No files or folders are excluded."
},
"instance.backups.exclusions.remove": {
"message": "Remove {path} from exclusions"
},
"instance.backups.loading": {
"message": "Loading backup settings..."
},
"instance.backups.progress-bytes": {
"message": "{processed} of {total}"
},
"instance.backups.restore": {
"message": "Restore backup"
},
"instance.backups.restore-body": {
"message": "Upcoming operations: add {added, plural, one {# file} other {# files}}, modify {modified, plural, one {# file} other {# files}}, and delete {deleted, plural, one {# file} other {# files}}. Continue?"
},
"instance.backups.restore-title": {
"message": "Restore this backup?"
},
"instance.backups.save-exclusions": {
"message": "Save exclusions"
},
"instance.backups.snapshot-meta": {
"message": "{files, plural, one {# file} other {# files}} · {size}"
},
"instance.backups.snapshots": {
"message": "Snapshots"
},
"instance.backups.stage.applying": {
"message": "Applying changed files"
},
"instance.backups.stage.failed": {
"message": "Backup failed"
},
"instance.backups.stage.hashing": {
"message": "Hashing files"
},
"instance.backups.stage.interrupted": {
"message": "Backup operation was interrupted"
},
"instance.backups.stage.materializing": {
"message": "Preparing changed files"
},
"instance.backups.stage.preview": {
"message": "Analyzing restore changes"
},
"instance.backups.stage.queued": {
"message": "Waiting to start"
},
"instance.backups.stage.saving": {
"message": "Saving snapshot index"
},
"instance.backups.stage.scanning": {
"message": "Scanning files"
},
"instance.backups.stage.validating": {
"message": "Validating restore"
},
"instance.backups.title": {
"message": "Instance backups"
},
"instance.backups.unavailable": {
"message": "Backups are unavailable"
},
"instance.backups.unavailable.direct-linked": {
"message": "Instances linked directly to another launcher cannot be backed up."
},
"instance.backups.unavailable.external-directory": {
"message": "Instances that use an external game directory cannot be backed up."
},
"instance.backups.unavailable.not-installed": {
"message": "Finish installing this instance before enabling backups."
},
"instance.backups.unavailable.root-link": {
"message": "Instances whose root folder is a link cannot be backed up."
},
"instance.backups.unavailable.root-missing": {
"message": "The instance folder is missing."
},
"instance.backups.unavailable.symlink": {
"message": "Imported symbolic-link instances cannot be backed up."
},
"settings.backups.repository.change": {
"message": "Change folder"
},
"settings.backups.repository.description": {
"message": "Deduplicated instance backup data and its independent index database."
},
"settings.backups.repository.location": {
"message": "Location"
},
"settings.backups.repository.location-description": {
"message": "Changing the location moves the complete repository. Choose a new or empty folder."
},
"settings.backups.repository.moving": {
"message": "Moving repository..."
},
"settings.backups.repository.moving-progress": {
"message": "Moving repository: {processed} of {total}"
},
"settings.backups.repository.title": {
"message": "Backup repository"
},
"settings.backups.repository.unavailable": {
"message": "Repository unavailable"
},
"settings.backups.repository.usage": {
"message": "Storage usage"
},
"settings.backups.repository.usage-description": {
"message": "{stored} stored for {logical} of backup data across {count, plural, one {# snapshot} other {# snapshots}}."
}
}
@@ -640,6 +640,57 @@
"app.appearance-settings.auto-hide-downloads-button.description": {
"message": "没有进行中的下载任务时隐藏侧边栏中的下载按钮,有任务时自动显示。"
},
"app.appearance-settings.fonts.title": {
"message": "字体"
},
"app.appearance-settings.fonts.description": {
"message": "选择启动器界面与日志使用的字体。"
},
"app.appearance-settings.fonts.ui-font": {
"message": "界面字体"
},
"app.appearance-settings.fonts.ui-font-description": {
"message": "应用于启动器界面。仅列出系统已安装的字体。"
},
"app.appearance-settings.fonts.mono-font": {
"message": "等宽字体"
},
"app.appearance-settings.fonts.mono-font-description": {
"message": "用于日志、服务器控制台等等宽内容。"
},
"app.appearance-settings.fonts.default": {
"message": "启动器默认"
},
"app.appearance-settings.fonts.monospace": {
"message": "等宽"
},
"app.appearance-settings.fonts.missing": {
"message": "未安装"
},
"app.appearance-settings.fonts.search-placeholder": {
"message": "搜索已安装的字体"
},
"app.appearance-settings.fonts.no-results": {
"message": "没有匹配的字体"
},
"app.appearance-settings.fonts.load-failed": {
"message": "无法读取已安装的字体列表。"
},
"app.appearance-settings.fonts.retry": {
"message": "重试"
},
"app.appearance-settings.fonts.reset": {
"message": "重置"
},
"app.appearance-settings.fonts.preview": {
"message": "预览"
},
"app.appearance-settings.fonts.ui-preview-sample": {
"message": "永和九年,岁在癸丑,暮春之初。"
},
"app.appearance-settings.fonts.mono-preview-sample": {
"message": "const x = () => 0; // 等宽"
},
"app.appearance-settings.auto-hide-downloads-button.title": {
"message": "自动隐藏下载按钮"
},
@@ -10713,5 +10764,170 @@
},
"app.navigation.screenshots": {
"message": "截图"
},
"instance.backups.added-size": {
"message": "新增存储 {size}"
},
"instance.backups.all-included": {
"message": "实例中的所有文件和文件夹都会纳入备份。"
},
"instance.backups.backup-now": {
"message": "立即备份"
},
"instance.backups.cancel": {
"message": "取消备份"
},
"instance.backups.choose": {
"message": "默认备份实例中的全部内容,请添加不需要备份的文件或文件夹。"
},
"instance.backups.delete": {
"message": "删除备份"
},
"instance.backups.delete-body": {
"message": "此快照将被永久删除,不再使用的仓库文件也会一并清理。"
},
"instance.backups.delete-title": {
"message": "删除此备份?"
},
"instance.backups.description": {
"message": "在实例关闭时,为整个实例创建去重快照。"
},
"instance.backups.disable": {
"message": "关闭备份"
},
"instance.backups.disable-blocked": {
"message": "必须先删除全部快照才能关闭备份。"
},
"instance.backups.edit-exclusions": {
"message": "编辑排除项"
},
"instance.backups.empty": {
"message": "尚未创建任何备份。"
},
"instance.backups.enable": {
"message": "启用备份"
},
"instance.backups.excluded-paths": {
"message": "不备份:{paths}"
},
"instance.backups.exclusions.add-file": {
"message": "添加忽略文件"
},
"instance.backups.exclusions.add-folder": {
"message": "添加忽略文件夹"
},
"instance.backups.exclusions.empty": {
"message": "没有忽略任何文件或文件夹。"
},
"instance.backups.exclusions.remove": {
"message": "从排除项中移除 {path}"
},
"instance.backups.loading": {
"message": "正在加载备份设置……"
},
"instance.backups.progress-bytes": {
"message": "已处理 {processed},共 {total}"
},
"instance.backups.restore": {
"message": "恢复备份"
},
"instance.backups.restore-body": {
"message": "即将进行的操作:新增 {added} 个文件,修改 {modified} 个文件,删除 {deleted} 个文件。是否继续?"
},
"instance.backups.restore-title": {
"message": "恢复此备份?"
},
"instance.backups.save-exclusions": {
"message": "保存排除项"
},
"instance.backups.snapshot-meta": {
"message": "{files, plural, other {# 个文件}} · {size}"
},
"instance.backups.snapshots": {
"message": "快照"
},
"instance.backups.stage.applying": {
"message": "正在应用变更文件"
},
"instance.backups.stage.failed": {
"message": "备份失败"
},
"instance.backups.stage.hashing": {
"message": "正在计算文件哈希"
},
"instance.backups.stage.interrupted": {
"message": "备份任务已中断"
},
"instance.backups.stage.materializing": {
"message": "正在准备变更文件"
},
"instance.backups.stage.preview": {
"message": "正在分析恢复变更"
},
"instance.backups.stage.queued": {
"message": "等待开始"
},
"instance.backups.stage.saving": {
"message": "正在保存快照索引"
},
"instance.backups.stage.scanning": {
"message": "正在扫描文件"
},
"instance.backups.stage.validating": {
"message": "正在验证恢复任务"
},
"instance.backups.title": {
"message": "实例备份"
},
"instance.backups.unavailable": {
"message": "备份不可用"
},
"instance.backups.unavailable.direct-linked": {
"message": "不支持备份直接关联到其他启动器的实例。"
},
"instance.backups.unavailable.external-directory": {
"message": "不支持备份使用外部游戏目录的实例。"
},
"instance.backups.unavailable.not-installed": {
"message": "请先完成实例安装,再启用备份。"
},
"instance.backups.unavailable.root-link": {
"message": "不支持备份根文件夹为链接的实例。"
},
"instance.backups.unavailable.root-missing": {
"message": "实例文件夹不存在。"
},
"instance.backups.unavailable.symlink": {
"message": "不支持备份以符号链接导入的实例。"
},
"settings.backups.repository.change": {
"message": "更改文件夹"
},
"settings.backups.repository.description": {
"message": "存放去重后的实例备份数据及其独立索引数据库。"
},
"settings.backups.repository.location": {
"message": "位置"
},
"settings.backups.repository.location-description": {
"message": "更改位置会移动完整仓库。请选择新的或空的文件夹。"
},
"settings.backups.repository.moving": {
"message": "正在移动仓库……"
},
"settings.backups.repository.moving-progress": {
"message": "正在移动备份仓库:已处理 {processed},共 {total}"
},
"settings.backups.repository.title": {
"message": "备份文件仓库"
},
"settings.backups.repository.unavailable": {
"message": "仓库不可用"
},
"settings.backups.repository.usage": {
"message": "存储占用"
},
"settings.backups.repository.usage-description": {
"message": "实际存储 {stored},对应 {logical} 的备份数据,共 {count, plural, other {# 个快照}}。"
}
}
@@ -572,6 +572,57 @@
"app.appearance-settings.auto-hide-downloads-button.description": {
"message": "沒有進行中的下載任務時隱藏側邊欄中的下載按鈕,有任務時自動顯示。"
},
"app.appearance-settings.fonts.title": {
"message": "字型"
},
"app.appearance-settings.fonts.description": {
"message": "選擇啟動器介面與日誌使用的字型。"
},
"app.appearance-settings.fonts.ui-font": {
"message": "介面字型"
},
"app.appearance-settings.fonts.ui-font-description": {
"message": "套用於啟動器介面。僅列出系統已安裝的字型。"
},
"app.appearance-settings.fonts.mono-font": {
"message": "等寬字型"
},
"app.appearance-settings.fonts.mono-font-description": {
"message": "用於日誌、伺服器主控台等等寬內容。"
},
"app.appearance-settings.fonts.default": {
"message": "啟動器預設"
},
"app.appearance-settings.fonts.monospace": {
"message": "等寬"
},
"app.appearance-settings.fonts.missing": {
"message": "未安裝"
},
"app.appearance-settings.fonts.search-placeholder": {
"message": "搜尋已安裝的字型"
},
"app.appearance-settings.fonts.no-results": {
"message": "沒有符合的字型"
},
"app.appearance-settings.fonts.load-failed": {
"message": "無法讀取已安裝的字型清單。"
},
"app.appearance-settings.fonts.retry": {
"message": "重試"
},
"app.appearance-settings.fonts.reset": {
"message": "重設"
},
"app.appearance-settings.fonts.preview": {
"message": "預覽"
},
"app.appearance-settings.fonts.ui-preview-sample": {
"message": "永和九年,歲在癸丑,暮春之初。"
},
"app.appearance-settings.fonts.mono-preview-sample": {
"message": "const x = () => 0; // 等寬"
},
"app.appearance-settings.auto-hide-downloads-button.title": {
"message": "自動隱藏下載按鈕"
},
@@ -10144,5 +10195,170 @@
},
"app.instance.dependencies.zoom-out": {
"message": "縮小"
},
"instance.backups.added-size": {
"message": "新增存储 {size}"
},
"instance.backups.all-included": {
"message": "实例中的所有文件和文件夹都会纳入备份。"
},
"instance.backups.backup-now": {
"message": "立即备份"
},
"instance.backups.cancel": {
"message": "取消备份"
},
"instance.backups.choose": {
"message": "默认备份实例中的全部内容,请添加不需要备份的文件或文件夹。"
},
"instance.backups.delete": {
"message": "删除备份"
},
"instance.backups.delete-body": {
"message": "此快照将被永久删除,不再使用的仓库文件也会一并清理。"
},
"instance.backups.delete-title": {
"message": "删除此备份?"
},
"instance.backups.description": {
"message": "在实例关闭时,为整个实例创建去重快照。"
},
"instance.backups.disable": {
"message": "关闭备份"
},
"instance.backups.disable-blocked": {
"message": "必须先删除全部快照才能关闭备份。"
},
"instance.backups.edit-exclusions": {
"message": "编辑排除项"
},
"instance.backups.empty": {
"message": "尚未创建任何备份。"
},
"instance.backups.enable": {
"message": "启用备份"
},
"instance.backups.excluded-paths": {
"message": "不备份:{paths}"
},
"instance.backups.exclusions.add-file": {
"message": "添加忽略文件"
},
"instance.backups.exclusions.add-folder": {
"message": "添加忽略文件夹"
},
"instance.backups.exclusions.empty": {
"message": "没有忽略任何文件或文件夹。"
},
"instance.backups.exclusions.remove": {
"message": "从排除项中移除 {path}"
},
"instance.backups.loading": {
"message": "正在加载备份设置……"
},
"instance.backups.progress-bytes": {
"message": "已处理 {processed},共 {total}"
},
"instance.backups.restore": {
"message": "恢复备份"
},
"instance.backups.restore-body": {
"message": "即将进行的操作:新增 {added} 个文件,修改 {modified} 个文件,删除 {deleted} 个文件。是否继续?"
},
"instance.backups.restore-title": {
"message": "恢复此备份?"
},
"instance.backups.save-exclusions": {
"message": "保存排除项"
},
"instance.backups.snapshot-meta": {
"message": "{files, plural, other {# 个文件}} · {size}"
},
"instance.backups.snapshots": {
"message": "快照"
},
"instance.backups.stage.applying": {
"message": "正在应用变更文件"
},
"instance.backups.stage.failed": {
"message": "备份失败"
},
"instance.backups.stage.hashing": {
"message": "正在计算文件哈希"
},
"instance.backups.stage.interrupted": {
"message": "备份任务已中断"
},
"instance.backups.stage.materializing": {
"message": "正在准备变更文件"
},
"instance.backups.stage.preview": {
"message": "正在分析恢复变更"
},
"instance.backups.stage.queued": {
"message": "等待开始"
},
"instance.backups.stage.saving": {
"message": "正在保存快照索引"
},
"instance.backups.stage.scanning": {
"message": "正在扫描文件"
},
"instance.backups.stage.validating": {
"message": "正在验证恢复任务"
},
"instance.backups.title": {
"message": "实例备份"
},
"instance.backups.unavailable": {
"message": "备份不可用"
},
"instance.backups.unavailable.direct-linked": {
"message": "不支持备份直接关联到其他启动器的实例。"
},
"instance.backups.unavailable.external-directory": {
"message": "不支持备份使用外部游戏目录的实例。"
},
"instance.backups.unavailable.not-installed": {
"message": "请先完成实例安装,再启用备份。"
},
"instance.backups.unavailable.root-link": {
"message": "不支持备份根文件夹为链接的实例。"
},
"instance.backups.unavailable.root-missing": {
"message": "实例文件夹不存在。"
},
"instance.backups.unavailable.symlink": {
"message": "不支持备份以符号链接导入的实例。"
},
"settings.backups.repository.change": {
"message": "更改文件夹"
},
"settings.backups.repository.description": {
"message": "存放去重后的实例备份数据及其独立索引数据库。"
},
"settings.backups.repository.location": {
"message": "位置"
},
"settings.backups.repository.location-description": {
"message": "更改位置会移动完整仓库。请选择新的或空的文件夹。"
},
"settings.backups.repository.moving": {
"message": "正在移动仓库……"
},
"settings.backups.repository.moving-progress": {
"message": "正在移动备份仓库:已处理 {processed},共 {total}"
},
"settings.backups.repository.title": {
"message": "备份文件仓库"
},
"settings.backups.repository.unavailable": {
"message": "仓库不可用"
},
"settings.backups.repository.usage": {
"message": "存储占用"
},
"settings.backups.repository.usage-description": {
"message": "实际存储 {stored},对应 {logical} 的备份数据,共 {count, plural, other {# 个快照}}。"
}
}
+38
View File
@@ -1,5 +1,11 @@
import { defineStore } from 'pinia'
import {
DEFAULT_MONO_FONT_STACK,
DEFAULT_UI_FONT_STACK,
resolveFontFamily,
} from '@/helpers/font-family.ts'
let systemThemeMq: MediaQueryList | null = null
export const DEFAULT_FEATURE_FLAGS = {
@@ -130,6 +136,9 @@ export type ThemeStore = {
devMode: boolean
featureFlags: FeatureFlags
uiFont: string | null
monoFont: string | null
}
export const DEFAULT_THEME_STORE: ThemeStore = {
@@ -154,6 +163,9 @@ export const DEFAULT_THEME_STORE: ThemeStore = {
devMode: false,
featureFlags: DEFAULT_FEATURE_FLAGS,
uiFont: null,
monoFont: null,
}
export const useTheming = defineStore('themeStore', {
@@ -249,6 +261,32 @@ export const useTheming = defineStore('themeStore', {
`${Math.min(Math.max(this.transparentBackgroundOpacity, 0), 100)}%`,
)
},
/**
* The font tokens are declared on `body` in the shared defaults, so the
* launcher picks up the chosen family by overriding the CSS variable there.
*/
setUiFont() {
const body = document.body
if (this.uiFont) {
body.style.setProperty(
'--font-standard',
resolveFontFamily(this.uiFont, DEFAULT_UI_FONT_STACK),
)
} else {
body.style.removeProperty('--font-standard')
}
},
setMonoFont() {
const body = document.body
if (this.monoFont) {
body.style.setProperty(
'--mono-font',
resolveFontFamily(this.monoFont, DEFAULT_MONO_FONT_STACK),
)
} else {
body.style.removeProperty('--mono-font')
}
},
getFeatureFlag(key: FeatureFlag) {
return this.featureFlags[key] ?? DEFAULT_FEATURE_FLAGS[key]
},
+2
View File
@@ -16,6 +16,7 @@ dashmap = { workspace = true }
either = { workspace = true }
enumset = { workspace = true, features = ["serde"] }
flate2 = { workspace = true }
fontdb = { workspace = true }
futures = { workspace = true }
hyper = { workspace = true, features = ["server"] }
hyper-util = { workspace = true }
@@ -28,6 +29,7 @@ paste = { workspace = true }
path-util = { workspace = true }
quartz_nbt = { workspace = true, features = ["serde"] }
regex = { workspace = true }
semver = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
serde_with = { workspace = true }
+23
View File
@@ -428,6 +428,21 @@ fn main() {
InlinedPlugin::new()
.commands(&[
"instance_remove",
"instance_get_backup_repository_status",
"instance_move_backup_repository",
"instance_get_backup_config",
"instance_normalize_backup_exclusion",
"instance_enable_backups",
"instance_update_backup_exclusions",
"instance_disable_backups",
"instance_start_backup",
"instance_cancel_backup",
"instance_list_backup_operations",
"instance_list_backups",
"instance_delete_backup",
"instance_get_backup_restore_preview",
"instance_restore_backup",
"instance_get_backup_delete_summary",
"instance_create_direct_link",
"instance_sync_direct_links",
"instance_get",
@@ -697,6 +712,14 @@ fn main() {
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin(
"fonts",
InlinedPlugin::new()
.commands(&["fonts_get_system_fonts"])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
),
)
.plugin(
"drop",
InlinedPlugin::new()
+1 -1
View File
@@ -9,7 +9,7 @@ use std::path::PathBuf;
use std::sync::Mutex;
use tauri::{Emitter, Runtime};
use tauri_plugin_dialog::DialogExt;
use theseus::instance::{get_full_path, list};
use theseus::instance::get_full_path;
const STUDIO_FILES_CHANGED_EVENT: &str = "studio-files-changed";
+91
View File
@@ -0,0 +1,91 @@
use serde::Serialize;
use std::collections::HashMap;
use std::sync::OnceLock;
use crate::api::Result;
#[derive(Debug, Clone, Serialize)]
pub struct SystemFontFamily {
pub family: String,
pub monospaced: bool,
}
/// Scanning every installed face costs a few hundred milliseconds, and the
/// collection only changes between sessions, so it is read once per process.
static SYSTEM_FONTS: OnceLock<Vec<SystemFontFamily>> = OnceLock::new();
type FontFamilies = [(String, fontdb::Language)];
/// fontdb lists the English US family first when the font has one, but the
/// lookup is explicit so a face with an unusual name table still resolves to
/// the name the webview matches against.
fn english_family_name(families: &FontFamilies) -> Option<&str> {
families
.iter()
.find(|(_, language)| {
*language == fontdb::Language::English_UnitedStates
})
.or_else(|| families.first())
.map(|(name, _)| name.trim())
.filter(|name| !name.is_empty())
}
#[derive(Default)]
struct FamilyAccumulator {
families: HashMap<String, bool>,
}
impl FamilyAccumulator {
fn add(&mut self, families: &FontFamilies, monospaced: bool) {
let Some(name) = english_family_name(families) else {
return;
};
self.families
.entry(name.to_owned())
.and_modify(|flag| *flag |= monospaced)
.or_insert(monospaced);
}
fn into_sorted_families(self) -> Vec<SystemFontFamily> {
let mut families: Vec<SystemFontFamily> = self
.families
.into_iter()
.map(|(family, monospaced)| SystemFontFamily { family, monospaced })
.collect();
families.sort_by_cached_key(|entry| entry.family.to_lowercase());
families
}
}
fn collect_system_fonts() -> Vec<SystemFontFamily> {
let mut database = fontdb::Database::new();
database.load_system_fonts();
let mut accumulator = FamilyAccumulator::default();
for face in database.faces() {
accumulator.add(&face.families, face.monospaced);
}
accumulator.into_sorted_families()
}
#[tauri::command]
pub async fn fonts_get_system_fonts() -> Result<Vec<SystemFontFamily>> {
if let Some(fonts) = SYSTEM_FONTS.get() {
return Ok(fonts.clone());
}
let fonts = tauri::async_runtime::spawn_blocking(collect_system_fonts)
.await
.map_err(std::io::Error::other)?;
// A concurrent caller may have won the race; the scan is deterministic.
Ok(SYSTEM_FONTS.get_or_init(|| fonts).clone())
}
pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("fonts")
.invoke_handler(tauri::generate_handler![fonts_get_system_fonts])
.build()
}
+134
View File
@@ -25,6 +25,21 @@ pub fn init<R: tauri::Runtime>() -> tauri::plugin::TauriPlugin<R> {
tauri::plugin::Builder::new("instance")
.invoke_handler(tauri::generate_handler![
instance_remove,
instance_get_backup_repository_status,
instance_move_backup_repository,
instance_get_backup_config,
instance_normalize_backup_exclusion,
instance_enable_backups,
instance_update_backup_exclusions,
instance_disable_backups,
instance_start_backup,
instance_cancel_backup,
instance_list_backup_operations,
instance_list_backups,
instance_delete_backup,
instance_get_backup_restore_preview,
instance_restore_backup,
instance_get_backup_delete_summary,
instance_create_direct_link,
instance_sync_direct_links,
instance_get,
@@ -1699,3 +1714,122 @@ pub async fn instance_open_screenshot<R: tauri::Runtime>(
.map_err(|e| std::io::Error::other(e.to_string()))?;
Ok(())
}
// ===== 实例备份命令(移植自 Axolotl) =====
#[tauri::command]
pub async fn instance_get_backup_repository_status()
-> Result<theseus::instance::BackupRepositoryStatus> {
Ok(theseus::instance::get_backup_repository_status().await?)
}
#[tauri::command]
pub async fn instance_move_backup_repository(
destination: PathBuf,
) -> Result<uuid::Uuid> {
Ok(theseus::instance::move_backup_repository(destination).await?)
}
#[tauri::command]
pub async fn instance_get_backup_config(
instance_id: &str,
) -> Result<theseus::instance::InstanceBackupConfig> {
Ok(theseus::instance::get_backup_config(instance_id).await?)
}
#[tauri::command]
pub async fn instance_normalize_backup_exclusion(
instance_id: &str,
selected_path: PathBuf,
kind: theseus::instance::BackupExclusionKind,
) -> Result<theseus::instance::BackupExclusion> {
Ok(theseus::instance::get_backup_exclusion_from_path(
instance_id,
selected_path,
kind,
)
.await?)
}
#[tauri::command]
pub async fn instance_enable_backups(
instance_id: &str,
excluded_paths: Vec<theseus::instance::BackupExclusion>,
) -> Result<theseus::instance::InstanceBackupConfig> {
Ok(theseus::instance::enable_backups(instance_id, excluded_paths).await?)
}
#[tauri::command]
pub async fn instance_update_backup_exclusions(
instance_id: &str,
excluded_paths: Vec<theseus::instance::BackupExclusion>,
) -> Result<theseus::instance::InstanceBackupConfig> {
Ok(
theseus::instance::update_backup_exclusions(
instance_id,
excluded_paths,
)
.await?,
)
}
#[tauri::command]
pub async fn instance_disable_backups(instance_id: &str) -> Result<()> {
Ok(theseus::instance::disable_backups(instance_id).await?)
}
#[tauri::command]
pub async fn instance_start_backup(instance_id: String) -> Result<uuid::Uuid> {
Ok(theseus::instance::start_backup_snapshot(instance_id).await?)
}
#[tauri::command]
pub async fn instance_cancel_backup(operation_id: uuid::Uuid) -> Result<bool> {
Ok(theseus::instance::cancel_backup(operation_id).await?)
}
#[tauri::command]
pub async fn instance_list_backup_operations(
instance_id: Option<String>,
active_only: bool,
) -> Result<Vec<theseus::instance::BackupOperation>> {
Ok(theseus::instance::list_backup_operations(
instance_id.as_deref(),
active_only,
)
.await?)
}
#[tauri::command]
pub async fn instance_list_backups(
instance_id: &str,
) -> Result<Vec<theseus::instance::BackupSnapshot>> {
Ok(theseus::instance::list_backup_snapshots(instance_id).await?)
}
#[tauri::command]
pub async fn instance_delete_backup(snapshot_id: &str) -> Result<()> {
Ok(theseus::instance::delete_backup_snapshot(snapshot_id).await?)
}
#[tauri::command]
pub async fn instance_get_backup_restore_preview(
snapshot_id: &str,
) -> Result<uuid::Uuid> {
Ok(theseus::instance::get_backup_restore_preview(snapshot_id).await?)
}
#[tauri::command]
pub async fn instance_restore_backup(
snapshot_id: &str,
plan_token: &str,
) -> Result<uuid::Uuid> {
Ok(theseus::instance::restore_snapshot(snapshot_id, plan_token).await?)
}
#[tauri::command]
pub async fn instance_get_backup_delete_summary(
instance_id: &str,
) -> Result<theseus::instance::BackupDeleteSummary> {
Ok(theseus::instance::get_backup_delete_summary(instance_id).await?)
}
+1
View File
@@ -36,6 +36,7 @@ pub mod curseforge;
pub mod datapacks;
pub mod drop;
pub mod files;
pub mod fonts;
pub mod friends;
pub mod worlds;
+1
View File
@@ -947,6 +947,7 @@ fn main() {
.plugin(api::datapacks::init())
.plugin(api::drop::init())
.plugin(api::files::init())
.plugin(api::fonts::init())
.plugin(api::friends::init())
.plugin(api::worlds::init())
.plugin(api::terracotta::init())
+11 -9
View File
@@ -1,5 +1,6 @@
use crate::api::Result;
use futures::StreamExt;
use semver::Version;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::sync::{Arc, Mutex};
@@ -8,7 +9,7 @@ use tauri::http::header::ACCEPT;
use tauri::{Manager, ResourceId, Runtime, Webview};
use tauri_plugin_http::reqwest;
use tauri_plugin_http::reqwest::ClientBuilder;
use tauri_plugin_updater::{Error, Update, UpdaterExt, Version};
use tauri_plugin_updater::{Error, Update, UpdaterExt};
use theseus::{
LoadingBarType, emit_loading, init_loading, launcher_user_agent,
};
@@ -34,8 +35,11 @@ const UPDATE_DOWNLOAD_TIMEOUT: std::time::Duration =
fn is_remote_newer(current: &Version, remote: &Version) -> bool {
use std::cmp::Ordering;
let core = (remote.major, remote.minor, remote.patch)
.cmp(&(current.major, current.minor, current.patch));
let core = (remote.major, remote.minor, remote.patch).cmp(&(
current.major,
current.minor,
current.patch,
));
if core != Ordering::Equal {
return core == Ordering::Greater;
}
@@ -79,12 +83,10 @@ fn compare_prerelease(left: &str, right: &str) -> std::cmp::Ordering {
let (r_prefix, r_num) = split_prerelease_identifier(r);
match (l_num, r_num) {
(Some(ln), Some(rn)) if l_prefix == r_prefix => {
match ln.cmp(&rn) {
Ordering::Equal => continue,
other => return other,
}
}
(Some(ln), Some(rn)) if l_prefix == r_prefix => match ln.cmp(&rn) {
Ordering::Equal => continue,
other => return other,
},
_ => {
let prefix_cmp = l_prefix.cmp(r_prefix);
if prefix_cmp != Ordering::Equal {
@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n auto_concurrent_downloads, minecraft_metadata_source,\n minecraft_file_source, modrinth_source, curseforge_source, mojang_auth_source,\n theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n\t\t\t\tdiscord_rpc, developer_mode, telemetry, telemetry_consent_version, personalized_ads,\n onboarded, onboarding_version, onboarding_instance_tour_completed,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_memory_auto, mc_force_fullscreen, mc_maximize_window, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start, enter_lightweight_mode_on_game_launch,\n auto_set_java_high_performance_mode,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates, accent_color,\n custom_background_path, custom_background_blur, custom_background_opacity,\n transparent_background, transparent_background_opacity, transparent_background_blur,\n sidebar_instance_count, home_layout, minimal_home_instance_id,\n json(home_widgets) as \"home_widgets?: String\", auto_hide_downloads_button,\n json(terracotta_public_nodes) terracotta_public_nodes,\n ui_scale,\n version\n FROM settings\n ",
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n auto_concurrent_downloads, minecraft_metadata_source,\n minecraft_file_source, modrinth_source, curseforge_source, mojang_auth_source,\n theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n\t\t\t\tdiscord_rpc, developer_mode, telemetry, telemetry_consent_version, personalized_ads,\n onboarded, onboarding_version, onboarding_instance_tour_completed,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_memory_auto, mc_force_fullscreen, mc_maximize_window, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start, enter_lightweight_mode_on_game_launch,\n auto_set_java_high_performance_mode,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates, accent_color,\n custom_background_path, custom_background_blur, custom_background_opacity,\n transparent_background, transparent_background_opacity, transparent_background_blur,\n sidebar_instance_count, home_layout, minimal_home_instance_id,\n json(home_widgets) as \"home_widgets?: String\", auto_hide_downloads_button,\n json(terracotta_public_nodes) terracotta_public_nodes,\n ui_scale,\n ui_font,\n backup_repository_path,\n mono_font,\n version\n FROM settings\n ",
"describe": {
"columns": [
{
@@ -299,8 +299,23 @@
"type_info": "Float"
},
{
"name": "version",
"name": "ui_font",
"ordinal": 59,
"type_info": "Text"
},
{
"name": "backup_repository_path",
"ordinal": 60,
"type_info": "Text"
},
{
"name": "mono_font",
"ordinal": 61,
"type_info": "Text"
},
{
"name": "version",
"ordinal": 62,
"type_info": "Integer"
}
],
@@ -367,8 +382,11 @@
false,
null,
false,
null,
null,
null,
false
]
},
"hash": "a00177609aec1a056ec47d46d4acd6d1a6244434991d3624b70c7734a2ab85c3"
}
"hash": "52d2d61ed9239875d6188417f74bbde439c49582e1e81f5acb7b52d769a1bc18"
}
@@ -0,0 +1,76 @@
CREATE TABLE repository_meta (
id INTEGER PRIMARY KEY CHECK (id = 0),
schema_name TEXT NOT NULL,
created_at INTEGER NOT NULL
);
INSERT INTO repository_meta (id, schema_name, created_at)
VALUES (0, 'axolotl-instance-backups', unixepoch('subsec') * 1000);
CREATE TABLE instance_backup_configs (
instance_id TEXT PRIMARY KEY,
instance_name TEXT NOT NULL,
created_at INTEGER NOT NULL,
modified_at INTEGER NOT NULL
);
CREATE TABLE backup_selections (
instance_id TEXT NOT NULL REFERENCES instance_backup_configs(instance_id) ON DELETE CASCADE,
path TEXT NOT NULL,
PRIMARY KEY (instance_id, path)
);
CREATE TABLE backup_snapshots (
id TEXT PRIMARY KEY,
instance_id TEXT NOT NULL REFERENCES instance_backup_configs(instance_id) ON DELETE CASCADE,
instance_name TEXT NOT NULL,
created_at INTEGER NOT NULL,
file_count INTEGER NOT NULL,
symlink_count INTEGER NOT NULL,
logical_size INTEGER NOT NULL,
added_size INTEGER NOT NULL
);
CREATE INDEX backup_snapshots_instance_created
ON backup_snapshots(instance_id, created_at DESC);
CREATE TABLE snapshot_roots (
snapshot_id TEXT NOT NULL REFERENCES backup_snapshots(id) ON DELETE CASCADE,
path TEXT NOT NULL,
existed INTEGER NOT NULL,
PRIMARY KEY (snapshot_id, path)
);
CREATE TABLE backup_objects (
hash TEXT PRIMARY KEY,
size INTEGER NOT NULL,
ref_count INTEGER NOT NULL CHECK (ref_count >= 0)
);
CREATE TABLE snapshot_entries (
snapshot_id TEXT NOT NULL REFERENCES backup_snapshots(id) ON DELETE CASCADE,
path TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('file', 'directory', 'symlink')),
object_hash TEXT REFERENCES backup_objects(hash),
size INTEGER NOT NULL DEFAULT 0,
modified_at INTEGER,
unix_mode INTEGER,
link_target TEXT,
link_is_directory INTEGER,
PRIMARY KEY (snapshot_id, path),
CHECK ((kind = 'file' AND object_hash IS NOT NULL) OR (kind != 'file' AND object_hash IS NULL)),
CHECK ((kind = 'symlink' AND link_target IS NOT NULL) OR (kind != 'symlink' AND link_target IS NULL))
);
CREATE INDEX snapshot_entries_object_hash
ON snapshot_entries(object_hash) WHERE object_hash IS NOT NULL;
CREATE TABLE object_gc_queue (
hash TEXT PRIMARY KEY,
queued_at INTEGER NOT NULL
);
CREATE TABLE pending_instance_deletions (
instance_id TEXT PRIMARY KEY,
marked_at INTEGER NOT NULL
);
@@ -0,0 +1,19 @@
ALTER TABLE backup_snapshots
ADD COLUMN scope_mode TEXT NOT NULL DEFAULT 'included_roots'
CHECK (scope_mode IN ('included_roots', 'excluded_paths'));
DROP TABLE backup_selections;
CREATE TABLE backup_exclusions (
instance_id TEXT NOT NULL REFERENCES instance_backup_configs(instance_id) ON DELETE CASCADE,
path TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('file', 'directory')),
PRIMARY KEY (instance_id, path)
);
CREATE TABLE snapshot_exclusions (
snapshot_id TEXT NOT NULL REFERENCES backup_snapshots(id) ON DELETE CASCADE,
path TEXT NOT NULL,
kind TEXT NOT NULL CHECK (kind IN ('file', 'directory')),
PRIMARY KEY (snapshot_id, path)
);
@@ -0,0 +1,26 @@
CREATE TABLE backup_operations (
id TEXT PRIMARY KEY,
operation_type TEXT NOT NULL
CHECK (operation_type IN ('create', 'restore')),
instance_id TEXT NOT NULL,
snapshot_id TEXT,
state TEXT NOT NULL
CHECK (state IN (
'queued', 'scanning', 'hashing', 'saving', 'validating',
'materializing', 'applying', 'completed', 'cancelled', 'failed', 'interrupted'
)),
processed_bytes INTEGER NOT NULL DEFAULT 0,
total_bytes INTEGER NOT NULL DEFAULT 0,
cancellable INTEGER NOT NULL DEFAULT 1,
error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
finished_at INTEGER
);
CREATE INDEX backup_operations_instance_created
ON backup_operations(instance_id, created_at DESC);
CREATE INDEX backup_operations_active
ON backup_operations(state, updated_at DESC)
WHERE state NOT IN ('completed', 'cancelled', 'failed', 'interrupted');
@@ -0,0 +1,2 @@
ALTER TABLE backup_operations
ADD COLUMN cancel_requested INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,45 @@
PRAGMA foreign_keys = OFF;
CREATE TABLE backup_operations_new (
id TEXT PRIMARY KEY,
operation_type TEXT NOT NULL
CHECK (operation_type IN ('create', 'restore', 'repository_move')),
instance_id TEXT NOT NULL,
snapshot_id TEXT,
state TEXT NOT NULL
CHECK (state IN (
'queued', 'scanning', 'hashing', 'saving', 'validating',
'materializing', 'applying', 'completed', 'cancelled', 'failed', 'interrupted'
)),
processed_bytes INTEGER NOT NULL DEFAULT 0,
total_bytes INTEGER NOT NULL DEFAULT 0,
cancellable INTEGER NOT NULL DEFAULT 1,
error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
finished_at INTEGER,
cancel_requested INTEGER NOT NULL DEFAULT 0
);
INSERT INTO backup_operations_new (
id, operation_type, instance_id, snapshot_id, state,
processed_bytes, total_bytes, cancellable, error,
created_at, updated_at, finished_at, cancel_requested
)
SELECT
id, operation_type, instance_id, snapshot_id, state,
processed_bytes, total_bytes, cancellable, error,
created_at, updated_at, finished_at, cancel_requested
FROM backup_operations;
DROP TABLE backup_operations;
ALTER TABLE backup_operations_new RENAME TO backup_operations;
CREATE INDEX backup_operations_instance_created
ON backup_operations(instance_id, created_at DESC);
CREATE INDEX backup_operations_active
ON backup_operations(state, updated_at DESC)
WHERE state NOT IN ('completed', 'cancelled', 'failed', 'interrupted');
PRAGMA foreign_keys = ON;
@@ -0,0 +1,49 @@
PRAGMA foreign_keys = OFF;
CREATE TABLE backup_operations_new (
id TEXT PRIMARY KEY,
operation_type TEXT NOT NULL
CHECK (operation_type IN ('create', 'restore', 'repository_move', 'restore_preview')),
instance_id TEXT NOT NULL,
snapshot_id TEXT,
state TEXT NOT NULL
CHECK (state IN (
'queued', 'scanning', 'hashing', 'saving', 'validating',
'materializing', 'applying', 'completed', 'cancelled', 'failed', 'interrupted'
)),
processed_bytes INTEGER NOT NULL DEFAULT 0,
total_bytes INTEGER NOT NULL DEFAULT 0,
cancellable INTEGER NOT NULL DEFAULT 1,
error TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
finished_at INTEGER,
cancel_requested INTEGER NOT NULL DEFAULT 0,
preview_added_files INTEGER,
preview_modified_files INTEGER,
preview_deleted_files INTEGER,
preview_plan_token TEXT
);
INSERT INTO backup_operations_new (
id, operation_type, instance_id, snapshot_id, state,
processed_bytes, total_bytes, cancellable, error,
created_at, updated_at, finished_at, cancel_requested
)
SELECT
id, operation_type, instance_id, snapshot_id, state,
processed_bytes, total_bytes, cancellable, error,
created_at, updated_at, finished_at, cancel_requested
FROM backup_operations;
DROP TABLE backup_operations;
ALTER TABLE backup_operations_new RENAME TO backup_operations;
CREATE INDEX backup_operations_instance_created
ON backup_operations(instance_id, created_at DESC);
CREATE INDEX backup_operations_active
ON backup_operations(state, updated_at DESC)
WHERE state NOT IN ('completed', 'cancelled', 'failed', 'interrupted');
PRAGMA foreign_keys = ON;
@@ -0,0 +1,2 @@
ALTER TABLE settings ADD COLUMN ui_font TEXT;
ALTER TABLE settings ADD COLUMN mono_font TEXT;
@@ -0,0 +1 @@
ALTER TABLE settings ADD COLUMN backup_repository_path TEXT;
@@ -0,0 +1,4 @@
CREATE TABLE pending_backup_repository_cleanups (
path TEXT PRIMARY KEY,
created_at INTEGER NOT NULL
);
File diff suppressed because it is too large. Load diff
@@ -103,6 +103,7 @@ pub async fn edit(
patch: EditInstance,
) -> crate::Result<InstanceMetadata> {
let state = State::get().await?;
super::ensure_backup_eligible_edit(instance_id, &patch).await?;
crate::state::edit_instance(instance_id, patch, &state.pool).await?;
let instance = crate::state::get_instance(instance_id, &state.pool)
+28
View File
@@ -1,5 +1,6 @@
//! Theseus instance management interface
mod backup;
mod content;
mod core_components;
mod export_mrpack;
@@ -16,6 +17,33 @@ mod screenshot_groups;
mod screenshots;
mod upgrade;
pub use self::backup::{
BackupDeleteSummary, BackupExclusion, BackupExclusionKind, BackupOperation,
BackupOperationState, BackupOperationType, BackupRepositoryStatus,
BackupRestorePreview, BackupSnapshot, InstanceBackupConfig,
InstanceBackupEligibility, cancel_backup,
delete_snapshot as delete_backup_snapshot, disable as disable_backups,
enable as enable_backups,
exclusion_from_absolute_path as get_backup_exclusion_from_path,
has_active_operations as has_active_backup_operations,
has_active_repository_move as has_active_backup_repository_move,
instance_config as get_backup_config,
instance_delete_summary as get_backup_delete_summary,
interrupt_active_operations as interrupt_active_backup_operations,
list_operations as list_backup_operations,
list_snapshots as list_backup_snapshots,
maintain_repository as maintain_backup_repository,
repository_status as get_backup_repository_status, restore_snapshot,
start_repository_move as move_backup_repository,
start_restore_preview as get_backup_restore_preview,
start_snapshot as start_backup_snapshot,
update_exclusions as update_backup_exclusions,
};
pub(crate) use self::backup::{
begin_instance_deletion, cancel_instance_deletion, delete_instance_backups,
ensure_backup_eligible_edit, has_active_instance_operation,
lock_instance_maintenance, move_default_repository_for_launcher_directory,
};
pub use self::content::{
apply_content_update_plan, get_content_items, get_content_items_by_paths,
get_content_snapshot, get_dependencies_as_content_items,
+9
View File
@@ -75,6 +75,15 @@ async fn run_with_extra_launch_args_inner(
extra_launch_args: Option<Vec<String>>,
gc_intent: Option<GcLaunchIntent>,
) -> crate::Result<(ProcessMetadata, Option<GcLaunchReport>)> {
if crate::api::instance::has_active_instance_operation(instance_id) {
return Err(crate::ErrorKind::InputError(
"Wait for the active backup operation to finish before launching this instance"
.to_string(),
)
.into());
}
let _maintenance_guard =
crate::api::instance::lock_instance_maintenance(instance_id).await;
let _hosted_guard =
crate::pack::hosted::prepare_launch(instance_id, offline_mode).await?;
let state = State::get().await?;
+18 -3
View File
@@ -1,8 +1,8 @@
use super::{FriendPayload, LoadingBarId};
use crate::event::{
CommandPayload, EventError, InstanceBulkUpdateProgressPayload,
InstancePayloadType, LoadingBar, LoadingBarType, ProcessPayloadType,
ServerPayloadType,
CommandPayload, EventError, InstanceBackupProgressPayload,
InstanceBulkUpdateProgressPayload, InstancePayloadType, LoadingBar,
LoadingBarType, ProcessPayloadType, ServerPayloadType,
};
#[cfg(feature = "tauri")]
use crate::event::{
@@ -358,6 +358,21 @@ pub async fn emit_instance_bulk_update_progress(
Ok(())
}
#[allow(unused_variables)]
pub async fn emit_instance_backup_progress(
payload: InstanceBackupProgressPayload,
) -> crate::Result<()> {
#[cfg(feature = "tauri")]
{
let event_state = crate::EventState::get()?;
event_state
.app
.emit("instance_backup_progress", payload)
.map_err(EventError::from)?;
}
Ok(())
}
// emit_command(CommandPayload::Something { something })
// ie: installing a pack, opening an .mrpack, etc
// Generally used for url deep links and file opens that we want to handle in the frontend
+51
View File
@@ -248,6 +248,57 @@ pub enum InstanceBulkUpdateProgressStage {
Finishing,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct InstanceBackupProgressPayload {
pub operation_id: Uuid,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance_id: Option<String>,
pub operation_type: InstanceBackupOperationType,
pub stage: InstanceBackupProgressStage,
pub processed_bytes: u64,
pub total_bytes: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub final_state: Option<InstanceBackupOperationFinalState>,
#[serde(skip_serializing_if = "Option::is_none")]
pub snapshot_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Serialize, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum InstanceBackupOperationType {
Create,
Restore,
Delete,
RepositoryMove,
RestorePreview,
}
#[derive(Serialize, Clone, Copy)]
#[serde(rename_all = "snake_case")]
pub enum InstanceBackupOperationFinalState {
Completed,
Cancelled,
Failed,
}
#[derive(Serialize, Clone)]
#[serde(rename_all = "snake_case")]
pub enum InstanceBackupProgressStage {
Scanning,
Hashing,
Saving,
Validating,
Copying,
Restoring,
Deleting,
Completed,
Cancelled,
Failed,
}
#[derive(Serialize, Deserialize, Clone)]
#[serde(tag = "event")]
pub enum CommandPayload {
+26
View File
@@ -363,6 +363,32 @@ impl DirectoryInfo {
return Err(crate::ErrorKind::DirectoryMoveError(format!("Cannot move directory to {}: directory is not writable", move_dir.display())).into());
}
// If the user hasn't chosen a custom backup repository, the
// default one lives next to the launcher data. Move it along
// with the rest of the directory and queue a cleanup of the
// old copy on next startup.
if settings.backup_repository_path.is_none() {
let old_repository =
prev_dir.join("Backups").join("instances");
let new_repository =
move_dir.join("Backups").join("instances");
if let Some(path) = crate::api::instance::move_default_repository_for_launcher_directory(
&old_repository,
&new_repository,
)
.await?
{
sqlx::query(
"INSERT INTO pending_backup_repository_cleanups (path, created_at)
VALUES (?, unixepoch('subsec') * 1000)
ON CONFLICT(path) DO UPDATE SET created_at = excluded.created_at",
)
.bind(path.to_string_lossy().to_string())
.execute(exec)
.await?;
}
}
const MOVE_DIRS: &[&str] = &[
CACHES_FOLDER_NAME,
INSTANCES_FOLDER_NAME,
@@ -9,7 +9,36 @@ pub(crate) async fn remove_instance(
state: &State,
) -> crate::Result<()> {
let _instance_lock = state.lock_instance_content(instance_id).await;
let _maintenance_guard =
crate::api::instance::lock_instance_maintenance(instance_id).await;
crate::api::instance::begin_instance_deletion(instance_id).await?;
let result = remove_instance_files_and_state(instance_id, state).await;
match result {
Ok(()) => {
crate::api::instance::delete_instance_backups(instance_id).await
}
Err(error) => {
if let Err(cancel_error) =
crate::api::instance::cancel_instance_deletion(instance_id)
.await
{
tracing::warn!(
instance_id,
%cancel_error,
"Failed to clear pending backup deletion marker"
);
}
Err(error)
}
}
}
async fn remove_instance_files_and_state(
instance_id: &str,
state: &State,
) -> crate::Result<()> {
let instance = instance_rows::get_instance_by_id(instance_id, &state.pool)
.await?
.ok_or_else(|| {
+1 -1
View File
@@ -623,7 +623,7 @@ impl State {
}
/// 在设置变更(如忽略 SSL 证书开关)后重建 HTTP 客户端。
pub(crate) async fn update_http_client_for_settings(
pub async fn update_http_client_for_settings(
&self,
settings: &Settings,
) -> crate::Result<()> {
+7
View File
@@ -533,6 +533,13 @@ impl ProcessManager {
.collect()
}
/// Whether any tracked process belongs to the given instance.
pub fn has_instance_process(&self, instance_id: &str) -> bool {
self.processes
.iter()
.any(|entry| entry.value().metadata.instance_id == instance_id)
}
pub fn try_wait(
&self,
id: Uuid,
+26
View File
@@ -145,6 +145,12 @@ pub struct Settings {
#[serde(default = "default_ui_scale")]
pub ui_scale: f64,
#[serde(default)]
pub ui_font: Option<String>,
#[serde(default)]
pub backup_repository_path: Option<String>,
#[serde(default)]
pub mono_font: Option<String>,
#[serde(default)]
pub close_behavior: String,
#[serde(default)]
pub auto_hide_downloads_button: bool,
@@ -289,6 +295,9 @@ impl Settings {
json(home_widgets) as \"home_widgets?: String\", auto_hide_downloads_button,
json(terracotta_public_nodes) terracotta_public_nodes,
ui_scale,
ui_font,
backup_repository_path,
mono_font,
version
FROM settings
"
@@ -362,6 +371,9 @@ impl Settings {
transparent_background_blur: res.transparent_background_blur == 1,
sidebar_instance_count: res.sidebar_instance_count as u32,
ui_scale: res.ui_scale,
ui_font: res.ui_font,
backup_repository_path: res.backup_repository_path,
mono_font: res.mono_font,
close_behavior,
auto_hide_downloads_button: res.auto_hide_downloads_button == 1,
home_layout: HomeLayout::from_string(&res.home_layout),
@@ -665,6 +677,20 @@ impl Settings {
.bind(self.ui_scale)
.execute(exec)
.await?;
sqlx::query("UPDATE settings SET ui_font = ? WHERE id = 0")
.bind(&self.ui_font)
.execute(exec)
.await?;
sqlx::query(
"UPDATE settings SET backup_repository_path = ? WHERE id = 0",
)
.bind(&self.backup_repository_path)
.execute(exec)
.await?;
sqlx::query("UPDATE settings SET mono_font = ? WHERE id = 0")
.bind(&self.mono_font)
.execute(exec)
.await?;
Ok(())
}