diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 9024f45f985..50a9ae60f09 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -34,6 +34,7 @@ import { LoadingBar, NotificationPanel, PopupNotificationPanel, + provideFileDownload, provideLoadingState, provideModalBehavior, provideModrinthClient, @@ -64,6 +65,7 @@ import { RouterView, useRoute, useRouter } from 'vue-router' import { useRouteLoading } from '@/app/runtime/use-route-loading' import AppSidebar from '@/app/shell/sidebar/index.vue' import AppTitleBar from '@/app/shell/title-bar/index.vue' +import { trackExternalFileDownload } from '@/components/ui/download-manager/external-file-downloads' import ErrorModal from '@/components/ui/ErrorModal.vue' import HostingUpdateRequired from '@/components/ui/HostingUpdateRequired.vue' import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue' @@ -157,6 +159,7 @@ import { instanceListQueryOptions, screenshotKeys, } from '@/pages/instance/query-options' +import { createFileDownload } from '@/platform/adapters/file-download' import { createAppClient } from '@/platform/modrinth-client' import { appUpdateState, @@ -283,6 +286,7 @@ useAppEvent('ads_consent_required', handleAdsConsentRequired, appEvents) const tauriApiClient = createAppClient() provideModrinthClient(tauriApiClient) +provideFileDownload(createFileDownload(tauriApiClient, trackExternalFileDownload)) const { data: authenticatedModrinthUser } = useQuery({ queryKey: computed(() => ['authenticated-user', 'campaigns', credentials.value?.user?.id]), queryFn: () => tauriApiClient.labrinth.users_v3.getAuthenticated(), 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 index e2b6de35107..e44ded43c8f 100644 --- 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 @@ -1,10 +1,6 @@ import { reactive, ref } from 'vue' -export interface ExternalFileDownloadProgress { - stage: 'waiting' | 'downloading' | 'saving' - downloadedBytes: number - totalBytes: number | null -} +import type { ExternalFileProgress } from '@/platform/app-lib/files/commands' export interface ExternalFileDownloadTask { id: string @@ -12,7 +8,7 @@ export interface ExternalFileDownloadTask { serverId?: string serverName?: string status: 'running' | 'succeeded' | 'failed' | 'canceled' - stage: 'preparing' | ExternalFileDownloadProgress['stage'] + stage: 'preparing' | ExternalFileProgress['stage'] downloadedBytes: number totalBytes: number | null rate: number @@ -66,7 +62,7 @@ export function trackExternalFileDownload( } return { - update(progress: ExternalFileDownloadProgress) { + update(progress: ExternalFileProgress) { if (task.status !== 'running' || task.canceling) return const now = performance.now() if (progress.stage !== 'downloading' || progress.downloadedBytes < previousBytes) { diff --git a/apps/app-frontend/src/helpers/native-drag-drop.ts b/apps/app-frontend/src/helpers/native-drag-drop.ts new file mode 100644 index 00000000000..23faeb5e15c --- /dev/null +++ b/apps/app-frontend/src/helpers/native-drag-drop.ts @@ -0,0 +1,44 @@ +import type { UnlistenFn } from '@tauri-apps/api/event' +import type { DragDropEvent } from '@tauri-apps/api/webview' +import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow' +import { platform } from '@tauri-apps/plugin-os' + +export type NativeDragDropEvent = + | { type: 'enter'; paths: string[]; position: { x: number; y: number } } + | { type: 'over'; position: { x: number; y: number } } + | { type: 'drop'; paths: string[]; position: { x: number; y: number } } + | { type: 'leave' } + +/** + * Listens for native OS file drag & drop on the main window, with positions in CSS pixels. + * + * This must listen as a `WebviewWindow`. Depending on how the main webview is attached, Tauri emits + * drag events either to `Webview` targets or to `Window` targets, and only a `WebviewWindow` + * listener matches both. + * + * Tauri types the position as physical, but only Windows reports physical pixels. macOS and Linux + * already report logical pixels, so only Windows is scaled down. + */ +export async function listenNativeDragDrop( + handler: (event: NativeDragDropEvent) => void | Promise, +): Promise { + const scale = platform() === 'windows' ? window.devicePixelRatio || 1 : 1 + + return await getCurrentWebviewWindow().onDragDropEvent((event: { payload: DragDropEvent }) => { + const payload = event.payload + + if (payload.type === 'leave') { + void handler({ type: 'leave' }) + return + } + + const position = { x: payload.position.x / scale, y: payload.position.y / scale } + + if (payload.type === 'over') { + void handler({ type: 'over', position }) + return + } + + void handler({ type: payload.type, paths: payload.paths, position }) + }) +} diff --git a/apps/app-frontend/src/platform/adapters/file-download.ts b/apps/app-frontend/src/platform/adapters/file-download.ts new file mode 100644 index 00000000000..076f2d567e4 --- /dev/null +++ b/apps/app-frontend/src/platform/adapters/file-download.ts @@ -0,0 +1,141 @@ +import { + type AbstractModrinthClient, + type DownloadSink, + getNodeBaseUrl, + ModrinthApiError, +} from '@modrinth/api-client' +import type { FileDownloadProvider } from '@modrinth/ui' +import { Channel } from '@tauri-apps/api/core' + +import { + type ExternalFileError, + type ExternalFileProgress, + releaseExternal, + saveExternal, + selectExternal, +} from '@/platform/app-lib/files/commands' + +export interface FileDownloadTask { + update: (progress: ExternalFileProgress) => void + succeed: (filename: string) => void + fail: (error: string) => void + canceled: () => void +} + +/** Registers a running download so the app can display its progress and cancel it. */ +export type TrackFileDownload = ( + saveId: string, + filename: string, + onCancel: () => Promise, + server: { serverId?: string; serverName?: string }, +) => FileDownloadTask + +export function createFileDownload( + client: AbstractModrinthClient, + trackDownload: TrackFileDownload, +): FileDownloadProvider { + return { + async download(file) { + const saveId = await selectExternal( + file.filename, + file.type === 'server-backup' || file.type === 'server-world' + ? { name: 'ZIP archive', extensions: ['zip'] } + : null, + ).catch((error: ExternalFileError) => { + throw new Error(error.message ?? String(error)) + }) + if (!saveId) return false + + let cancelled = false + let finished = false + let savedFilename = file.filename + const task = trackDownload( + saveId, + file.filename, + async () => { + cancelled = true + await releaseExternal(saveId) + }, + { serverId: file.serverId, serverName: file.serverName }, + ) + const onProgress = new Channel((progress) => { + if (cancelled || finished) return + task.update(progress) + }) + + const saveUrl = async (url: string, headers: Record = {}) => { + if (cancelled) throw new Error('Download cancelled') + try { + savedFilename = await saveExternal(saveId, { url, headers }, onProgress) + } catch (error) { + const nativeError = error as ExternalFileError + throw new ModrinthApiError(nativeError.message ?? String(error), { + statusCode: nativeError.statusCode, + }) + } + } + const sink: DownloadSink = (url, options) => saveUrl(url, options.headers) + + try { + switch (file.type) { + case 'server-file': { + const { token } = await client.kyros.files_v1.authorizeFileDownload( + file.nodeUrlHost, + file.worldId, + file.path, + ) + await saveUrl( + client.kyros.files_v1.getFileDownloadUrl(file.nodeUrlHost, file.worldId, token), + ) + break + } + case 'server-world': { + const { token } = await client.kyros.files_v1.authorizeFullWorldDownload( + file.nodeUrlHost, + file.worldId, + ) + await client.download( + `/worlds/${encodeURIComponent(file.worldId)}/files/download-full-zip`, + { + api: getNodeBaseUrl(file.nodeUrlHost), + version: 'v1', + params: { token }, + skipAuth: true, + }, + sink, + ) + break + } + case 'server-backup': { + const { token } = await client.kyros.backups_v1.authorizeDownload( + file.nodeUrlHost, + file.worldId, + file.backupId, + ) + await saveUrl( + client.kyros.backups_v1.getDownloadUrl( + file.nodeUrlHost, + file.worldId, + file.backupId, + token, + ), + ) + break + } + } + task.succeed(savedFilename) + return true + } catch (error) { + if (cancelled) { + task.canceled() + return false + } + task.fail(error instanceof Error ? error.message : String(error)) + throw error + } finally { + finished = true + await releaseExternal(saveId).catch(() => undefined) + } + }, + } +} diff --git a/apps/app-frontend/src/platform/adapters/local-file-upload.ts b/apps/app-frontend/src/platform/adapters/local-file-upload.ts new file mode 100644 index 00000000000..1d6433534dc --- /dev/null +++ b/apps/app-frontend/src/platform/adapters/local-file-upload.ts @@ -0,0 +1,30 @@ +import { type LocalFileUploader, ModrinthApiError } from '@modrinth/api-client' +import { Channel } from '@tauri-apps/api/core' + +import { + cancelLocalUpload, + type LocalUploadProgress, + uploadLocal, +} from '@/platform/app-lib/files/commands' + +/** Uploads local files from the Rust side, streaming them from disk. */ +export function createLocalFileUploader(): LocalFileUploader { + return async ({ url, headers, file, multipartField, signal, onProgress }) => { + const uploadId = crypto.randomUUID() + const cancel = () => void cancelLocalUpload(uploadId).catch(() => undefined) + signal.addEventListener('abort', cancel) + try { + return await uploadLocal( + uploadId, + { url, headers, path: file.path, fileName: file.name, multipartField }, + new Channel(({ loaded, total }) => + onProgress({ loaded, total, progress: total > 0 ? loaded / total : 1 }), + ), + ) + } catch (error) { + throw new ModrinthApiError((error as { message?: string }).message ?? String(error)) + } finally { + signal.removeEventListener('abort', cancel) + } + } +} diff --git a/apps/app-frontend/src/platform/app-lib/files/commands.ts b/apps/app-frontend/src/platform/app-lib/files/commands.ts new file mode 100644 index 00000000000..cd660ed54fe --- /dev/null +++ b/apps/app-frontend/src/platform/app-lib/files/commands.ts @@ -0,0 +1,80 @@ +import { type Channel, invoke } from '@tauri-apps/api/core' + +export interface ExternalFileFilter { + name: string + extensions: string[] +} + +export interface ExternalFileRequest { + url: string + headers: Record +} + +export interface ExternalFileProgress { + stage: 'waiting' | 'downloading' | 'saving' + downloadedBytes: number + totalBytes: number | null +} + +export interface ExternalFileError { + message?: string + statusCode?: number +} + +export function selectExternal( + filename: string, + filter: ExternalFileFilter | null, +): Promise { + return invoke('plugin:files|files_select_external', { filename, filter }) +} + +export function saveExternal( + saveId: string, + request: ExternalFileRequest, + onProgress: Channel, +): Promise { + return invoke('plugin:files|files_save_external', { saveId, request, onProgress }) +} + +export function releaseExternal(saveId: string): Promise { + return invoke('plugin:files|files_release_external', { saveId }) +} + +export interface LocalFileInfo { + name: string + size: number +} + +export interface LocalUploadRequest { + url: string + headers: Record + path: string + fileName?: string + multipartField?: string +} + +export interface LocalUploadProgress { + loaded: number + total: number +} + +export interface LocalUploadResponse { + status: number + body: string +} + +export function getLocalFileInfo(path: string): Promise { + return invoke('plugin:files|file_local_info', { path }) +} + +export function uploadLocal( + uploadId: string, + request: LocalUploadRequest, + onProgress: Channel, +): Promise { + return invoke('plugin:files|file_upload_local', { uploadId, request, onProgress }) +} + +export function cancelLocalUpload(uploadId: string): Promise { + return invoke('plugin:files|file_cancel_local_upload', { uploadId }) +} diff --git a/apps/app-frontend/src/platform/modrinth-client.ts b/apps/app-frontend/src/platform/modrinth-client.ts index cd61e1a73b0..9e5afd79637 100644 --- a/apps/app-frontend/src/platform/modrinth-client.ts +++ b/apps/app-frontend/src/platform/modrinth-client.ts @@ -10,6 +10,7 @@ import { getVersion } from '@tauri-apps/api/app' import { config } from '@/config' import { get as getCreds } from '@/helpers/mr_auth' +import { createLocalFileUploader } from '@/platform/adapters/local-file-upload' export function createAppClient(): TauriModrinthClient { const appVersion = getVersion() @@ -19,6 +20,7 @@ export function createAppClient(): TauriModrinthClient { labrinthBaseUrl: config.labrinthBaseUrl, archonBaseUrl: config.archonBaseUrl, sharedInstancesBaseUrl: config.sharedInstancesBaseUrl, + localFileUploader: createLocalFileUploader(), features: [ new NodeAuthFeature({ getAuth: () => nodeAuthState.getAuth?.() ?? null, diff --git a/apps/app-frontend/src/providers/setup.ts b/apps/app-frontend/src/providers/setup.ts index 5c8bf5982f6..bec1551bb72 100644 --- a/apps/app-frontend/src/providers/setup.ts +++ b/apps/app-frontend/src/providers/setup.ts @@ -6,7 +6,6 @@ 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' @@ -25,7 +24,6 @@ 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 deleted file mode 100644 index da4a07d63dc..00000000000 --- a/apps/app-frontend/src/providers/setup/file-download.ts +++ /dev/null @@ -1,88 +0,0 @@ -import type { AbstractModrinthClient } from '@modrinth/api-client' -import { type FileDownload, 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 -} - -async function authorizeDownloadUrl(client: AbstractModrinthClient, file: FileDownload) { - if (file.type === 'server-backup') { - const { token } = await client.kyros.backups_v1.authorizeDownload( - file.nodeUrlHost, - file.worldId, - file.backupId, - ) - return client.kyros.backups_v1.getDownloadUrl( - file.nodeUrlHost, - file.worldId, - file.backupId, - token, - ) - } - - const { token } = await client.kyros.files_v1.authorizeFileDownload( - file.nodeUrlHost, - file.worldId, - file.path, - ) - return client.kyros.files_v1.getFileDownloadUrl(file.nodeUrlHost, file.worldId, token) -} - -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 url = await authorizeDownloadUrl(client, file) - if (cancelled) return false - 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-frontend/src/providers/setup/file-drop.ts b/apps/app-frontend/src/providers/setup/file-drop.ts index bfa9fde6056..ff663e19d11 100644 --- a/apps/app-frontend/src/providers/setup/file-drop.ts +++ b/apps/app-frontend/src/providers/setup/file-drop.ts @@ -1,20 +1,13 @@ import { provideFileDrop } from '@modrinth/ui' import { invoke } from '@tauri-apps/api/core' -import type { DragDropEvent } from '@tauri-apps/api/webview' -import { getCurrentWebview } from '@tauri-apps/api/webview' + +import { listenNativeDragDrop } from '@/helpers/native-drag-drop' +import { getLocalFileInfo } from '@/platform/app-lib/files/commands' function getFileName(path: string) { return path.split(/[\\/]/).pop() || 'file' } -function toLogicalPosition(position: { x: number; y: number }) { - const scale = window.devicePixelRatio || 1 - return { - x: position.x / scale, - y: position.y / scale, - } -} - async function readDraggedFile(path: string) { const data = await invoke('plugin:files|file_read_dragged_file', { path }) return new Uint8Array(data) @@ -25,10 +18,8 @@ export function setupFileDropProvider() { provideFileDrop({ async listenNativeFileDrop(handler) { - return await getCurrentWebview().onDragDropEvent((event: { payload: DragDropEvent }) => { - const payload = event.payload - - if (payload.type === 'leave') { + return await listenNativeDragDrop((event) => { + if (event.type === 'leave') { nativeFileDropPaths = [] void handler({ type: 'leave', @@ -38,17 +29,17 @@ export function setupFileDropProvider() { return } - if (payload.type === 'enter' || payload.type === 'drop') { - nativeFileDropPaths = payload.paths + if (event.type === 'enter' || event.type === 'drop') { + nativeFileDropPaths = event.paths } void handler({ - type: payload.type, + type: event.type, paths: nativeFileDropPaths, - position: toLogicalPosition(payload.position), + position: event.position, }) - if (payload.type === 'drop') { + if (event.type === 'drop') { nativeFileDropPaths = [] } }) @@ -58,5 +49,14 @@ export function setupFileDropProvider() { paths.map(async (path) => new File([await readDraggedFile(path)], getFileName(path))), ) }, + async resolveLocalFiles(paths) { + return await Promise.all( + paths.map(async (path) => ({ + kind: 'local' as const, + path, + ...(await getLocalFileInfo(path)), + })), + ) + }, }) } diff --git a/apps/app/build.rs b/apps/app/build.rs index 7ae9fd15281..fb47043f527 100644 --- a/apps/app/build.rs +++ b/apps/app/build.rs @@ -430,6 +430,9 @@ fn main() { "files_save_external", "files_release_external", "file_read_dragged_file", + "file_local_info", + "file_upload_local", + "file_cancel_local_upload", ]) .default_permission( DefaultPermissionRule::AllowAllCommands, diff --git a/apps/app/src/api/files.rs b/apps/app/src/api/files.rs index 045f248ed5f..279e3c6cdc5 100644 --- a/apps/app/src/api/files.rs +++ b/apps/app/src/api/files.rs @@ -17,6 +17,7 @@ pub fn init() -> tauri::plugin::TauriPlugin { tauri::plugin::Builder::new("files") .setup(|app, _| { app.manage(ExternalSaveSessions::default()); + app.manage(LocalUploads::default()); Ok(()) }) .on_event(|app, event| { @@ -34,6 +35,14 @@ pub fn init() -> tauri::plugin::TauriPlugin { true } }); + app.state::().0.retain(|_, upload| { + if &upload.window_label == label { + upload.cancel.cancel(); + false + } else { + true + } + }); } }) .invoke_handler(tauri::generate_handler![ @@ -43,6 +52,9 @@ pub fn init() -> tauri::plugin::TauriPlugin { files_save_external, files_release_external, file_read_dragged_file, + file_local_info, + file_upload_local, + file_cancel_local_upload, file_list, file_read, file_write, @@ -69,6 +81,127 @@ pub async fn file_read_dragged_file(path: String) -> Result> { Ok(tokio::fs::read(path).await?) } +#[derive(Serialize)] +pub struct LocalFileInfo { + name: String, + size: u64, +} + +#[tauri::command] +pub async fn file_local_info(path: String) -> Result { + let metadata = tokio::fs::metadata(&path).await?; + if !metadata.is_file() { + return Err(anyhow!("path is not a file").into()); + } + + let name = std::path::Path::new(&path) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "file".to_string()); + Ok(LocalFileInfo { + name, + size: metadata.len(), + }) +} + +#[derive(Default)] +pub struct LocalUploads(dashmap::DashMap); + +struct LocalUpload { + window_label: String, + cancel: CancellationToken, +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LocalUploadRequest { + url: String, + #[serde(default)] + headers: HashMap, + path: PathBuf, + file_name: Option, + multipart_field: Option, +} + +#[derive(Serialize, Clone, Copy)] +pub struct LocalUploadProgress { + loaded: u64, + total: u64, +} + +#[derive(Serialize)] +pub struct LocalUploadResponse { + status: u16, + body: String, +} + +/// Uploads a local file straight from disk, so large files never pass through the webview. +#[tauri::command] +pub async fn file_upload_local( + window: tauri::Window, + state: tauri::State<'_, LocalUploads>, + upload_id: String, + request: LocalUploadRequest, + on_progress: tauri::ipc::Channel, +) -> Result { + let url = url::Url::parse(&request.url)?; + let cancel = CancellationToken::new(); + state.0.insert( + upload_id.clone(), + LocalUpload { + window_label: window.label().to_string(), + cancel: cancel.clone(), + }, + ); + + let mut last_progress: Option = None; + let upload = theseus::upload::upload_local_file( + theseus::upload::LocalFileUpload { + url, + headers: request.headers.into_iter().collect(), + path: request.path, + file_name: request.file_name, + multipart_field: request.multipart_field, + }, + move |loaded, total| { + if loaded == total + || last_progress.is_none_or(|last| { + last.elapsed() >= Duration::from_millis(100) + }) + { + let _ = on_progress.send(LocalUploadProgress { loaded, total }); + last_progress = Some(Instant::now()); + } + }, + ); + + let result: Result = tokio::select! { + _ = cancel.cancelled() => Err(anyhow!("Upload cancelled").into()), + result = upload => result.map_err(Into::into), + }; + state.0.remove(&upload_id); + + let response = result?; + Ok(LocalUploadResponse { + status: response.status, + body: response.body, + }) +} + +#[tauri::command] +pub async fn file_cancel_local_upload( + window: tauri::Window, + state: tauri::State<'_, LocalUploads>, + upload_id: String, +) -> Result<()> { + if let Some((_, upload)) = state.0.remove_if(&upload_id, |_, upload| { + upload.window_label == window.label() + }) { + upload.cancel.cancel(); + } + Ok(()) +} + #[tauri::command] pub async fn file_extract_zip( instance_id: &str, @@ -334,7 +467,7 @@ impl From for ExternalSaveError { } fn external_save_session( - window: &tauri::WebviewWindow, + window: &tauri::Window, state: &ExternalSaveSessions, save_id: &str, ) -> std::result::Result, ExternalSaveError> { @@ -348,7 +481,7 @@ fn external_save_session( #[tauri::command] pub async fn files_select_external( - window: tauri::WebviewWindow, + window: tauri::Window, state: tauri::State<'_, ExternalSaveSessions>, filename: String, filter: Option, @@ -388,7 +521,7 @@ pub async fn files_select_external( #[tauri::command] pub async fn files_save_external( - window: tauri::WebviewWindow, + window: tauri::Window, state: tauri::State<'_, ExternalSaveSessions>, save_id: String, request: ExternalSaveRequest, @@ -467,7 +600,7 @@ pub async fn files_save_external( #[tauri::command] pub async fn files_release_external( - window: tauri::WebviewWindow, + window: tauri::Window, state: tauri::State<'_, ExternalSaveSessions>, save_id: String, ) -> std::result::Result<(), ExternalSaveError> { diff --git a/packages/api-client/src/core/abstract-client.ts b/packages/api-client/src/core/abstract-client.ts index e750505d046..df5160ac335 100644 --- a/packages/api-client/src/core/abstract-client.ts +++ b/packages/api-client/src/core/abstract-client.ts @@ -1,8 +1,10 @@ import type { InferredClientModules } from '../modules' import { buildModuleStructure } from '../modules' import type { BaseUrlConfig, ClientConfig } from '../types/client' +import type { DownloadSink } from '../types/download' import type { RequestContext, RequestOptions } from '../types/request' import type { UploadMetadata, UploadProgress, UploadRequestOptions } from '../types/upload' +import { appendRequestParams } from '../utils/fetch' import type { AbstractFeature } from './abstract-feature' import type { AbstractModule } from './abstract-module' import type { AbstractSyncClient } from './abstract-sync' @@ -169,6 +171,47 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { } } + async download(path: string, options: RequestOptions, sink: DownloadSink): Promise { + let baseUrl: string + if (options.api === 'labrinth') { + baseUrl = this.resolveBaseUrl(this.config.labrinthBaseUrl!) + } else if (options.api === 'archon') { + baseUrl = this.resolveBaseUrl(this.config.archonBaseUrl!) + } else if (options.api === 'sharedinstances') { + baseUrl = this.resolveBaseUrl(this.config.sharedInstancesBaseUrl!) + } else { + baseUrl = options.api + } + + const mergedOptions: RequestOptions = { + method: 'GET', + retry: false, + circuitBreaker: false, + ...options, + headers: { + ...(await this.buildDefaultHeaders()), + ...options.headers, + }, + } + this.attachArchonSentryCaptureHeader(mergedOptions) + const context = this.buildContext( + this.buildUrl(path, baseUrl, options.version), + path, + mergedOptions, + ) + + try { + await this.executeFeatureChain(context, () => + sink(appendRequestParams(context.url, context.options.params), context.options), + ) + await this.config.hooks?.onResponse?.(undefined, context) + } catch (error) { + const apiError = this.normalizeError(error, context) + await this.config.hooks?.onError?.(apiError, context) + throw apiError + } + } + async stream(path: string, options: RequestOptions): Promise> { let baseUrl: string if (options.api === 'labrinth') { @@ -326,7 +369,7 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { options: UploadRequestOptions, ): RequestContext { let metadata: UploadMetadata - let body: File | Blob | FormData + let body: UploadMetadata['file'] | FormData if ('formData' in options && options.formData) { metadata = { @@ -339,6 +382,7 @@ export abstract class AbstractModrinthClient extends AbstractUploadClient { metadata = { isUpload: true, file: options.file, + multipartField: options.multipartField, onProgress: options.onProgress, } body = options.file diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index eefeda12efb..5548a4ca761 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -43,7 +43,7 @@ export { XHRUploadClient } from './platform/xhr-upload-client' export { clearNodeAuthState, nodeAuthState, setNodeAuthState } from './state/node-auth' export * from './types' export { withJWTRetry } from './utils/jwt-retry' -export { getNodeWebSocketUrl } from './utils/node-url' +export { getNodeBaseUrl, getNodeWebSocketUrl } from './utils/node-url' export { WebSocketPinger, type WebSocketPingOptions } from './utils/pingtest' export { type ParsedSseEvent, diff --git a/packages/api-client/src/platform/generic.ts b/packages/api-client/src/platform/generic.ts index 7ec63b68249..bc2f1972286 100644 --- a/packages/api-client/src/platform/generic.ts +++ b/packages/api-client/src/platform/generic.ts @@ -46,8 +46,9 @@ export class GenericModrinthClient extends XHRUploadClient { protected async executeRequest(url: string, options: RequestOptions): Promise { try { - const response = await $fetch(url, { + const response = await $fetch(url, { method: options.method ?? 'GET', + responseType: options.responseType, headers: options.headers, body: options.body as BodyInit, params: options.params as Record, @@ -55,7 +56,7 @@ export class GenericModrinthClient extends XHRUploadClient { signal: options.signal, }) - return response + return response as T } catch (error) { // ofetch throws FetchError for HTTP errors throw this.normalizeError(error) diff --git a/packages/api-client/src/platform/nuxt.ts b/packages/api-client/src/platform/nuxt.ts index a5308535ba3..b09f5077fca 100644 --- a/packages/api-client/src/platform/nuxt.ts +++ b/packages/api-client/src/platform/nuxt.ts @@ -160,6 +160,7 @@ export class NuxtModrinthClient extends XHRUploadClient { // @ts-expect-error - $fetch is provided by Nuxt const response = await $fetch(url, { method: options.method ?? 'GET', + responseType: options.responseType, headers: options.headers, body: options.body, params: options.params, diff --git a/packages/api-client/src/platform/tauri.ts b/packages/api-client/src/platform/tauri.ts index ac82b53edba..78fd17787c2 100644 --- a/packages/api-client/src/platform/tauri.ts +++ b/packages/api-client/src/platform/tauri.ts @@ -92,7 +92,7 @@ export class TauriModrinthClient extends XHRUploadClient { // Handle binary downloads (e.g. kyros fs files) before JSON parsing. const contentType = response.headers.get('content-type')?.toLowerCase() ?? '' - if (fullUrl.includes('/fs/download')) { + if (options.responseType === 'blob' || fullUrl.includes('/fs/download')) { return (await response.blob()) as T } if ( diff --git a/packages/api-client/src/platform/xhr-upload-client.ts b/packages/api-client/src/platform/xhr-upload-client.ts index cd1dcd46876..8cd5cdba8d8 100644 --- a/packages/api-client/src/platform/xhr-upload-client.ts +++ b/packages/api-client/src/platform/xhr-upload-client.ts @@ -1,12 +1,24 @@ import { AbstractModrinthClient } from '../core/abstract-client' import { ModrinthApiError } from '../core/errors' import type { RequestContext } from '../types/request' -import type { - UploadHandle, - UploadMetadata, - UploadProgress, - UploadRequestOptions, +import { + isLocalFile, + type LocalFile, + type UploadHandle, + type UploadMetadata, + type UploadProgress, + type UploadRequestOptions, } from '../types/upload' +import { appendRequestParams } from '../utils/fetch' + +function parseResponseBody(body: string): unknown { + if (!body) return undefined + try { + return JSON.parse(body) + } catch { + return body + } +} /** * Abstract client with XHR-based upload implementation @@ -42,7 +54,9 @@ export abstract class XHRUploadClient extends AbstractModrinthClient { let context: RequestContext | undefined const handle: UploadHandle = { promise: (async () => { - const isFormData = 'formData' in options && options.formData instanceof FormData + const isFormData = + ('formData' in options && options.formData instanceof FormData) || + !!options.multipartField const baseHeaders = await this.buildDefaultHeaders() if (isFormData) { delete baseHeaders['Content-Type'] @@ -96,9 +110,19 @@ export abstract class XHRUploadClient extends AbstractModrinthClient { progressCallbacks: Array<(p: UploadProgress) => void>, abortController: AbortController, ): Promise { + const metadata = context.metadata as UploadMetadata + if (isLocalFile(metadata.file)) { + return this.executeLocalFileUpload( + context, + metadata.file, + progressCallbacks, + abortController, + ) + } + const file = metadata.file + return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() - const metadata = context.metadata as UploadMetadata xhr.upload.addEventListener('progress', (e) => { if (e.lengthComputable) { @@ -147,13 +171,54 @@ export abstract class XHRUploadClient extends AbstractModrinthClient { xhr.setRequestHeader(key, value) } - // Send either FormData or file depending on what was provided - const data = 'formData' in metadata ? metadata.formData : metadata.file + let data: XMLHttpRequestBodyInit | undefined = metadata.formData ?? file + if (file && metadata.multipartField) { + data = new FormData() + data.append(metadata.multipartField, file, file instanceof File ? file.name : 'file') + } xhr.send(data) abortController.signal.addEventListener('abort', () => xhr.abort()) }) } + /** + * Uploads a local file through the platform's `localFileUploader`, at the end of the feature + * chain so the request carries the same auth and headers as an XHR upload. + */ + protected async executeLocalFileUpload( + context: RequestContext, + file: LocalFile, + progressCallbacks: Array<(p: UploadProgress) => void>, + abortController: AbortController, + ): Promise { + const uploader = this.config.localFileUploader + if (!uploader) { + throw new ModrinthApiError('Local file uploads are not supported on this platform') + } + if (abortController.signal.aborted) { + throw new ModrinthApiError('Upload cancelled') + } + + const response = await uploader({ + url: appendRequestParams(context.url, context.options.params), + headers: context.options.headers ?? {}, + file, + multipartField: (context.metadata as UploadMetadata).multipartField, + signal: abortController.signal, + onProgress: (progress) => progressCallbacks.forEach((cb) => cb(progress)), + }) + + const data = parseResponseBody(response.body) + if (response.status >= 200 && response.status < 300) { + return data as T + } + throw this.createNormalizedError( + new Error(`Upload failed with status ${response.status}`), + response.status, + data, + ) + } + protected createUploadError(xhr: XMLHttpRequest): ModrinthApiError { let responseData: unknown try { diff --git a/packages/api-client/src/types/client.ts b/packages/api-client/src/types/client.ts index b2b991cc44b..22c9ea134ee 100644 --- a/packages/api-client/src/types/client.ts +++ b/packages/api-client/src/types/client.ts @@ -1,5 +1,6 @@ import type { AbstractFeature } from '../core/abstract-feature' import type { RequestContext } from './request' +import type { LocalFileUploader } from './upload' export type MaybePromise = T | Promise export type UserAgentProvider = string | (() => MaybePromise) @@ -75,6 +76,12 @@ export interface ClientConfig { */ archonSentryCapture?: boolean | (() => boolean) + /** + * Sends uploads of `LocalFile`s, which the platform reads straight from disk. + * Without it, uploading a `LocalFile` fails. + */ + localFileUploader?: LocalFileUploader + /** * Features to enable for this client * Features are applied in the order they appear in this array diff --git a/packages/api-client/src/types/download.ts b/packages/api-client/src/types/download.ts new file mode 100644 index 00000000000..d8619e29415 --- /dev/null +++ b/packages/api-client/src/types/download.ts @@ -0,0 +1,3 @@ +import type { RequestOptions } from './request' + +export type DownloadSink = (url: string, options: RequestOptions) => Promise diff --git a/packages/api-client/src/types/index.ts b/packages/api-client/src/types/index.ts index 2fb40b14e4f..f5135f584a8 100644 --- a/packages/api-client/src/types/index.ts +++ b/packages/api-client/src/types/index.ts @@ -8,13 +8,20 @@ export type { export type { BackoffStrategy, RetryConfig } from '../features/retry' export type { Archon } from '../modules/archon/types' export type { BaseUrlConfig, ClientConfig, RequestHooks } from './client' +export type { DownloadSink } from './download' export type { ApiErrorData, ModrinthErrorResponse } from './errors' export { isModrinthErrorResponse } from './errors' export type { HttpMethod, RequestContext, RequestOptions, ResponseData } from './request' export type { + LocalFile, + LocalFileUploader, + LocalFileUploadRequest, + LocalFileUploadResponse, + UploadFile, UploadHandle, UploadMetadata, UploadProgress, UploadRequestOptions, UploadState, } from './upload' +export { isLocalFile } from './upload' diff --git a/packages/api-client/src/types/request.ts b/packages/api-client/src/types/request.ts index d1a57d8845a..ca7b9836530 100644 --- a/packages/api-client/src/types/request.ts +++ b/packages/api-client/src/types/request.ts @@ -29,6 +29,9 @@ export type RequestOptions = { */ method?: HttpMethod + /** Explicit response decoding for binary downloads. */ + responseType?: 'blob' + /** * Request headers */ diff --git a/packages/api-client/src/types/upload.ts b/packages/api-client/src/types/upload.ts index 61e50dc94d0..86d5235aec7 100644 --- a/packages/api-client/src/types/upload.ts +++ b/packages/api-client/src/types/upload.ts @@ -12,6 +12,54 @@ export interface UploadProgress { progress: number } +/** + * A file on the local filesystem, which the platform uploads straight from disk rather than + * reading it into memory. Requires a `localFileUploader` in the client config. + */ +export interface LocalFile { + kind: 'local' + path: string + name: string + size: number +} + +/** + * A file that can be uploaded, either held by the browser or on the local filesystem + */ +export type UploadFile = File | Blob | LocalFile + +export function isLocalFile(file: unknown): file is LocalFile { + return typeof file === 'object' && file !== null && (file as LocalFile).kind === 'local' +} + +/** + * A local file upload, after the feature chain has applied auth and other headers + */ +export interface LocalFileUploadRequest { + url: string + headers: Record + file: LocalFile + /** Sends the file as this multipart form field rather than as the raw body */ + multipartField?: string + signal: AbortSignal + onProgress: (progress: UploadProgress) => void +} + +/** + * The response to a local file upload, whatever its status + */ +export interface LocalFileUploadResponse { + status: number + body: string +} + +/** + * Sends local file uploads for the platform, such as through a native HTTP client + */ +export type LocalFileUploader = ( + request: LocalFileUploadRequest, +) => Promise + /** * Base options for upload requests */ @@ -24,8 +72,10 @@ interface BaseUploadRequestOptions extends Omit { isUpload: true - file: File | Blob + file: UploadFile + multipartField?: string formData?: never onProgress?: (progress: UploadProgress) => void } diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs index 5fddc743a1c..c67f5edff70 100644 --- a/packages/app-lib/src/api/mod.rs +++ b/packages/app-lib/src/api/mod.rs @@ -17,6 +17,7 @@ pub mod reports; pub mod server_address; pub mod settings; pub mod tags; +pub mod upload; pub mod users; pub mod worlds; diff --git a/packages/app-lib/src/api/upload.rs b/packages/app-lib/src/api/upload.rs new file mode 100644 index 00000000000..c322b91e711 --- /dev/null +++ b/packages/app-lib/src/api/upload.rs @@ -0,0 +1,95 @@ +//! Uploads of local files to HTTP endpoints, streamed from disk. + +use crate::util::fetch::INSECURE_NO_TIMEOUT_REQWEST_CLIENT; +use crate::util::io::IOError; +use crate::{ErrorKind, Result}; +use futures::TryStreamExt; +use reqwest::header::CONTENT_LENGTH; +use reqwest::multipart::{Form, Part}; +use std::path::PathBuf; +use tokio_util::io::ReaderStream; + +const CHUNK_SIZE: usize = 64 * 1024; + +/// A file on disk and the request to upload it with. +pub struct LocalFileUpload { + pub url: url::Url, + pub headers: Vec<(String, String)>, + pub path: PathBuf, + /// The file name sent with a multipart upload, the name of `path` by default. + pub file_name: Option, + /// Sends the file as this multipart form field rather than as the raw request body. + pub multipart_field: Option, +} + +/// The response to an upload, whatever its status. +pub struct UploadResponse { + pub status: u16, + pub body: String, +} + +/// Uploads a local file without reading it into memory, calling `on_progress` with the bytes +/// sent so far and the size of the file. +pub async fn upload_local_file( + upload: LocalFileUpload, + mut on_progress: impl FnMut(u64, u64) + Send + 'static, +) -> Result { + if !matches!(upload.url.scheme(), "http" | "https") { + return Err(ErrorKind::InputError(format!( + "Unsupported upload URL scheme: {}", + upload.url.scheme() + )) + .into()); + } + + let file = tokio::fs::File::open(&upload.path) + .await + .map_err(|e| IOError::with_path(e, &upload.path))?; + let metadata = file + .metadata() + .await + .map_err(|e| IOError::with_path(e, &upload.path))?; + if !metadata.is_file() { + return Err(ErrorKind::InputError(format!( + "{} is not a file", + upload.path.display() + )) + .into()); + } + + let total = metadata.len(); + let mut loaded = 0; + on_progress(loaded, total); + let stream = ReaderStream::with_capacity(file, CHUNK_SIZE).inspect_ok( + move |chunk| { + loaded += chunk.len() as u64; + on_progress(loaded, total); + }, + ); + let body = reqwest::Body::wrap_stream(stream); + + let mut request = INSECURE_NO_TIMEOUT_REQWEST_CLIENT.post(upload.url); + for (name, value) in upload.headers { + request = request.header(name, value); + } + request = match upload.multipart_field { + Some(field) => { + let file_name = upload.file_name.unwrap_or_else(|| { + upload + .path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "file".to_string()) + }); + let part = + Part::stream_with_length(body, total).file_name(file_name); + request.multipart(Form::new().part(field, part)) + } + None => request.header(CONTENT_LENGTH, total).body(body), + }; + + let response = request.send().await?; + let status = response.status().as_u16(); + let body = response.text().await?; + Ok(UploadResponse { status, body }) +} diff --git a/packages/ui/src/components/base/DropzoneFileInput.vue b/packages/ui/src/components/base/DropzoneFileInput.vue index cac41e5e635..a391ed04f68 100644 --- a/packages/ui/src/components/base/DropzoneFileInput.vue +++ b/packages/ui/src/components/base/DropzoneFileInput.vue @@ -1,15 +1,21 @@ diff --git a/packages/ui/src/composables/file-drop.ts b/packages/ui/src/composables/file-drop.ts index f4c2bc1c6f8..90ed341ecf7 100644 --- a/packages/ui/src/composables/file-drop.ts +++ b/packages/ui/src/composables/file-drop.ts @@ -1,3 +1,4 @@ +import type { LocalFile } from '@modrinth/api-client' import type { ComputedRef, Ref } from 'vue' import { computed, onMounted, onUnmounted, ref, unref } from 'vue' @@ -10,6 +11,8 @@ export interface UseFileDropTargetOptions { target: Ref disabled?: MaybeRef onFiles: (files: File[]) => void | Promise + /** Receives native drops as local files instead of reading them into memory, where supported. */ + onLocalFiles?: (files: LocalFile[]) => void | Promise onError?: (error: unknown) => void } @@ -35,6 +38,7 @@ export function useFileDropTarget(options: UseFileDropTargetOptions) { const domDragCounter = ref(0) const domDragging = ref(false) const nativeDragging = ref(false) + const isProcessing = ref(false) const disabled = computed(() => unref(options.disabled) ?? false) const isDragging = computed(() => domDragging.value || nativeDragging.value) @@ -58,7 +62,35 @@ export function useFileDropTarget(options: UseFileDropTargetOptions) { } function canHandleNativeFileDrop(event: NativeFileDropEvent) { - return event.paths.length > 0 && !disabled.value && isPositionOverTarget(event.position) + return ( + event.paths.length > 0 && + !disabled.value && + !isProcessing.value && + isPositionOverTarget(event.position) + ) + } + + async function processFiles( + getFiles: () => T[] | Promise, + onFiles: (files: T[]) => void | Promise, + ) { + isProcessing.value = true + let files: T[] + try { + files = await getFiles() + } catch (error) { + options.onError?.(error) + return + } finally { + isProcessing.value = false + } + + if (files.length === 0) return + try { + await onFiles(files) + } catch (error) { + options.onError?.(error) + } } async function handleNativeFileDrop(event: NativeFileDropEvent) { @@ -77,16 +109,25 @@ export function useFileDropTarget(options: UseFileDropTargetOptions) { nativeDragging.value = false if (!canDrop || !fileDrop) return - try { - const files = await fileDrop.createFilesFromNativePaths(event.paths) - await options.onFiles(files) - } catch (error) { - options.onError?.(error) + const { onLocalFiles } = options + const { resolveLocalFiles } = fileDrop + if (onLocalFiles && resolveLocalFiles) { + await processFiles(() => resolveLocalFiles(event.paths), onLocalFiles) + } else { + await processFiles(() => fileDrop.createFilesFromNativePaths(event.paths), options.onFiles) + } + } + + function rejectProcessingDrag(event: DragEvent) { + event.preventDefault() + if (event.dataTransfer) { + event.dataTransfer.dropEffect = 'none' } } function handleDragEnter(event: DragEvent) { if (disabled.value || !isFileDrag(event)) return + if (isProcessing.value) return rejectProcessingDrag(event) event.preventDefault() domDragCounter.value++ @@ -95,6 +136,7 @@ export function useFileDropTarget(options: UseFileDropTargetOptions) { function handleDragOver(event: DragEvent) { if (disabled.value || !isFileDrag(event)) return + if (isProcessing.value) return rejectProcessingDrag(event) event.preventDefault() if (event.dataTransfer) { @@ -118,16 +160,12 @@ export function useFileDropTarget(options: UseFileDropTargetOptions) { event.preventDefault() resetDomDrag() - if (disabled.value) return + if (disabled.value || isProcessing.value) return const files = getDroppedFiles(event) if (files.length === 0) return - try { - await options.onFiles(files) - } catch (error) { - options.onError?.(error) - } + await processFiles(() => files, options.onFiles) } let nativeFileDropUnlisten: (() => void) | null = null @@ -158,6 +196,7 @@ export function useFileDropTarget(options: UseFileDropTargetOptions) { onUnmounted(() => { unmounted = true nativeDragging.value = false + isProcessing.value = false resetDomDrag() if (nativeFileDropUnlisten) { nativeFileDropUnlisten() @@ -167,6 +206,7 @@ export function useFileDropTarget(options: UseFileDropTargetOptions) { return { isDragging, + isProcessing, dropTargetProps: { onDragenter: handleDragEnter, onDragover: handleDragOver, diff --git a/packages/ui/src/providers/file-download.ts b/packages/ui/src/providers/file-download.ts index e05b9cbaec5..d2b4ba37ce6 100644 --- a/packages/ui/src/providers/file-download.ts +++ b/packages/ui/src/providers/file-download.ts @@ -1,24 +1,16 @@ import { createContext } from './create-context' -interface ServerDownloadBase { - filename: string - serverId: string - serverName: string - nodeUrlHost: string - worldId: string -} - -export interface ServerFileDownload extends ServerDownloadBase { - type: 'server-file' - path: string -} - -export interface ServerBackupDownload extends ServerDownloadBase { - type: 'server-backup' - backupId: string -} - -export type FileDownload = ServerFileDownload | ServerBackupDownload +export type FileDownload = { filename: string; serverId?: string; serverName?: string } & ( + | { type: 'server-file'; serverId: string; nodeUrlHost: string; worldId: string; path: string } + | { + type: 'server-backup' + serverId: string + nodeUrlHost: string + worldId: string + backupId: string + } + | { type: 'server-world'; nodeUrlHost: string; worldId: string } +) export interface FileDownloadProvider { download: (file: FileDownload) => Promise diff --git a/packages/ui/src/providers/file-drop.ts b/packages/ui/src/providers/file-drop.ts index c4343cadd98..a6dc3e7d353 100644 --- a/packages/ui/src/providers/file-drop.ts +++ b/packages/ui/src/providers/file-drop.ts @@ -1,3 +1,5 @@ +import type { LocalFile } from '@modrinth/api-client' + import { createContext } from '.' export type NativeFileDropEvent = { @@ -14,6 +16,8 @@ export interface FileDropProvider { handler: (event: NativeFileDropEvent) => void | Promise, ) => Promise<() => void> createFilesFromNativePaths: (paths: string[]) => Promise + /** Describes dropped paths as local files, for uploads that read them straight from disk. */ + resolveLocalFiles?: (paths: string[]) => Promise } export const [injectFileDrop, provideFileDrop] = createContext('FileDrop')