Skip to content

Commit 80ac0b3

Browse files
authored
feat: upload and download app-lib util (#8125)
1 parent a4239e9 commit 80ac0b3

30 files changed

Lines changed: 910 additions & 200 deletions

File tree

‎apps/app-frontend/src/App.vue‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
LoadingBar,
3535
NotificationPanel,
3636
PopupNotificationPanel,
37+
provideFileDownload,
3738
provideLoadingState,
3839
provideModalBehavior,
3940
provideModrinthClient,
@@ -64,6 +65,7 @@ import { RouterView, useRoute, useRouter } from 'vue-router'
6465
import { useRouteLoading } from '@/app/runtime/use-route-loading'
6566
import AppSidebar from '@/app/shell/sidebar/index.vue'
6667
import AppTitleBar from '@/app/shell/title-bar/index.vue'
68+
import { trackExternalFileDownload } from '@/components/ui/download-manager/external-file-downloads'
6769
import ErrorModal from '@/components/ui/ErrorModal.vue'
6870
import HostingUpdateRequired from '@/components/ui/HostingUpdateRequired.vue'
6971
import AddServerToInstanceModal from '@/components/ui/install_flow/AddServerToInstanceModal.vue'
@@ -157,6 +159,7 @@ import {
157159
instanceListQueryOptions,
158160
screenshotKeys,
159161
} from '@/pages/instance/query-options'
162+
import { createFileDownload } from '@/platform/adapters/file-download'
160163
import { createAppClient } from '@/platform/modrinth-client'
161164
import {
162165
appUpdateState,
@@ -283,6 +286,7 @@ useAppEvent('ads_consent_required', handleAdsConsentRequired, appEvents)
283286
284287
const tauriApiClient = createAppClient()
285288
provideModrinthClient(tauriApiClient)
289+
provideFileDownload(createFileDownload(tauriApiClient, trackExternalFileDownload))
286290
const { data: authenticatedModrinthUser } = useQuery({
287291
queryKey: computed(() => ['authenticated-user', 'campaigns', credentials.value?.user?.id]),
288292
queryFn: () => tauriApiClient.labrinth.users_v3.getAuthenticated(),

‎apps/app-frontend/src/components/ui/download-manager/external-file-downloads.ts‎

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,14 @@
11
import { reactive, ref } from 'vue'
22

3-
export interface ExternalFileDownloadProgress {
4-
stage: 'waiting' | 'downloading' | 'saving'
5-
downloadedBytes: number
6-
totalBytes: number | null
7-
}
3+
import type { ExternalFileProgress } from '@/platform/app-lib/files/commands'
84

95
export interface ExternalFileDownloadTask {
106
id: string
117
filename: string
128
serverId?: string
139
serverName?: string
1410
status: 'running' | 'succeeded' | 'failed' | 'canceled'
15-
stage: 'preparing' | ExternalFileDownloadProgress['stage']
11+
stage: 'preparing' | ExternalFileProgress['stage']
1612
downloadedBytes: number
1713
totalBytes: number | null
1814
rate: number
@@ -66,7 +62,7 @@ export function trackExternalFileDownload(
6662
}
6763

6864
return {
69-
update(progress: ExternalFileDownloadProgress) {
65+
update(progress: ExternalFileProgress) {
7066
if (task.status !== 'running' || task.canceling) return
7167
const now = performance.now()
7268
if (progress.stage !== 'downloading' || progress.downloadedBytes < previousBytes) {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import type { UnlistenFn } from '@tauri-apps/api/event'
2+
import type { DragDropEvent } from '@tauri-apps/api/webview'
3+
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow'
4+
import { platform } from '@tauri-apps/plugin-os'
5+
6+
export type NativeDragDropEvent =
7+
| { type: 'enter'; paths: string[]; position: { x: number; y: number } }
8+
| { type: 'over'; position: { x: number; y: number } }
9+
| { type: 'drop'; paths: string[]; position: { x: number; y: number } }
10+
| { type: 'leave' }
11+
12+
/**
13+
* Listens for native OS file drag & drop on the main window, with positions in CSS pixels.
14+
*
15+
* This must listen as a `WebviewWindow`. Depending on how the main webview is attached, Tauri emits
16+
* drag events either to `Webview` targets or to `Window` targets, and only a `WebviewWindow`
17+
* listener matches both.
18+
*
19+
* Tauri types the position as physical, but only Windows reports physical pixels. macOS and Linux
20+
* already report logical pixels, so only Windows is scaled down.
21+
*/
22+
export async function listenNativeDragDrop(
23+
handler: (event: NativeDragDropEvent) => void | Promise<void>,
24+
): Promise<UnlistenFn> {
25+
const scale = platform() === 'windows' ? window.devicePixelRatio || 1 : 1
26+
27+
return await getCurrentWebviewWindow().onDragDropEvent((event: { payload: DragDropEvent }) => {
28+
const payload = event.payload
29+
30+
if (payload.type === 'leave') {
31+
void handler({ type: 'leave' })
32+
return
33+
}
34+
35+
const position = { x: payload.position.x / scale, y: payload.position.y / scale }
36+
37+
if (payload.type === 'over') {
38+
void handler({ type: 'over', position })
39+
return
40+
}
41+
42+
void handler({ type: payload.type, paths: payload.paths, position })
43+
})
44+
}
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import {
2+
type AbstractModrinthClient,
3+
type DownloadSink,
4+
getNodeBaseUrl,
5+
ModrinthApiError,
6+
} from '@modrinth/api-client'
7+
import type { FileDownloadProvider } from '@modrinth/ui'
8+
import { Channel } from '@tauri-apps/api/core'
9+
10+
import {
11+
type ExternalFileError,
12+
type ExternalFileProgress,
13+
releaseExternal,
14+
saveExternal,
15+
selectExternal,
16+
} from '@/platform/app-lib/files/commands'
17+
18+
export interface FileDownloadTask {
19+
update: (progress: ExternalFileProgress) => void
20+
succeed: (filename: string) => void
21+
fail: (error: string) => void
22+
canceled: () => void
23+
}
24+
25+
/** Registers a running download so the app can display its progress and cancel it. */
26+
export type TrackFileDownload = (
27+
saveId: string,
28+
filename: string,
29+
onCancel: () => Promise<void>,
30+
server: { serverId?: string; serverName?: string },
31+
) => FileDownloadTask
32+
33+
export function createFileDownload(
34+
client: AbstractModrinthClient,
35+
trackDownload: TrackFileDownload,
36+
): FileDownloadProvider {
37+
return {
38+
async download(file) {
39+
const saveId = await selectExternal(
40+
file.filename,
41+
file.type === 'server-backup' || file.type === 'server-world'
42+
? { name: 'ZIP archive', extensions: ['zip'] }
43+
: null,
44+
).catch((error: ExternalFileError) => {
45+
throw new Error(error.message ?? String(error))
46+
})
47+
if (!saveId) return false
48+
49+
let cancelled = false
50+
let finished = false
51+
let savedFilename = file.filename
52+
const task = trackDownload(
53+
saveId,
54+
file.filename,
55+
async () => {
56+
cancelled = true
57+
await releaseExternal(saveId)
58+
},
59+
{ serverId: file.serverId, serverName: file.serverName },
60+
)
61+
const onProgress = new Channel<ExternalFileProgress>((progress) => {
62+
if (cancelled || finished) return
63+
task.update(progress)
64+
})
65+
66+
const saveUrl = async (url: string, headers: Record<string, string> = {}) => {
67+
if (cancelled) throw new Error('Download cancelled')
68+
try {
69+
savedFilename = await saveExternal(saveId, { url, headers }, onProgress)
70+
} catch (error) {
71+
const nativeError = error as ExternalFileError
72+
throw new ModrinthApiError(nativeError.message ?? String(error), {
73+
statusCode: nativeError.statusCode,
74+
})
75+
}
76+
}
77+
const sink: DownloadSink = (url, options) => saveUrl(url, options.headers)
78+
79+
try {
80+
switch (file.type) {
81+
case 'server-file': {
82+
const { token } = await client.kyros.files_v1.authorizeFileDownload(
83+
file.nodeUrlHost,
84+
file.worldId,
85+
file.path,
86+
)
87+
await saveUrl(
88+
client.kyros.files_v1.getFileDownloadUrl(file.nodeUrlHost, file.worldId, token),
89+
)
90+
break
91+
}
92+
case 'server-world': {
93+
const { token } = await client.kyros.files_v1.authorizeFullWorldDownload(
94+
file.nodeUrlHost,
95+
file.worldId,
96+
)
97+
await client.download(
98+
`/worlds/${encodeURIComponent(file.worldId)}/files/download-full-zip`,
99+
{
100+
api: getNodeBaseUrl(file.nodeUrlHost),
101+
version: 'v1',
102+
params: { token },
103+
skipAuth: true,
104+
},
105+
sink,
106+
)
107+
break
108+
}
109+
case 'server-backup': {
110+
const { token } = await client.kyros.backups_v1.authorizeDownload(
111+
file.nodeUrlHost,
112+
file.worldId,
113+
file.backupId,
114+
)
115+
await saveUrl(
116+
client.kyros.backups_v1.getDownloadUrl(
117+
file.nodeUrlHost,
118+
file.worldId,
119+
file.backupId,
120+
token,
121+
),
122+
)
123+
break
124+
}
125+
}
126+
task.succeed(savedFilename)
127+
return true
128+
} catch (error) {
129+
if (cancelled) {
130+
task.canceled()
131+
return false
132+
}
133+
task.fail(error instanceof Error ? error.message : String(error))
134+
throw error
135+
} finally {
136+
finished = true
137+
await releaseExternal(saveId).catch(() => undefined)
138+
}
139+
},
140+
}
141+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { type LocalFileUploader, ModrinthApiError } from '@modrinth/api-client'
2+
import { Channel } from '@tauri-apps/api/core'
3+
4+
import {
5+
cancelLocalUpload,
6+
type LocalUploadProgress,
7+
uploadLocal,
8+
} from '@/platform/app-lib/files/commands'
9+
10+
/** Uploads local files from the Rust side, streaming them from disk. */
11+
export function createLocalFileUploader(): LocalFileUploader {
12+
return async ({ url, headers, file, multipartField, signal, onProgress }) => {
13+
const uploadId = crypto.randomUUID()
14+
const cancel = () => void cancelLocalUpload(uploadId).catch(() => undefined)
15+
signal.addEventListener('abort', cancel)
16+
try {
17+
return await uploadLocal(
18+
uploadId,
19+
{ url, headers, path: file.path, fileName: file.name, multipartField },
20+
new Channel<LocalUploadProgress>(({ loaded, total }) =>
21+
onProgress({ loaded, total, progress: total > 0 ? loaded / total : 1 }),
22+
),
23+
)
24+
} catch (error) {
25+
throw new ModrinthApiError((error as { message?: string }).message ?? String(error))
26+
} finally {
27+
signal.removeEventListener('abort', cancel)
28+
}
29+
}
30+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import { type Channel, invoke } from '@tauri-apps/api/core'
2+
3+
export interface ExternalFileFilter {
4+
name: string
5+
extensions: string[]
6+
}
7+
8+
export interface ExternalFileRequest {
9+
url: string
10+
headers: Record<string, string>
11+
}
12+
13+
export interface ExternalFileProgress {
14+
stage: 'waiting' | 'downloading' | 'saving'
15+
downloadedBytes: number
16+
totalBytes: number | null
17+
}
18+
19+
export interface ExternalFileError {
20+
message?: string
21+
statusCode?: number
22+
}
23+
24+
export function selectExternal(
25+
filename: string,
26+
filter: ExternalFileFilter | null,
27+
): Promise<string | null> {
28+
return invoke('plugin:files|files_select_external', { filename, filter })
29+
}
30+
31+
export function saveExternal(
32+
saveId: string,
33+
request: ExternalFileRequest,
34+
onProgress: Channel<ExternalFileProgress>,
35+
): Promise<string> {
36+
return invoke('plugin:files|files_save_external', { saveId, request, onProgress })
37+
}
38+
39+
export function releaseExternal(saveId: string): Promise<void> {
40+
return invoke('plugin:files|files_release_external', { saveId })
41+
}
42+
43+
export interface LocalFileInfo {
44+
name: string
45+
size: number
46+
}
47+
48+
export interface LocalUploadRequest {
49+
url: string
50+
headers: Record<string, string>
51+
path: string
52+
fileName?: string
53+
multipartField?: string
54+
}
55+
56+
export interface LocalUploadProgress {
57+
loaded: number
58+
total: number
59+
}
60+
61+
export interface LocalUploadResponse {
62+
status: number
63+
body: string
64+
}
65+
66+
export function getLocalFileInfo(path: string): Promise<LocalFileInfo> {
67+
return invoke('plugin:files|file_local_info', { path })
68+
}
69+
70+
export function uploadLocal(
71+
uploadId: string,
72+
request: LocalUploadRequest,
73+
onProgress: Channel<LocalUploadProgress>,
74+
): Promise<LocalUploadResponse> {
75+
return invoke('plugin:files|file_upload_local', { uploadId, request, onProgress })
76+
}
77+
78+
export function cancelLocalUpload(uploadId: string): Promise<void> {
79+
return invoke('plugin:files|file_cancel_local_upload', { uploadId })
80+
}

‎apps/app-frontend/src/platform/modrinth-client.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { getVersion } from '@tauri-apps/api/app'
1010

1111
import { config } from '@/config'
1212
import { get as getCreds } from '@/helpers/mr_auth'
13+
import { createLocalFileUploader } from '@/platform/adapters/local-file-upload'
1314

1415
export function createAppClient(): TauriModrinthClient {
1516
const appVersion = getVersion()
@@ -19,6 +20,7 @@ export function createAppClient(): TauriModrinthClient {
1920
labrinthBaseUrl: config.labrinthBaseUrl,
2021
archonBaseUrl: config.archonBaseUrl,
2122
sharedInstancesBaseUrl: config.sharedInstancesBaseUrl,
23+
localFileUploader: createLocalFileUploader(),
2224
features: [
2325
new NodeAuthFeature({
2426
getAuth: () => nodeAuthState.getAuth?.() ?? null,

‎apps/app-frontend/src/providers/setup.ts‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import type { InstanceIconConfig } from '@/helpers/types'
66
import type { AppEvents } from './app-events'
77
import { setupOnboardingChecklistProvider } from './onboarding-checklist'
88
import { setupCreationModal } from './setup/creation-modal'
9-
import { setupFileDownloadProvider } from './setup/file-download'
109
import { setupFileDropProvider } from './setup/file-drop'
1110
import { setupFilePickerProvider } from './setup/file-picker'
1211
import { setupImageViewerEditorProvider } from './setup/image-viewer-editor'
@@ -25,7 +24,6 @@ export function setupProviders(
2524
const tags = setupTagsProvider(notificationManager)
2625
setupFileDropProvider()
2726
setupFilePickerProvider()
28-
setupFileDownloadProvider(client)
2927
setupImageViewerEditorProvider()
3028
setupInstanceImportProvider(notificationManager)
3129
const onboardingChecklist = setupOnboardingChecklistProvider(appEvents)

0 commit comments

Comments
 (0)