diff --git a/Cargo.lock b/Cargo.lock
index 045a26a5e6f..43dae65372f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -13762,6 +13762,7 @@ dependencies = [
"infer 0.22.0",
"itertools 0.15.0",
"json5",
+ "libc",
"minecraft-meta-manifest",
"modrinth-content-management",
"notify",
diff --git a/Cargo.toml b/Cargo.toml
index a6a0fe00e35..24154a871e9 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -138,6 +138,7 @@ lettre = { version = "0.11.23", default-features = false, features = [
"tokio1",
"tokio1-rustls",
] }
+libc = "0.2.191"
lingua = "=1.8.0"
linkify = "0.11.0"
lz4_flex = { version = "0.14.0", default-features = false, features = [
diff --git a/apps/app-frontend/src/components/ui/SplashScreen.vue b/apps/app-frontend/src/components/ui/SplashScreen.vue
index c63421f80f6..1d79183bff5 100644
--- a/apps/app-frontend/src/components/ui/SplashScreen.vue
+++ b/apps/app-frontend/src/components/ui/SplashScreen.vue
@@ -67,8 +67,8 @@
-
- {{ message }}
+
+ {{ message }}
@@ -78,8 +78,8 @@
diff --git a/apps/app-frontend/src/features/library/shared-instances/shared-instance-modals.vue b/apps/app-frontend/src/features/library/shared-instances/shared-instance-modals.vue
index 9962666b3e3..3e459d0d37a 100644
--- a/apps/app-frontend/src/features/library/shared-instances/shared-instance-modals.vue
+++ b/apps/app-frontend/src/features/library/shared-instances/shared-instance-modals.vue
@@ -57,6 +57,7 @@ async function install(
async () => {
if (loadingIds.value.has(sharedInstanceId)) return
setLoading(sharedInstanceId, true)
+ let jobId: string
try {
const job = await install_shared_instance(
sharedInstanceId,
@@ -67,12 +68,16 @@ async function install(
instance.icon_path ?? null,
instance.icon_path ? (instance.icon_config ?? null) : null,
)
+ jobId = job.job_id
await queryClient.invalidateQueries({ queryKey: ['instances'] })
options.onInstalled?.()
- await wait_for_install_job(appEvents, job.job_id).catch(() => {})
- } finally {
+ } catch (error) {
setLoading(sharedInstanceId, false)
+ throw error
}
+ void wait_for_install_job(appEvents, jobId)
+ .catch(() => {})
+ .finally(() => setLoading(sharedInstanceId, false))
},
manager
? { id: manager.id, username: manager.username, avatarUrl: manager.avatar_url ?? null }
diff --git a/apps/app-frontend/src/features/settings/system/resource-management-settings/app-directory/change-directory-modal.vue b/apps/app-frontend/src/features/settings/system/resource-management-settings/app-directory/change-directory-modal.vue
new file mode 100644
index 00000000000..2dbae5984e9
--- /dev/null
+++ b/apps/app-frontend/src/features/settings/system/resource-management-settings/app-directory/change-directory-modal.vue
@@ -0,0 +1,228 @@
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.currentLabel) }}
+
+
+
+
+
{{ formatMessage(messages.newLabel) }}
+
+
+
+ {{ formatMessage(messages.instancesRunning) }}
+
+
+ {{ formatMessage(messages.nested) }}
+
+
+ {{ formatMessage(messages.driveRoot) }}
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.moveAndRestart) }}
+
+
+
+
+
diff --git a/apps/app-frontend/src/features/settings/system/resource-management-settings/app-directory/index.vue b/apps/app-frontend/src/features/settings/system/resource-management-settings/app-directory/index.vue
new file mode 100644
index 00000000000..90f8096a493
--- /dev/null
+++ b/apps/app-frontend/src/features/settings/system/resource-management-settings/app-directory/index.vue
@@ -0,0 +1,198 @@
+
+
+
+
+
+
+ {{ formatMessage(messages.title) }}
+
+
{{ formatMessage(messages.description) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.changeDirectory) }}
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/features/settings/system/resource-management-settings/app-directory/paths.ts b/apps/app-frontend/src/features/settings/system/resource-management-settings/app-directory/paths.ts
new file mode 100644
index 00000000000..a6ac2be1684
--- /dev/null
+++ b/apps/app-frontend/src/features/settings/system/resource-management-settings/app-directory/paths.ts
@@ -0,0 +1,21 @@
+export function normalizePath(path: string, caseInsensitive: boolean): string {
+ const normalized = path.replace(/\\/g, '/').replace(/\/+$/, '')
+ return caseInsensitive ? normalized.toLowerCase() : normalized
+}
+
+export function isNestedPath(first: string, second: string): boolean {
+ return first.startsWith(`${second}/`) || second.startsWith(`${first}/`)
+}
+
+const DRIVE_ROOTS = [
+ /^$/,
+ /^[a-z]:$/i,
+ /^\/Volumes\/[^/]+$/,
+ /^\/mnt\/[^/]+$/,
+ /^(\/run)?\/media\/[^/]+\/[^/]+$/,
+]
+
+export function isDriveRoot(path: string): boolean {
+ const normalized = normalizePath(path, false)
+ return DRIVE_ROOTS.some((root) => root.test(normalized))
+}
diff --git a/apps/app-frontend/src/features/settings/system/resource-management-settings/app-directory/use-app-directory.ts b/apps/app-frontend/src/features/settings/system/resource-management-settings/app-directory/use-app-directory.ts
new file mode 100644
index 00000000000..0e46f1aa6a1
--- /dev/null
+++ b/apps/app-frontend/src/features/settings/system/resource-management-settings/app-directory/use-app-directory.ts
@@ -0,0 +1,81 @@
+import { injectNotificationManager } from '@modrinth/ui'
+import { useMutation, useQuery, useQueryClient } from '@tanstack/vue-query'
+import { computed, watch } from 'vue'
+
+import {
+ appSettingsKeys,
+ appSettingsQueryOptions,
+ cancel_directory_change,
+ get,
+ set,
+} from '@/helpers/settings'
+import { restartApp } from '@/helpers/utils.js'
+import { getDefaultAppDirectory } from '@/platform/app-lib/settings/commands'
+
+export function useAppDirectory() {
+ const { handleError } = injectNotificationManager()
+ const queryClient = useQueryClient()
+ const settingsQuery = useQuery(appSettingsQueryOptions())
+ const defaultDirQuery = useQuery({
+ queryKey: ['app-directory', 'default'],
+ networkMode: 'always',
+ queryFn: getDefaultAppDirectory,
+ staleTime: Infinity,
+ })
+
+ watch([settingsQuery.error, defaultDirQuery.error], ([settingsError, defaultDirError]) => {
+ const error = settingsError ?? defaultDirError
+ if (error) handleError(error)
+ })
+
+ const defaultDir = computed(() => defaultDirQuery.data.value ?? null)
+
+ const currentDir = computed(() => {
+ const settings = settingsQuery.data.value
+ return settings?.prev_custom_dir ?? settings?.custom_dir ?? null
+ })
+
+ const pendingDir = computed(() => {
+ const settings = settingsQuery.data.value
+ if (!settings?.prev_custom_dir || !settings.custom_dir) return null
+ return settings.custom_dir === settings.prev_custom_dir ? null : settings.custom_dir
+ })
+
+ const settingsMutationOptions = {
+ networkMode: 'always',
+ mutationKey: appSettingsKeys.update,
+ scope: { id: 'app-settings' },
+ onMutate: () => queryClient.cancelQueries({ queryKey: appSettingsKeys.all }),
+ onError: handleError,
+ onSettled: () => queryClient.invalidateQueries({ queryKey: appSettingsKeys.all }),
+ } as const
+
+ const changeMutation = useMutation({
+ ...settingsMutationOptions,
+ mutationFn: async ({ directory, restartNow }: { directory: string; restartNow: boolean }) => {
+ await set({ ...(await get()), custom_dir: directory })
+ if (restartNow) await restartApp()
+ },
+ })
+
+ const cancelMutation = useMutation({
+ ...settingsMutationOptions,
+ mutationFn: () => cancel_directory_change(),
+ })
+
+ const busy = computed(() => changeMutation.isPending.value || cancelMutation.isPending.value)
+
+ function changeDirectory(directory: string, restartNow: boolean) {
+ changeMutation.mutate({ directory, restartNow })
+ }
+
+ function cancelMove() {
+ cancelMutation.mutate()
+ }
+
+ function restartNow() {
+ restartApp().catch(handleError)
+ }
+
+ return { currentDir, defaultDir, pendingDir, busy, changeDirectory, cancelMove, restartNow }
+}
diff --git a/apps/app-frontend/src/features/settings/system/resource-management-settings/app-directory/use-copy-path.ts b/apps/app-frontend/src/features/settings/system/resource-management-settings/app-directory/use-copy-path.ts
new file mode 100644
index 00000000000..061a72e5d92
--- /dev/null
+++ b/apps/app-frontend/src/features/settings/system/resource-management-settings/app-directory/use-copy-path.ts
@@ -0,0 +1,38 @@
+import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
+
+const messages = defineMessages({
+ copiedTitle: {
+ id: 'app.settings.resource-management.app-directory.path-copied-title',
+ defaultMessage: 'Path copied',
+ },
+ copiedText: {
+ id: 'app.settings.resource-management.app-directory.path-copied-text',
+ defaultMessage: 'The app directory path has been copied to your clipboard.',
+ },
+ copyFailedTitle: {
+ id: 'app.settings.resource-management.app-directory.path-copy-failed-title',
+ defaultMessage: 'Failed to copy path',
+ },
+})
+
+export function useCopyPath() {
+ const { formatMessage } = useVIntl()
+ const { addNotification } = injectNotificationManager()
+
+ return async function copyPath(path: string) {
+ try {
+ await navigator.clipboard.writeText(path)
+ addNotification({
+ type: 'success',
+ title: formatMessage(messages.copiedTitle),
+ text: formatMessage(messages.copiedText),
+ })
+ } catch (error) {
+ addNotification({
+ type: 'error',
+ title: formatMessage(messages.copyFailedTitle),
+ text: error instanceof Error ? error.message : String(error),
+ })
+ }
+ }
+}
diff --git a/apps/app-frontend/src/features/settings/system/resource-management-settings/index.vue b/apps/app-frontend/src/features/settings/system/resource-management-settings/index.vue
index eb12af28eb1..ae1fefeed1a 100644
--- a/apps/app-frontend/src/features/settings/system/resource-management-settings/index.vue
+++ b/apps/app-frontend/src/features/settings/system/resource-management-settings/index.vue
@@ -1,34 +1,19 @@
-
-
- {{ formatMessage(messages.appDirectoryTitle) }}
-
-
-
-
-
-
-
-
-
- {{ formatMessage(messages.appDirectoryDescription) }}
-
-
+
diff --git a/apps/app-frontend/src/locales/ar-SA/index.json b/apps/app-frontend/src/locales/ar-SA/index.json
index b05f9f6c6a3..da2d483e19d 100644
--- a/apps/app-frontend/src/locales/ar-SA/index.json
+++ b/apps/app-frontend/src/locales/ar-SA/index.json
@@ -2762,15 +2762,9 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "النسخ الاحتياطية لقاعدة بيانات التطبيق"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "تصفح لاختيار مجلد التطبيق"
- },
"app.settings.resource-management.app-directory.description": {
"message": "المكان الذي يخزن فيه تطبيق Modrinth النسخ والملفات الأخرى. تسري التغييرات بعد إعادة تشغيل التطبيق."
},
- "app.settings.resource-management.app-directory.select": {
- "message": "حدد مجلد تطبيق جديد"
- },
"app.settings.resource-management.app-directory.title": {
"message": "مجلد التطبيق"
},
diff --git a/apps/app-frontend/src/locales/cs-CZ/index.json b/apps/app-frontend/src/locales/cs-CZ/index.json
index 91e5eefb2da..eba381b1bd3 100644
--- a/apps/app-frontend/src/locales/cs-CZ/index.json
+++ b/apps/app-frontend/src/locales/cs-CZ/index.json
@@ -2921,15 +2921,9 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "Zálohy databáze aplikace"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "Procházet pro adresář aplikace"
- },
"app.settings.resource-management.app-directory.description": {
"message": "Kde Modrinth App ukládá instance a další soubory. Změny se projeví po restartu aplikace."
},
- "app.settings.resource-management.app-directory.select": {
- "message": "Vyberte nový adresář pro aplikaci"
- },
"app.settings.resource-management.app-directory.title": {
"message": "Složka aplikace"
},
diff --git a/apps/app-frontend/src/locales/de-CH/index.json b/apps/app-frontend/src/locales/de-CH/index.json
index ba94be9bc8d..48eae73b608 100644
--- a/apps/app-frontend/src/locales/de-CH/index.json
+++ b/apps/app-frontend/src/locales/de-CH/index.json
@@ -2951,15 +2951,9 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "App-Daten Backups"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "Nach einem App-Verzeichnis suchen"
- },
"app.settings.resource-management.app-directory.description": {
"message": "Hier speichert die Modrinth App Instanzen und andere Dateien. Änderungen werden nach einem Neustart der App wirksam."
},
- "app.settings.resource-management.app-directory.select": {
- "message": "Neues App-Verzeichnis auswählen"
- },
"app.settings.resource-management.app-directory.title": {
"message": "App-Verzeichnis"
},
diff --git a/apps/app-frontend/src/locales/de-DE/index.json b/apps/app-frontend/src/locales/de-DE/index.json
index 5e6e7e90042..e9ef30f6701 100644
--- a/apps/app-frontend/src/locales/de-DE/index.json
+++ b/apps/app-frontend/src/locales/de-DE/index.json
@@ -2951,15 +2951,9 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "App-Daten Backups"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "Nach einem App-Verzeichnis suchen"
- },
"app.settings.resource-management.app-directory.description": {
"message": "Hier speichert die Modrinth App Instanzen und andere Dateien. Änderungen werden nach einem Neustart der App wirksam."
},
- "app.settings.resource-management.app-directory.select": {
- "message": "Neues App-Verzeichnis auswählen"
- },
"app.settings.resource-management.app-directory.title": {
"message": "App-Verzeichnis"
},
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
index 55356c89c81..ee5c83c6183 100644
--- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -2981,15 +2981,84 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "App database backups"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "Browse for an app directory"
+ "app.settings.resource-management.app-directory.cancel-move": {
+ "message": "Cancel move"
+ },
+ "app.settings.resource-management.app-directory.change": {
+ "message": "Change directory"
+ },
+ "app.settings.resource-management.app-directory.change-to-default": {
+ "message": "Change to default"
},
"app.settings.resource-management.app-directory.description": {
- "message": "Where Modrinth App stores instances and other files. Changes take effect after restarting the app."
+ "message": "Where Modrinth App stores instances and other files."
+ },
+ "app.settings.resource-management.app-directory.modal.admonition-body": {
+ "message": "You won't be able to use Modrinth App while we move your data. This may take a while."
+ },
+ "app.settings.resource-management.app-directory.modal.admonition-header": {
+ "message": "All your files will be moved"
+ },
+ "app.settings.resource-management.app-directory.modal.choose": {
+ "message": "Choose a folder"
+ },
+ "app.settings.resource-management.app-directory.modal.current": {
+ "message": "Current"
+ },
+ "app.settings.resource-management.app-directory.modal.drive-root": {
+ "message": "Choose a folder rather than the top of a drive, so your files don't mix with everything else on it."
+ },
+ "app.settings.resource-management.app-directory.modal.header": {
+ "message": "Change app directory"
+ },
+ "app.settings.resource-management.app-directory.modal.instances-running": {
+ "message": "Close all running instances before changing the app directory."
+ },
+ "app.settings.resource-management.app-directory.modal.more-options": {
+ "message": "More move options"
+ },
+ "app.settings.resource-management.app-directory.modal.move-and-restart": {
+ "message": "Move and restart"
+ },
+ "app.settings.resource-management.app-directory.modal.move-later": {
+ "message": "Move on next launch"
+ },
+ "app.settings.resource-management.app-directory.modal.nested": {
+ "message": "The new folder can't be inside your current app directory, or contain it."
+ },
+ "app.settings.resource-management.app-directory.modal.new": {
+ "message": "New"
},
- "app.settings.resource-management.app-directory.select": {
+ "app.settings.resource-management.app-directory.modal.picker-title": {
"message": "Select a new app directory"
},
+ "app.settings.resource-management.app-directory.more-change-options": {
+ "message": "More directory options"
+ },
+ "app.settings.resource-management.app-directory.move-now": {
+ "message": "Move and restart now"
+ },
+ "app.settings.resource-management.app-directory.open-folder": {
+ "message": "Open folder"
+ },
+ "app.settings.resource-management.app-directory.open-in-explorer": {
+ "message": "Open in File Explorer"
+ },
+ "app.settings.resource-management.app-directory.open-in-finder": {
+ "message": "Open in Finder"
+ },
+ "app.settings.resource-management.app-directory.path-copied-text": {
+ "message": "The app directory path has been copied to your clipboard."
+ },
+ "app.settings.resource-management.app-directory.path-copied-title": {
+ "message": "Path copied"
+ },
+ "app.settings.resource-management.app-directory.path-copy-failed-title": {
+ "message": "Failed to copy path"
+ },
+ "app.settings.resource-management.app-directory.pending": {
+ "message": "Your app directory will move to {path} the next time Modrinth App opens."
+ },
"app.settings.resource-management.app-directory.title": {
"message": "App directory"
},
@@ -3542,6 +3611,9 @@
"app.skins.toggle-ears-features-on": {
"message": "Toggle on"
},
+ "app.splash.moving-app-directory": {
+ "message": "Moving your app directory..."
+ },
"app.survey.body": {
"message": "Would you mind answering a few questions about your experience with Modrinth App?"
},
diff --git a/apps/app-frontend/src/locales/es-419/index.json b/apps/app-frontend/src/locales/es-419/index.json
index d9a0bbe2957..477a722e0a0 100644
--- a/apps/app-frontend/src/locales/es-419/index.json
+++ b/apps/app-frontend/src/locales/es-419/index.json
@@ -2951,15 +2951,9 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "Respaldos de la base de datos de la aplicación"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "Buscar un directorio para la aplicación"
- },
"app.settings.resource-management.app-directory.description": {
"message": "Aquí es donde la aplicación guardará las instancias y otros archivos. Los cambios entrarán en efecto al reiniciar la app."
},
- "app.settings.resource-management.app-directory.select": {
- "message": "Seleccionar un directorio nuevo para la app"
- },
"app.settings.resource-management.app-directory.title": {
"message": "Directorio de la aplicación"
},
diff --git a/apps/app-frontend/src/locales/es-ES/index.json b/apps/app-frontend/src/locales/es-ES/index.json
index c7467d77255..3336a89eea3 100644
--- a/apps/app-frontend/src/locales/es-ES/index.json
+++ b/apps/app-frontend/src/locales/es-ES/index.json
@@ -2951,15 +2951,9 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "Copias de seguridad de base de datos"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "Buscar un directorio de aplicaciones"
- },
"app.settings.resource-management.app-directory.description": {
"message": "Donde Modrinth App almacena instancias y otros archivos. Los cambios entran en vigor tras reiniciar la aplicación."
},
- "app.settings.resource-management.app-directory.select": {
- "message": "Seleccionar un nuevo directorio de aplicaciones"
- },
"app.settings.resource-management.app-directory.title": {
"message": "Directorio de aplicaciones"
},
diff --git a/apps/app-frontend/src/locales/fr-FR/index.json b/apps/app-frontend/src/locales/fr-FR/index.json
index 9049284ef6e..ec08602ffbe 100644
--- a/apps/app-frontend/src/locales/fr-FR/index.json
+++ b/apps/app-frontend/src/locales/fr-FR/index.json
@@ -2912,15 +2912,9 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "Sauvegardes de la base de données de l’application"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "Parcourir pour le répertoire de l'application"
- },
"app.settings.resource-management.app-directory.description": {
"message": "Où Modrinth App stocke les instances et les autres fichiers. Changer prend effet après avoir redémarré l'application."
},
- "app.settings.resource-management.app-directory.select": {
- "message": "Sélectionner un nouveau répertoire pour l'application"
- },
"app.settings.resource-management.app-directory.title": {
"message": "Répertoire de l'application"
},
diff --git a/apps/app-frontend/src/locales/hu-HU/index.json b/apps/app-frontend/src/locales/hu-HU/index.json
index 0fbc470eedf..7c3277ac969 100644
--- a/apps/app-frontend/src/locales/hu-HU/index.json
+++ b/apps/app-frontend/src/locales/hu-HU/index.json
@@ -2765,15 +2765,9 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "Alkalmazásadatbázis-biztonsági mentések"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "Alkalmazáskönyvtár tallózása"
- },
"app.settings.resource-management.app-directory.description": {
"message": "A Modrinth App ebben a mappában tárolja a példányokat és az egyéb fájlokat. A módosítások az alkalmazás újraindítása után lépnek érvénybe."
},
- "app.settings.resource-management.app-directory.select": {
- "message": "Új alkalmazáskönyvtár kiválasztása"
- },
"app.settings.resource-management.app-directory.title": {
"message": "Alkalmazáskönyvtár"
},
diff --git a/apps/app-frontend/src/locales/it-IT/index.json b/apps/app-frontend/src/locales/it-IT/index.json
index 1ce95fcc7a6..a86c8f5d4c9 100644
--- a/apps/app-frontend/src/locales/it-IT/index.json
+++ b/apps/app-frontend/src/locales/it-IT/index.json
@@ -2951,15 +2951,9 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "Backup del database dell'app"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "Esplora una cartella per l'app"
- },
"app.settings.resource-management.app-directory.description": {
"message": "Dove Modrinth App memorizza le istanze e altri file. Le modifiche avranno effetto dopo un riavvio."
},
- "app.settings.resource-management.app-directory.select": {
- "message": "Seleziona la nuova cartella dell'app"
- },
"app.settings.resource-management.app-directory.title": {
"message": "Cartella dell'app"
},
diff --git a/apps/app-frontend/src/locales/ja-JP/index.json b/apps/app-frontend/src/locales/ja-JP/index.json
index f96c588eb16..b6fb932ddb2 100644
--- a/apps/app-frontend/src/locales/ja-JP/index.json
+++ b/apps/app-frontend/src/locales/ja-JP/index.json
@@ -2501,15 +2501,9 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "アプリデータベースのバックアップ"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "アプリのディレクトリを参照"
- },
"app.settings.resource-management.app-directory.description": {
"message": "Modrinth Appがインスタンスやその他のファイルを保存する場所です。変更はアプリの再起動後に反映されます。"
},
- "app.settings.resource-management.app-directory.select": {
- "message": "新しいアプリディレクトリを選択"
- },
"app.settings.resource-management.app-directory.title": {
"message": "アプリのディレクトリ"
},
diff --git a/apps/app-frontend/src/locales/ko-KR/index.json b/apps/app-frontend/src/locales/ko-KR/index.json
index f64c18e6faa..a582bf5eeeb 100644
--- a/apps/app-frontend/src/locales/ko-KR/index.json
+++ b/apps/app-frontend/src/locales/ko-KR/index.json
@@ -2951,15 +2951,9 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "앱 데이터베이스 백업"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "앱 디렉터리 찾아보기"
- },
"app.settings.resource-management.app-directory.description": {
"message": "Modrinth App이 인스턴스 및 기타 파일을 저장하는 위치입니다. 변경 사항은 앱을 재시작한 후 적용됩니다."
},
- "app.settings.resource-management.app-directory.select": {
- "message": "새 앱 디렉터리 선택"
- },
"app.settings.resource-management.app-directory.title": {
"message": "앱 경로"
},
diff --git a/apps/app-frontend/src/locales/nl-NL/index.json b/apps/app-frontend/src/locales/nl-NL/index.json
index 9c2b3d7d912..e6f0694d0fd 100644
--- a/apps/app-frontend/src/locales/nl-NL/index.json
+++ b/apps/app-frontend/src/locales/nl-NL/index.json
@@ -2909,15 +2909,9 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "Back-ups van de app-database"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "Zoek naar een app-folder"
- },
"app.settings.resource-management.app-directory.description": {
"message": "Hier slaat de Modrinth-app instanties en andere bestanden op. Wijzigingen worden pas van kracht nadat de app opnieuw is opgestart."
},
- "app.settings.resource-management.app-directory.select": {
- "message": "Kies een nieuwe app-folder"
- },
"app.settings.resource-management.app-directory.title": {
"message": "App-folder"
},
diff --git a/apps/app-frontend/src/locales/pl-PL/index.json b/apps/app-frontend/src/locales/pl-PL/index.json
index a143cb745e3..eb9587414bc 100644
--- a/apps/app-frontend/src/locales/pl-PL/index.json
+++ b/apps/app-frontend/src/locales/pl-PL/index.json
@@ -2894,15 +2894,9 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "Kopie zapasowe bazy danych aplikacji"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "Przeglądaj lokalizację aplikacji"
- },
"app.settings.resource-management.app-directory.description": {
"message": "Gdzie aplikacja Modrinth przechowuje instancje i inne pliki. Wymaga ponownego uruchomienia aplikacji."
},
- "app.settings.resource-management.app-directory.select": {
- "message": "Wybierz nową lokalizację aplikacji"
- },
"app.settings.resource-management.app-directory.title": {
"message": "Katalog aplikacji"
},
diff --git a/apps/app-frontend/src/locales/pt-BR/index.json b/apps/app-frontend/src/locales/pt-BR/index.json
index db64e2ff708..c1d30e34aef 100644
--- a/apps/app-frontend/src/locales/pt-BR/index.json
+++ b/apps/app-frontend/src/locales/pt-BR/index.json
@@ -2951,15 +2951,9 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "Backups do banco de dados do aplicativo"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "Buscar por um diretório de aplicativo"
- },
"app.settings.resource-management.app-directory.description": {
"message": "Onde o Modrinth App armazena as instâncias e outros arquivos. As mudanças serão aplicadas após reiniciar o aplicativo."
},
- "app.settings.resource-management.app-directory.select": {
- "message": "Selecione um novo diretório de aplicativo"
- },
"app.settings.resource-management.app-directory.title": {
"message": "Diretório de aplicativo"
},
diff --git a/apps/app-frontend/src/locales/pt-PT/index.json b/apps/app-frontend/src/locales/pt-PT/index.json
index fe30ad3d6e1..964979227c6 100644
--- a/apps/app-frontend/src/locales/pt-PT/index.json
+++ b/apps/app-frontend/src/locales/pt-PT/index.json
@@ -1190,9 +1190,6 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "Cópias de segurança da base de dados das aplicações"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "Procurar um diretório de aplicações"
- },
"app.settings.resource-management.app-directory.description": {
"message": "Local onde a aplicação Modrinth armazena instâncias e outros ficheiros. As alterações entram em vigor após o reinício da aplicação."
},
diff --git a/apps/app-frontend/src/locales/ru-RU/index.json b/apps/app-frontend/src/locales/ru-RU/index.json
index 2070b25d22b..8fb01a5efab 100644
--- a/apps/app-frontend/src/locales/ru-RU/index.json
+++ b/apps/app-frontend/src/locales/ru-RU/index.json
@@ -2876,15 +2876,9 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "Резервные копии базы данных"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "Выбрать каталог приложения"
- },
"app.settings.resource-management.app-directory.description": {
"message": "Здесь Modrinth App хранит сборки и другие файлы. Изменения вступят в силу после перезапуска."
},
- "app.settings.resource-management.app-directory.select": {
- "message": "Выбор нового каталога приложения"
- },
"app.settings.resource-management.app-directory.title": {
"message": "Каталог приложения"
},
diff --git a/apps/app-frontend/src/locales/sv-SE/index.json b/apps/app-frontend/src/locales/sv-SE/index.json
index f013650a36e..eda6e811e62 100644
--- a/apps/app-frontend/src/locales/sv-SE/index.json
+++ b/apps/app-frontend/src/locales/sv-SE/index.json
@@ -2678,15 +2678,9 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "Säkerhetskopior av appdatabas"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "Bläddra efter en appmapp"
- },
"app.settings.resource-management.app-directory.description": {
"message": "Var Modrinth App lagrar instanser och andra filer. Ändringar träder i kraft efter appen startas om."
},
- "app.settings.resource-management.app-directory.select": {
- "message": "Välj en ny appmapp"
- },
"app.settings.resource-management.app-directory.title": {
"message": "Appmapp"
},
diff --git a/apps/app-frontend/src/locales/tr-TR/index.json b/apps/app-frontend/src/locales/tr-TR/index.json
index e8bdede5e12..fe781d19c8c 100644
--- a/apps/app-frontend/src/locales/tr-TR/index.json
+++ b/apps/app-frontend/src/locales/tr-TR/index.json
@@ -2759,15 +2759,9 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "Uygulama veritabanı yedekleri"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "Bir uygulama dizinine göz atın"
- },
"app.settings.resource-management.app-directory.description": {
"message": "Modrinth App'in örnekleri ve diğer dosyaları depoladığı yer. Değişiklikler uygulamayı yeniden başlattıktan sonra etki eder."
},
- "app.settings.resource-management.app-directory.select": {
- "message": "Yeni bir uygulama dizini seçin"
- },
"app.settings.resource-management.app-directory.title": {
"message": "Uygulama dizini"
},
diff --git a/apps/app-frontend/src/locales/uk-UA/index.json b/apps/app-frontend/src/locales/uk-UA/index.json
index 9c2990cffb6..a6532cda3dc 100644
--- a/apps/app-frontend/src/locales/uk-UA/index.json
+++ b/apps/app-frontend/src/locales/uk-UA/index.json
@@ -2939,15 +2939,9 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "Резервні копії бази даних застосунку"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "Оглянути каталог застосунку"
- },
"app.settings.resource-management.app-directory.description": {
"message": "Де Modrinth App зберігає примірники й інші файли. Зміни набудуть чинності після перезапуску застосунку."
},
- "app.settings.resource-management.app-directory.select": {
- "message": "Вибрати новий каталог застосунку"
- },
"app.settings.resource-management.app-directory.title": {
"message": "Каталог застосунку"
},
diff --git a/apps/app-frontend/src/locales/vi-VN/index.json b/apps/app-frontend/src/locales/vi-VN/index.json
index 3b80b44f04f..20ced800966 100644
--- a/apps/app-frontend/src/locales/vi-VN/index.json
+++ b/apps/app-frontend/src/locales/vi-VN/index.json
@@ -2756,15 +2756,9 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "Bản sao lưu cơ sở dữ liệu ứng dụng"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "Chọn thư mục ứng dụng"
- },
"app.settings.resource-management.app-directory.description": {
"message": "Nơi Ứng dụng Modrinth lưu các bản cài đặt và tệp khác. Thay đổi có hiệu lực sau khi khởi động lại ứng dụng."
},
- "app.settings.resource-management.app-directory.select": {
- "message": "Chọn thư mục ứng dụng mới"
- },
"app.settings.resource-management.app-directory.title": {
"message": "Thư mục ứng dụng"
},
diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json
index f0ecd314d67..6c2d6c755da 100644
--- a/apps/app-frontend/src/locales/zh-CN/index.json
+++ b/apps/app-frontend/src/locales/zh-CN/index.json
@@ -2876,15 +2876,9 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "应用数据库备份"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "浏览应用目录"
- },
"app.settings.resource-management.app-directory.description": {
"message": "Modrinth App存储实例和其他文件的位置。更改会在重启程序后生效。"
},
- "app.settings.resource-management.app-directory.select": {
- "message": "选择新的应用目录"
- },
"app.settings.resource-management.app-directory.title": {
"message": "应用目录"
},
diff --git a/apps/app-frontend/src/locales/zh-TW/index.json b/apps/app-frontend/src/locales/zh-TW/index.json
index 5934cef81fe..061dbec2d7b 100644
--- a/apps/app-frontend/src/locales/zh-TW/index.json
+++ b/apps/app-frontend/src/locales/zh-TW/index.json
@@ -2915,15 +2915,9 @@
"app.settings.resource-management.app-database-backups.title": {
"message": "應用程式資料庫備份"
},
- "app.settings.resource-management.app-directory.browse": {
- "message": "瀏覽應用程式資料夾"
- },
"app.settings.resource-management.app-directory.description": {
"message": "Modrinth App 儲存實例及其他檔案的位置。變更將於重新啟動應用程式後生效。"
},
- "app.settings.resource-management.app-directory.select": {
- "message": "選擇新的應用程式資料夾"
- },
"app.settings.resource-management.app-directory.title": {
"message": "應用程式資料夾"
},
diff --git a/apps/app-frontend/src/platform/app-lib/settings/commands.ts b/apps/app-frontend/src/platform/app-lib/settings/commands.ts
new file mode 100644
index 00000000000..e1629e87406
--- /dev/null
+++ b/apps/app-frontend/src/platform/app-lib/settings/commands.ts
@@ -0,0 +1,5 @@
+import { invoke } from '@tauri-apps/api/core'
+
+export function getDefaultAppDirectory(): Promise {
+ return invoke('plugin:settings|default_app_directory')
+}
diff --git a/apps/app-frontend/src/platform/dialogs.ts b/apps/app-frontend/src/platform/dialogs.ts
index 0bc366a3a97..75804beb014 100644
--- a/apps/app-frontend/src/platform/dialogs.ts
+++ b/apps/app-frontend/src/platform/dialogs.ts
@@ -1,5 +1,10 @@
import { open } from '@tauri-apps/plugin-dialog'
+export async function pickDirectory(title: string): Promise {
+ const directory = await open({ multiple: false, directory: true, title })
+ return typeof directory === 'string' ? directory : null
+}
+
export function pickFile(): Promise {
return open({ multiple: false, directory: false })
}
diff --git a/apps/app/build.rs b/apps/app/build.rs
index 48825652443..5413d1ba5a1 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -338,6 +338,7 @@ fn main() {
"store_cleanup",
"store_set_cache_limit",
"cancel_directory_change",
+ "default_app_directory",
])
.default_permission(
DefaultPermissionRule::AllowAllCommands,
diff --git a/apps/app/src/api/settings.rs b/apps/app/src/api/settings.rs
index a048133e134..8068f682612 100644
--- a/apps/app/src/api/settings.rs
+++ b/apps/app/src/api/settings.rs
@@ -10,11 +10,17 @@ pub fn init() -> tauri::plugin::TauriPlugin {
store_usage,
store_cleanup,
store_set_cache_limit,
- cancel_directory_change
+ cancel_directory_change,
+ default_app_directory
])
.build()
}
+#[tauri::command]
+pub async fn default_app_directory() -> Result {
+ Ok(settings::default_app_directory().await?)
+}
+
#[tauri::command]
pub async fn store_usage() -> Result {
Ok(settings::store_usage().await?)
diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml
index 305f7854a0f..1d7a866fc10 100644
--- a/packages/app-lib/Cargo.toml
+++ b/packages/app-lib/Cargo.toml
@@ -128,6 +128,9 @@ zip = { workspace = true }
dotenvy = { workspace = true }
dunce = { workspace = true }
+[target.'cfg(unix)'.dependencies]
+libc = { workspace = true }
+
[target.'cfg(target_os = "macos")'.dependencies]
cidre = { workspace = true, features = ["blocks", "nw"] }
diff --git a/packages/app-lib/src/api/settings.rs b/packages/app-lib/src/api/settings.rs
index 6a11e52aac9..08d2321b9e9 100644
--- a/packages/app-lib/src/api/settings.rs
+++ b/packages/app-lib/src/api/settings.rs
@@ -24,6 +24,10 @@ pub async fn store_set_cache_limit(bytes: u64) -> crate::Result<()> {
.await
}
+pub async fn default_app_directory() -> crate::Result {
+ Ok(State::get().await?.directories.settings_dir.clone())
+}
+
/// Gets entire settings
#[tracing::instrument]
pub async fn get() -> crate::Result {
diff --git a/packages/app-lib/src/state/dirs.rs b/packages/app-lib/src/state/dirs.rs
index 8e348b5973e..99f5aaedcb9 100644
--- a/packages/app-lib/src/state/dirs.rs
+++ b/packages/app-lib/src/state/dirs.rs
@@ -1,9 +1,8 @@
//! Theseus directory information
pub(crate) mod move_app_directory;
-use crate::event::emit::{emit_loading, init_loading};
+use crate::ErrorKind;
use crate::state::{Settings, State};
use crate::util::fetch::IoSemaphore;
-use crate::{ErrorKind, LoadingBarType};
use anyhow::Context;
use std::path::PathBuf;
use tokio::fs;
@@ -281,22 +280,12 @@ impl DirectoryInfo {
if let Some(previous) = &previous
&& moving
{
- let loading = init_loading(
- LoadingBarType::DirectoryMove {
- old: previous.to_string_lossy().into_owned(),
- new: destination.to_string_lossy().into_owned(),
- },
- 100.0,
- "Moving launcher directory",
- )
- .await?;
move_app_directory::move_app_directory(
previous,
&destination,
pool,
)
.await?;
- emit_loading(&loading, 100.0, None)?;
}
if !moving {
move_app_directory::resume_completed_move(&destination, pool)
diff --git a/packages/app-lib/src/state/dirs/move_app_directory.rs b/packages/app-lib/src/state/dirs/move_app_directory.rs
index a4efb818486..b7abaa15a71 100644
--- a/packages/app-lib/src/state/dirs/move_app_directory.rs
+++ b/packages/app-lib/src/state/dirs/move_app_directory.rs
@@ -1,17 +1,82 @@
-use crate::ErrorKind;
+//! 1. The user picks a new directory. The move runs on the next startup, before
+//! the rest of the app state is created.
+//! 2. The old directory is walked. Folders and links are created at the
+//! destination, and every file is added to a list. Rebuildable caches are
+//! left out; `caches/icons` and `caches/game-locales` are kept because
+//! instances and saved settings point at them. Files already at the
+//! destination on the first attempt are recorded as protected.
+//! 3. Files are copied in parallel, without syncing each one to disk. A copy
+//! keeps its source's modification time, so an unprotected file already at
+//! the destination with the same time is skipped. A protected file is only
+//! accepted if its contents match.
+//! 4. Every copy is hashed and compared with its source. Mismatches are copied
+//! again, up to three attempts, after which the move fails. Protected files
+//! are never replaced.
+//! 5. Every new file and folder is synced to disk once.
+//! 6. Paths in the database are rewritten and the move is marked as copied.
+//! 7. The old directory is deleted.
+//!
+//! Until step 6, the old directory is untouched and remains the source of
+//! truth. If the move is interrupted before then, the next startup runs it
+//! again and step 4 catches any copy left damaged. Cancelling only resets
+//! settings.
+//!
+//! After step 6, the new directory is the source of truth. If the app stops
+//! during step 7, the next startup finishes deleting the old directory.
+
+use crate::event::emit::{emit_loading, init_loading};
use crate::state::content_store;
+use crate::{ErrorKind, LoadingBarType};
mod managed_content;
use crate::state::content_store::{
- hash_file, normalize, relative_link, sync_directory,
+ FileHashes, hash_file, normalize, relative_link, remove_instance_file,
+ sync_directory,
};
use crate::state::instances::adapters::sqlite::instance_rows;
use crate::state::{Instance, JavaVersion};
+use crate::util::content_hash::{copy_and_hash, hash_file_with_progress};
use anyhow::bail;
+use futures::stream::{self, StreamExt, TryStreamExt};
use serde_json::Value;
use sqlx::SqlitePool;
+use std::collections::HashSet;
use std::path::{Path, PathBuf};
+use std::sync::atomic::{AtomicU64, Ordering};
+use std::time::Instant;
use tokio::fs;
+use tokio::io::AsyncWriteExt;
+
+const PARALLEL_FILES: usize = 8;
+const COPY_ATTEMPTS: usize = 3;
+
+type ProgressFn<'a> = &'a (dyn Fn(u64) + Send + Sync);
+
+#[derive(Default)]
+struct MovePlan {
+ files: Vec<(PathBuf, PathBuf)>,
+ directories: Vec,
+ /// Targets that existed before the move wrote anything. They are never replaced.
+ protected: HashSet,
+ bytes: u64,
+ required: u64,
+}
+
+impl MovePlan {
+ async fn add_file(&mut self, source: PathBuf, target: PathBuf, len: u64) {
+ self.bytes = self.bytes.saturating_add(len);
+ if fs::symlink_metadata(&target).await.is_ok() {
+ self.protected.insert(target.clone());
+ } else {
+ self.required = self.required.saturating_add(len);
+ }
+ self.files.push((source, target));
+ }
+}
+
+const PROTECTED_TARGETS_KEY: &str = "store_directory_move_protected";
+const PRESERVED_CACHE_ENTRIES: [&str; 2] = ["icons", "game-locales"];
+const COPY_PROGRESS_LIMIT: f64 = 0.99;
const MOVED_APP_DIRECTORIES: [&str; 6] = [
"store",
@@ -44,24 +109,28 @@ pub(crate) async fn relocate_tree(from: &Path, to: &Path) -> crate::Result<()> {
}
return Ok(());
}
- copy_tree(
+ let mut plan = MovePlan::default();
+ plan_tree(
from,
to,
&[(from.to_path_buf(), to.to_path_buf())],
- &std::collections::HashSet::new(),
+ &HashSet::new(),
+ &mut plan,
)
- .await
+ .await?;
+ copy_planned(&plan, to, &|_| {}).await
}
-async fn copy_tree(
+async fn plan_tree(
from: &Path,
to: &Path,
mappings: &[(PathBuf, PathBuf)],
- managed_paths: &std::collections::HashSet,
+ skipped_paths: &HashSet,
+ plan: &mut MovePlan,
) -> crate::Result<()> {
let mut pending = vec![(from.to_path_buf(), to.to_path_buf())];
while let Some((source, target)) = pending.pop() {
- if managed_paths.contains(&source) {
+ if skipped_paths.contains(&source) {
continue;
}
let metadata = fs::symlink_metadata(&source).await?;
@@ -114,47 +183,229 @@ async fn copy_tree(
while let Some(entry) = entries.next_entry().await? {
pending.push((entry.path(), target.join(entry.file_name())));
}
+ plan.directories.push(target);
} else if metadata.is_file() {
- if let Ok(existing) = fs::symlink_metadata(&target).await {
- if !existing.is_file()
- || existing.file_type().is_symlink()
- || hash_file(&source).await? != hash_file(&target).await?
- {
- bail!(ErrorKind::InputError(format!(
- "migration would overwrite different data at {}",
- target.display()
- )));
- }
- continue;
- }
- let parent = target.parent().ok_or(ErrorKind::InputError(
- "invalid migration target".to_string(),
- ))?;
- fs::create_dir_all(parent).await?;
- let temporary = parent
- .join(format!(".modrinth-move-{}.tmp", uuid::Uuid::new_v4()));
- crate::state::content_store::writable_copy(&source, &temporary)
- .await?;
- fs::File::options()
- .write(true)
- .open(&temporary)
- .await?
- .sync_all()
- .await?;
- fs::set_permissions(&temporary, metadata.permissions()).await?;
- if hash_file(&source).await? != hash_file(&temporary).await? {
- bail!(ErrorKind::InputError(
- "file changed or failed verification during directory migration"
- .to_string(),
- ));
- }
- fs::rename(&temporary, &target).await?;
- sync_directory(parent).await?;
+ plan.add_file(source, target, metadata.len()).await;
+ }
+ }
+ Ok(())
+}
+
+async fn copy_planned(
+ plan: &MovePlan,
+ root: &Path,
+ on_progress: ProgressFn<'_>,
+) -> crate::Result<()> {
+ let mut pending = (0..plan.files.len()).collect::>();
+ for attempt in 0..COPY_ATTEMPTS {
+ let copied: Vec<_> = stream::iter(pending)
+ .map(|index| async move {
+ let (source, target) = &plan.files[index];
+ copy_file(
+ source,
+ target,
+ plan.protected.contains(target),
+ attempt > 0,
+ on_progress,
+ )
+ .await
+ .map(|hashes| (index, hashes))
+ })
+ .buffer_unordered(PARALLEL_FILES)
+ .try_collect()
+ .await?;
+ let verified: Vec<_> = stream::iter(copied)
+ .map(|(index, hashes)| async move {
+ let (source, target) = &plan.files[index];
+ verify_file(source, target, hashes, on_progress)
+ .await
+ .map(|matches| (index, matches))
+ })
+ .buffer_unordered(PARALLEL_FILES)
+ .try_collect()
+ .await?;
+ pending = verified
+ .into_iter()
+ .filter_map(|(index, matches)| (!matches).then_some(index))
+ .collect();
+ if pending.is_empty() {
+ break;
}
+ tracing::warn!(
+ files = pending.len(),
+ attempt,
+ "Files failed verification during app directory move"
+ );
+ }
+ if let Some((source, _)) = pending.first().map(|&index| &plan.files[index])
+ {
+ bail!(ErrorKind::InputError(format!(
+ "{} could not be copied after {COPY_ATTEMPTS} attempts",
+ source.display()
+ )));
}
+ let paths = plan
+ .files
+ .iter()
+ .map(|(_, target)| target)
+ .chain(&plan.directories)
+ .chain(std::iter::once(&root.to_path_buf()))
+ .cloned()
+ .collect::>();
+ stream::iter(paths)
+ .map(|path| async move {
+ Ok::<_, crate::Error>(
+ tokio::task::spawn_blocking(move || flush_path(&path))
+ .await??,
+ )
+ })
+ .buffer_unordered(PARALLEL_FILES)
+ .try_collect::>()
+ .await?;
+ #[cfg(target_os = "macos")]
+ fs::File::open(root).await?.sync_all().await?;
Ok(())
}
+/// `None` when the file was already copied. Only targets the move wrote may be
+/// trusted by modification time or replaced; `protected` targets must match.
+async fn copy_file(
+ source: &Path,
+ target: &Path,
+ protected: bool,
+ replace: bool,
+ on_progress: ProgressFn<'_>,
+) -> crate::Result