Skip to content
Closed
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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -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,
),
Expand Down Expand Up @@ -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"
>
<component
:is="job.kind === 'debug-export' ? DownloadIcon : BoxIcon"
:is="
job.kind === 'external-file' || job.kind === 'debug-export' ? DownloadIcon : BoxIcon
"
class="size-6 text-primary"
aria-hidden="true"
/>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<void>
}

export const externalFileDownloads = ref(new Map<string, ExternalFileDownloadTask>())

export function trackExternalFileDownload(
id: string,
filename: string,
onCancel: () => Promise<void>,
server: { serverId?: string; serverName?: string } = {},
) {
const task = reactive<ExternalFileDownloadTask>({
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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
])

Expand All @@ -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),
),
)

Expand Down Expand Up @@ -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<typeof storeVerificationTask.value>,
): DownloadManagerJob {
Expand Down Expand Up @@ -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))
}
Expand All @@ -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
Expand All @@ -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
Expand Down
9 changes: 9 additions & 0 deletions apps/app-frontend/src/locales/en-US/index.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
2 changes: 2 additions & 0 deletions apps/app-frontend/src/providers/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -24,6 +25,7 @@ export function setupProviders(
const tags = setupTagsProvider(notificationManager)
setupFileDropProvider()
setupFilePickerProvider()
setupFileDownloadProvider(client)
setupImageViewerEditorProvider()
setupInstanceImportProvider(notificationManager)
const onboardingChecklist = setupOnboardingChecklistProvider(appEvents)
Expand Down
Loading
Loading