diff --git a/Cargo.lock b/Cargo.lock
index 5f4b5ef373..89f897a66e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -12558,6 +12558,7 @@ dependencies = [
"tauri-plugin-single-instance",
"tauri-plugin-updater",
"tauri-plugin-window-state",
+ "tempfile",
"theseus",
"thiserror 2.0.17",
"tokio",
diff --git a/apps/app-frontend/src/components/ui/download-manager/download-manager-job.vue b/apps/app-frontend/src/components/ui/download-manager/download-manager-job.vue
index ff2dbdaecb..963670ceee 100644
--- a/apps/app-frontend/src/components/ui/download-manager/download-manager-job.vue
+++ b/apps/app-frontend/src/components/ui/download-manager/download-manager-job.vue
@@ -65,7 +65,7 @@ const cancelLabel = computed(() =>
formatMessage(
needsAttention.value
? messages.dismiss
- : props.job.kind === 'debug-export'
+ : props.job.kind === 'external-file' || props.job.kind === 'debug-export'
? commonMessages.cancelButton
: messages.cancel,
),
@@ -105,7 +105,9 @@ const instanceLink = computed(() =>
class="flex size-9 shrink-0 items-center justify-center rounded-xl border border-solid border-surface-5 bg-purple/10"
>
diff --git a/apps/app-frontend/src/components/ui/download-manager/external-file-downloads.ts b/apps/app-frontend/src/components/ui/download-manager/external-file-downloads.ts
new file mode 100644
index 0000000000..e2b6de3510
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/download-manager/external-file-downloads.ts
@@ -0,0 +1,107 @@
+import { reactive, ref } from 'vue'
+
+export interface ExternalFileDownloadProgress {
+ stage: 'waiting' | 'downloading' | 'saving'
+ downloadedBytes: number
+ totalBytes: number | null
+}
+
+export interface ExternalFileDownloadTask {
+ id: string
+ filename: string
+ serverId?: string
+ serverName?: string
+ status: 'running' | 'succeeded' | 'failed' | 'canceled'
+ stage: 'preparing' | ExternalFileDownloadProgress['stage']
+ downloadedBytes: number
+ totalBytes: number | null
+ rate: number
+ lastRead: number
+ createdAt: string
+ finishedAt?: string
+ error?: string
+ canceling: boolean
+ cancel: () => Promise
+}
+
+export const externalFileDownloads = ref(new Map())
+
+export function trackExternalFileDownload(
+ id: string,
+ filename: string,
+ onCancel: () => Promise,
+ server: { serverId?: string; serverName?: string } = {},
+) {
+ const task = reactive({
+ id,
+ filename,
+ ...server,
+ status: 'running',
+ stage: 'preparing',
+ downloadedBytes: 0,
+ totalBytes: null,
+ rate: 0,
+ lastRead: performance.now(),
+ createdAt: new Date().toISOString(),
+ canceling: false,
+ async cancel() {
+ if (task.status !== 'running' || task.canceling) return
+ task.canceling = true
+ try {
+ await onCancel()
+ if (task.status === 'running') finish('canceled')
+ } finally {
+ task.canceling = false
+ }
+ },
+ })
+ externalFileDownloads.value.set(id, task)
+ let previousBytes = 0
+ let previousTime = performance.now()
+
+ function finish(status: ExternalFileDownloadTask['status']) {
+ task.status = status
+ task.finishedAt = new Date().toISOString()
+ task.rate = 0
+ }
+
+ return {
+ update(progress: ExternalFileDownloadProgress) {
+ if (task.status !== 'running' || task.canceling) return
+ const now = performance.now()
+ if (progress.stage !== 'downloading' || progress.downloadedBytes < previousBytes) {
+ task.rate = 0
+ } else if (progress.downloadedBytes > previousBytes) {
+ const rate =
+ ((progress.downloadedBytes - previousBytes) * 1000) / Math.max(1, now - previousTime)
+ task.rate = task.rate ? task.rate * 0.6 + rate * 0.4 : rate
+ task.lastRead = now
+ }
+ task.stage = progress.stage
+ task.downloadedBytes = progress.downloadedBytes
+ task.totalBytes = progress.totalBytes
+ previousBytes = progress.downloadedBytes
+ previousTime = now
+ },
+ succeed(filename: string) {
+ task.filename = filename
+ finish('succeeded')
+ },
+ fail(error: string) {
+ task.error = error
+ finish('failed')
+ },
+ canceled() {
+ finish('canceled')
+ },
+ }
+}
+
+export function getExternalFileDownloadRate(task: ExternalFileDownloadTask, now: number) {
+ return task.status === 'running' &&
+ task.stage === 'downloading' &&
+ !task.canceling &&
+ now - task.lastRead < 5000
+ ? task.rate
+ : 0
+}
diff --git a/apps/app-frontend/src/components/ui/download-manager/use-download-manager.ts b/apps/app-frontend/src/components/ui/download-manager/use-download-manager.ts
index aff44edb3a..0a7b950060 100644
--- a/apps/app-frontend/src/components/ui/download-manager/use-download-manager.ts
+++ b/apps/app-frontend/src/components/ui/download-manager/use-download-manager.ts
@@ -26,13 +26,19 @@ import { injectAppEvents } from '@/providers/app-events'
import { debugInfoExports, type DebugInfoExportTask } from './debug-info-export'
import { createDownloadTransferTracker } from './download-transfer'
+import {
+ externalFileDownloads,
+ type ExternalFileDownloadTask,
+ getExternalFileDownloadRate,
+} from './external-file-downloads'
import { createInstallJobProgressTracker } from './install-job-progress'
import { storeVerificationTask } from './store-verification'
import { useInstallJobDisplay } from './use-install-job-display'
export interface DownloadManagerJob {
id: string
- kind?: 'debug-export'
+ kind?: 'external-file' | 'debug-export'
+ serverId?: string
createdAt?: string
instanceId: string | null
status: InstallJobSnapshot['status']
@@ -69,6 +75,13 @@ const verificationMessages = defineMessages({
},
})
+const fileMessages = defineMessages({
+ preparing: { id: 'app.file-download.preparing', defaultMessage: 'Preparing download…' },
+ waiting: { id: 'app.file-download.waiting', defaultMessage: 'Waiting for the server…' },
+ canceling: { id: 'app.file-download.canceling', defaultMessage: 'Canceling download…' },
+ canceled: { id: 'app.action-bar.install.summary.canceled', defaultMessage: 'Canceled' },
+})
+
const debugExportMessages = defineMessages({
canceled: { id: 'app.action-bar.install.summary.canceled', defaultMessage: 'Canceled' },
title: {
@@ -167,12 +180,14 @@ export function useDownloadManager() {
const task = storeVerificationTask.value
return task ? [buildVerificationRow(task)] : []
})
+ const fileRows = computed(() => [...externalFileDownloads.value.values()].map(buildFileRow))
const debugExportRows = computed(() =>
[...debugInfoExports.value.values()].map(buildDebugExportRow),
)
const allRows = computed(() => [
...rows.value,
...verificationRows.value,
+ ...fileRows.value,
...debugExportRows.value,
])
@@ -197,10 +212,11 @@ export function useDownloadManager() {
)
const rate = computed(() =>
display.formatRate(
- activeJobs.value.reduce(
- (total, job) => total + (transfer.get(job.id, now.value).rate ?? 0),
- 0,
- ),
+ activeJobs.value.reduce((total, job) => {
+ const file = externalFileDownloads.value.get(job.id)
+ const fileRate = file ? getExternalFileDownloadRate(file, now.value) : null
+ return total + (fileRate ?? transfer.get(job.id, now.value).rate ?? 0)
+ }, 0),
),
)
@@ -246,6 +262,68 @@ export function useDownloadManager() {
}
}
+ function buildFileRow(task: ExternalFileDownloadTask): DownloadManagerJob {
+ const rate = getExternalFileDownloadRate(task, now.value)
+ const progress = task.totalBytes ? Math.min(0.99, task.downloadedBytes / task.totalBytes) : 0
+ const text =
+ task.error ??
+ formatMessage(
+ task.canceling
+ ? fileMessages.canceling
+ : task.status === 'canceled'
+ ? fileMessages.canceled
+ : task.status === 'succeeded'
+ ? commonMessages.savedLabel
+ : task.stage === 'preparing'
+ ? fileMessages.preparing
+ : task.stage === 'waiting'
+ ? fileMessages.waiting
+ : task.stage === 'saving'
+ ? commonMessages.savingButton
+ : commonMessages.downloadingButton,
+ )
+
+ return {
+ id: task.id,
+ kind: 'external-file',
+ serverId: task.serverId,
+ createdAt: task.createdAt,
+ instanceId: null,
+ status: task.status,
+ paused: false,
+ canceling: task.canceling,
+ canPause: false,
+ canCancel: task.status === 'running' && task.stage !== 'saving',
+ title: task.serverName ?? task.filename,
+ iconUrl: null,
+ text,
+ taskType: task.filename,
+ finishedAt: task.finishedAt,
+ progress,
+ overallProgress: task.status === 'succeeded' ? 1 : progress,
+ progressLabel:
+ task.status === 'running' && (task.stage === 'downloading' || task.stage === 'saving')
+ ? [
+ task.totalBytes
+ ? `${formatBytes(task.downloadedBytes)} / ${formatBytes(task.totalBytes)}`
+ : formatBytes(task.downloadedBytes),
+ display.formatRate(rate),
+ ]
+ .filter(Boolean)
+ .join(' · ')
+ : '',
+ waiting: task.stage !== 'downloading' || !task.totalBytes,
+ eta:
+ rate && task.totalBytes
+ ? display.formatEta((task.totalBytes - task.downloadedBytes) / rate)
+ : '',
+ canRetry: false,
+ canCopyDetails: !!task.error,
+ copied: copiedJobs.value.has(task.id),
+ busy: busyJobs.value.has(task.id),
+ }
+ }
+
function buildVerificationRow(
task: NonNullable,
): DownloadManagerJob {
@@ -387,6 +465,11 @@ export function useDownloadManager() {
await runAction(id, debugExport.cancel)
return
}
+ const file = externalFileDownloads.value.get(id)
+ if (file) {
+ await runAction(id, file.cancel)
+ return
+ }
if (!jobs.value.get(id)?.can_cancel) return
await runJobAction(id, () => install_job_cancel(id))
}
@@ -403,6 +486,11 @@ export function useDownloadManager() {
if (debugExport.status !== 'running') debugInfoExports.value.delete(id)
return
}
+ const file = externalFileDownloads.value.get(id)
+ if (file) {
+ if (file.status !== 'running') externalFileDownloads.value.delete(id)
+ return
+ }
if (storeVerificationTask.value?.id === id) {
if (storeVerificationTask.value.status !== 'running') storeVerificationTask.value = null
return
@@ -424,10 +512,8 @@ export function useDownloadManager() {
async function copyDetails(id: string) {
await runAction(id, async () => {
- const debugExport = debugInfoExports.value.get(id)
- const details = debugExport
- ? (debugExport.error ?? '')
- : await install_job_support_details(id)
+ const file = externalFileDownloads.value.get(id) ?? debugInfoExports.value.get(id)
+ const details = file ? (file.error ?? '') : await install_job_support_details(id)
if (disposed) return
await navigator.clipboard.writeText(details)
if (disposed) return
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
index 3a6f744a6e..2d7281b7b8 100644
--- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -479,6 +479,15 @@
"app.features-settings.sync-across-devices.title": {
"message": "Sync features across devices"
},
+ "app.file-download.canceling": {
+ "message": "Canceling download…"
+ },
+ "app.file-download.preparing": {
+ "message": "Preparing download…"
+ },
+ "app.file-download.waiting": {
+ "message": "Waiting for the server…"
+ },
"app.home.jump-back-in.actions.label": {
"message": "Instance actions"
},
diff --git a/apps/app-frontend/src/providers/setup.ts b/apps/app-frontend/src/providers/setup.ts
index bec1551bb7..5c8bf5982f 100644
--- a/apps/app-frontend/src/providers/setup.ts
+++ b/apps/app-frontend/src/providers/setup.ts
@@ -6,6 +6,7 @@ import type { InstanceIconConfig } from '@/helpers/types'
import type { AppEvents } from './app-events'
import { setupOnboardingChecklistProvider } from './onboarding-checklist'
import { setupCreationModal } from './setup/creation-modal'
+import { setupFileDownloadProvider } from './setup/file-download'
import { setupFileDropProvider } from './setup/file-drop'
import { setupFilePickerProvider } from './setup/file-picker'
import { setupImageViewerEditorProvider } from './setup/image-viewer-editor'
@@ -24,6 +25,7 @@ export function setupProviders(
const tags = setupTagsProvider(notificationManager)
setupFileDropProvider()
setupFilePickerProvider()
+ setupFileDownloadProvider(client)
setupImageViewerEditorProvider()
setupInstanceImportProvider(notificationManager)
const onboardingChecklist = setupOnboardingChecklistProvider(appEvents)
diff --git a/apps/app-frontend/src/providers/setup/file-download.ts b/apps/app-frontend/src/providers/setup/file-download.ts
new file mode 100644
index 0000000000..eb6be894ac
--- /dev/null
+++ b/apps/app-frontend/src/providers/setup/file-download.ts
@@ -0,0 +1,70 @@
+import type { AbstractModrinthClient } from '@modrinth/api-client'
+import { provideFileDownload } from '@modrinth/ui'
+import { Channel, invoke } from '@tauri-apps/api/core'
+
+import {
+ type ExternalFileDownloadProgress,
+ trackExternalFileDownload,
+} from '@/components/ui/download-manager/external-file-downloads'
+
+interface NativeSaveError {
+ message?: string
+}
+
+export function setupFileDownloadProvider(client: AbstractModrinthClient) {
+ provideFileDownload({
+ async download(file) {
+ const saveId = await invoke('plugin:files|files_select_external', {
+ filename: file.filename,
+ filter: null,
+ }).catch((error: NativeSaveError) => {
+ throw new Error(error.message ?? String(error))
+ })
+ if (!saveId) return false
+
+ let cancelled = false
+ let finished = false
+ const task = trackExternalFileDownload(
+ saveId,
+ file.filename,
+ async () => {
+ cancelled = true
+ await invoke('plugin:files|files_release_external', { saveId })
+ },
+ { serverId: file.serverId, serverName: file.serverName },
+ )
+ const onProgress = new Channel((progress) => {
+ if (cancelled || finished) return
+ task.update(progress)
+ })
+
+ try {
+ const { token } = await client.kyros.files_v1.authorizeFileDownload(
+ file.nodeUrlHost,
+ file.worldId,
+ file.path,
+ )
+ if (cancelled) return false
+ const url = client.kyros.files_v1.getFileDownloadUrl(file.nodeUrlHost, file.worldId, token)
+ const savedFilename = await invoke('plugin:files|files_save_external', {
+ saveId,
+ request: { url },
+ onProgress,
+ })
+ task.succeed(savedFilename)
+ return true
+ } catch (error) {
+ if (cancelled) {
+ task.canceled()
+ return false
+ }
+ const message = (error as NativeSaveError).message ?? String(error)
+ task.fail(message)
+ throw new Error(message)
+ } finally {
+ finished = true
+ await invoke('plugin:files|files_release_external', { saveId }).catch(() => undefined)
+ }
+ },
+ })
+}
diff --git a/apps/app/Cargo.toml b/apps/app/Cargo.toml
index e5c59d1dfe..c8d4accdfd 100644
--- a/apps/app/Cargo.toml
+++ b/apps/app/Cargo.toml
@@ -38,6 +38,7 @@ tauri-plugin-os = { workspace = true }
tauri-plugin-single-instance = { workspace = true }
tauri-plugin-updater = { workspace = true }
tauri-plugin-window-state = { workspace = true }
+tempfile = { workspace = true }
theseus = { workspace = true, features = ["tauri"] }
thiserror = { workspace = true }
tokio = { workspace = true, features = ["time"] }
diff --git a/apps/app/build.rs b/apps/app/build.rs
index ee792094f8..f959d17c2c 100644
--- a/apps/app/build.rs
+++ b/apps/app/build.rs
@@ -423,6 +423,9 @@ fn main() {
"file_rename",
"file_delete",
"file_save_as",
+ "files_select_external",
+ "files_save_external",
+ "files_release_external",
"file_read_dragged_file",
])
.default_permission(
diff --git a/apps/app/src/api/files.rs b/apps/app/src/api/files.rs
index 9600acefb5..de8ade6b2b 100644
--- a/apps/app/src/api/files.rs
+++ b/apps/app/src/api/files.rs
@@ -1,15 +1,46 @@
use crate::api::Result;
use async_zip::base::read::seek::ZipFileReader;
-use serde::Serialize;
+use serde::{Deserialize, Serialize};
+use std::collections::HashMap;
use std::io::Cursor;
-use tauri::Runtime;
+use std::path::PathBuf;
+use std::sync::Arc;
+use std::time::{Duration, Instant};
+use tauri::{Manager, Runtime};
use tauri_plugin_dialog::DialogExt;
+use tauri_plugin_http::reqwest;
+use tokio::io::AsyncWriteExt;
+use tokio_util::sync::CancellationToken;
pub fn init() -> tauri::plugin::TauriPlugin {
tauri::plugin::Builder::new("files")
+ .setup(|app, _| {
+ app.manage(ExternalSaveSessions::default());
+ Ok(())
+ })
+ .on_event(|app, event| {
+ if let tauri::RunEvent::WindowEvent {
+ label,
+ event: tauri::WindowEvent::Destroyed,
+ ..
+ } = event
+ {
+ app.state::().0.retain(|_, session| {
+ if &session.window_label == label {
+ session.cancel.cancel();
+ false
+ } else {
+ true
+ }
+ });
+ }
+ })
.invoke_handler(tauri::generate_handler![
file_extract_zip,
file_save_as,
+ files_select_external,
+ files_save_external,
+ files_release_external,
file_read_dragged_file,
file_list,
file_read,
@@ -232,3 +263,211 @@ pub async fn file_save_as(
Ok(())
}
+
+#[derive(Default)]
+pub struct ExternalSaveSessions(
+ dashmap::DashMap>,
+);
+
+struct ExternalSaveSession {
+ window_label: String,
+ path: PathBuf,
+ lock: tokio::sync::Mutex<()>,
+ cancel: CancellationToken,
+}
+
+#[derive(Deserialize)]
+pub struct ExternalSaveFilter {
+ name: String,
+ extensions: Vec,
+}
+
+#[derive(Deserialize)]
+pub struct ExternalSaveRequest {
+ url: String,
+ #[serde(default)]
+ headers: HashMap,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ExternalSaveProgress {
+ stage: &'static str,
+ downloaded_bytes: u64,
+ total_bytes: Option,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct ExternalSaveError {
+ message: String,
+ status_code: Option,
+}
+
+impl ExternalSaveError {
+ fn new(message: impl Into) -> Self {
+ Self {
+ message: message.into(),
+ status_code: None,
+ }
+ }
+}
+
+impl From for ExternalSaveError {
+ fn from(error: std::io::Error) -> Self {
+ Self::new(error.to_string())
+ }
+}
+
+impl From for ExternalSaveError {
+ fn from(error: reqwest::Error) -> Self {
+ Self {
+ status_code: error.status().map(|status| status.as_u16()),
+ message: error.without_url().to_string(),
+ }
+ }
+}
+
+fn external_save_session(
+ window: &tauri::WebviewWindow,
+ state: &ExternalSaveSessions,
+ save_id: &str,
+) -> std::result::Result, ExternalSaveError> {
+ state
+ .0
+ .get(save_id)
+ .filter(|session| session.window_label == window.label())
+ .map(|session| Arc::clone(session.value()))
+ .ok_or_else(|| ExternalSaveError::new("Unknown save session"))
+}
+
+#[tauri::command]
+pub async fn files_select_external(
+ window: tauri::WebviewWindow,
+ state: tauri::State<'_, ExternalSaveSessions>,
+ filename: String,
+ filter: Option,
+) -> std::result::Result