Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/app-frontend/src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
LoadingBar,
NotificationPanel,
PopupNotificationPanel,
provideFileDownload,
provideLoadingState,
provideModalBehavior,
provideModrinthClient,
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
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
filename: string
serverId?: string
serverName?: string
status: 'running' | 'succeeded' | 'failed' | 'canceled'
stage: 'preparing' | ExternalFileDownloadProgress['stage']
stage: 'preparing' | ExternalFileProgress['stage']
downloadedBytes: number
totalBytes: number | null
rate: number
Expand Down Expand Up @@ -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) {
Expand Down
44 changes: 44 additions & 0 deletions apps/app-frontend/src/helpers/native-drag-drop.ts
Original file line number Diff line number Diff line change
@@ -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<void>,
): Promise<UnlistenFn> {
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 })
})
}
141 changes: 141 additions & 0 deletions apps/app-frontend/src/platform/adapters/file-download.ts
Original file line number Diff line number Diff line change
@@ -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<void>,
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<ExternalFileProgress>((progress) => {
if (cancelled || finished) return
task.update(progress)
})

const saveUrl = async (url: string, headers: Record<string, string> = {}) => {
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)
}
},
}
}
30 changes: 30 additions & 0 deletions apps/app-frontend/src/platform/adapters/local-file-upload.ts
Original file line number Diff line number Diff line change
@@ -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<LocalUploadProgress>(({ 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)
}
}
}
80 changes: 80 additions & 0 deletions apps/app-frontend/src/platform/app-lib/files/commands.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
}

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<string | null> {
return invoke('plugin:files|files_select_external', { filename, filter })
}

export function saveExternal(
saveId: string,
request: ExternalFileRequest,
onProgress: Channel<ExternalFileProgress>,
): Promise<string> {
return invoke('plugin:files|files_save_external', { saveId, request, onProgress })
}

export function releaseExternal(saveId: string): Promise<void> {
return invoke('plugin:files|files_release_external', { saveId })
}

export interface LocalFileInfo {
name: string
size: number
}

export interface LocalUploadRequest {
url: string
headers: Record<string, string>
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<LocalFileInfo> {
return invoke('plugin:files|file_local_info', { path })
}

export function uploadLocal(
uploadId: string,
request: LocalUploadRequest,
onProgress: Channel<LocalUploadProgress>,
): Promise<LocalUploadResponse> {
return invoke('plugin:files|file_upload_local', { uploadId, request, onProgress })
}

export function cancelLocalUpload(uploadId: string): Promise<void> {
return invoke('plugin:files|file_cancel_local_upload', { uploadId })
}
2 changes: 2 additions & 0 deletions apps/app-frontend/src/platform/modrinth-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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,
Expand Down
2 changes: 0 additions & 2 deletions apps/app-frontend/src/providers/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -25,7 +24,6 @@ export function setupProviders(
const tags = setupTagsProvider(notificationManager)
setupFileDropProvider()
setupFilePickerProvider()
setupFileDownloadProvider(client)
setupImageViewerEditorProvider()
setupInstanceImportProvider(notificationManager)
const onboardingChecklist = setupOnboardingChecklistProvider(appEvents)
Expand Down
Loading
Loading