feat: Implement direct upload functionality for desktop client releases
Desktop Client Build / Resolve Build Metadata (push) Successful in 52s
Frontend CI / Frontend (push) Successful in 3m49s
Backend CI / Backend (push) Successful in 16m10s
Desktop Client Build / Build Desktop Client (push) Successful in 23m22s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 31s
Desktop Client Build / Resolve Build Metadata (push) Successful in 52s
Frontend CI / Frontend (push) Successful in 3m49s
Backend CI / Backend (push) Successful in 16m10s
Desktop Client Build / Build Desktop Client (push) Successful in 23m22s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 31s
- Added new endpoints for initiating and completing direct uploads of desktop client packages. - Introduced request and response structures for direct upload operations. - Enhanced the upload process to support SHA256 and Content-MD5 validation. - Updated the frontend to reflect changes in upload button states and progress indicators. - Modified object storage clients to support presigned PUT URLs with content type and MD5 headers. - Added tests for direct upload functionality and ensured existing upload processes remain intact.
This commit is contained in:
@@ -17,6 +17,7 @@
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.7.9",
|
||||
"dayjs": "^1.11.20",
|
||||
"hash-wasm": "^4.12.0",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "^3.5.31",
|
||||
"vue-router": "^4.5.1"
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import axios from 'axios'
|
||||
import { createMD5, createSHA256 } from 'hash-wasm'
|
||||
|
||||
import { http } from '@/lib/http'
|
||||
|
||||
export type DesktopReleasePlatform = 'darwin' | 'win32' | 'linux'
|
||||
@@ -80,17 +83,27 @@ const DESKTOP_CLIENT_RELEASE_CHUNK_TIMEOUT_MS = 5 * 60 * 1000
|
||||
// complete 阶段服务端需要合并分片、计算 SHA256 并把整个安装包上传到 OSS,远超默认 30s
|
||||
const DESKTOP_CLIENT_RELEASE_COMPLETE_TIMEOUT_MS = 10 * 60 * 1000
|
||||
const DESKTOP_CLIENT_RELEASE_CHUNK_SIZE_BYTES = 16 * 1024 * 1024
|
||||
const DESKTOP_CLIENT_RELEASE_HASH_CHUNK_SIZE_BYTES = 8 * 1024 * 1024
|
||||
const DESKTOP_CLIENT_RELEASE_CHUNK_RETRIES = 2
|
||||
|
||||
export interface DesktopClientReleaseUploadProgress {
|
||||
loaded: number
|
||||
total?: number
|
||||
percent?: number
|
||||
stage?: 'uploading' | 'complete'
|
||||
stage?: 'hashing' | 'signing' | 'uploading' | 'verifying' | 'complete'
|
||||
chunkIndex?: number
|
||||
chunkCount?: number
|
||||
}
|
||||
|
||||
interface DesktopClientReleaseUploadTarget {
|
||||
platform: string
|
||||
arch: string
|
||||
channel: string
|
||||
version: string
|
||||
kind?: 'installer' | 'updater'
|
||||
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
|
||||
}
|
||||
|
||||
interface DesktopClientReleaseUploadSession {
|
||||
upload_id: string
|
||||
chunk_size_bytes: number
|
||||
@@ -99,6 +112,23 @@ interface DesktopClientReleaseUploadSession {
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
interface DesktopClientReleaseDirectUploadSession {
|
||||
upload_url?: string
|
||||
method: 'PUT'
|
||||
headers: Record<string, string>
|
||||
oss_object_key: string
|
||||
file_name: string
|
||||
file_size_bytes: number
|
||||
sha256: string
|
||||
object_exists: boolean
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
interface DesktopClientReleaseFileHashes {
|
||||
sha256: string
|
||||
contentMD5: string
|
||||
}
|
||||
|
||||
export const platformOptions = [
|
||||
{ label: 'macOS', value: 'darwin' },
|
||||
{ label: 'Windows', value: 'win32' },
|
||||
@@ -143,27 +173,13 @@ export const desktopClientReleaseApi = {
|
||||
},
|
||||
async uploadOSS(
|
||||
file: File,
|
||||
target: {
|
||||
platform: string
|
||||
arch: string
|
||||
channel: string
|
||||
version: string
|
||||
kind?: 'installer' | 'updater'
|
||||
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
|
||||
},
|
||||
target: DesktopClientReleaseUploadTarget,
|
||||
) {
|
||||
return uploadOSSInChunks(file, target)
|
||||
return uploadOSSDirect(file, target)
|
||||
},
|
||||
async uploadOSSLegacy(
|
||||
file: File,
|
||||
target: {
|
||||
platform: string
|
||||
arch: string
|
||||
channel: string
|
||||
version: string
|
||||
kind?: 'installer' | 'updater'
|
||||
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
|
||||
},
|
||||
target: DesktopClientReleaseUploadTarget,
|
||||
) {
|
||||
const form = new FormData()
|
||||
form.append('file', file, file.name)
|
||||
@@ -200,16 +216,119 @@ export const desktopClientReleaseApi = {
|
||||
},
|
||||
}
|
||||
|
||||
async function uploadOSSDirect(
|
||||
file: File,
|
||||
target: DesktopClientReleaseUploadTarget,
|
||||
): Promise<DesktopClientReleaseUploadResult> {
|
||||
const hashes = await hashFile(file, (loaded) => {
|
||||
target.onProgress?.({
|
||||
loaded,
|
||||
total: file.size,
|
||||
percent: file.size ? Math.min(99, Math.round((loaded / file.size) * 100)) : undefined,
|
||||
stage: 'hashing',
|
||||
})
|
||||
})
|
||||
|
||||
target.onProgress?.({
|
||||
loaded: file.size,
|
||||
total: file.size,
|
||||
percent: 99,
|
||||
stage: 'signing',
|
||||
})
|
||||
const session = await http.post<DesktopClientReleaseDirectUploadSession>(
|
||||
'/desktop-client/releases/direct-upload',
|
||||
{
|
||||
platform: target.platform,
|
||||
arch: target.arch,
|
||||
channel: target.channel,
|
||||
version: target.version,
|
||||
kind: target.kind ?? 'installer',
|
||||
file_name: file.name,
|
||||
file_size_bytes: file.size,
|
||||
sha256: hashes.sha256,
|
||||
content_md5: hashes.contentMD5,
|
||||
},
|
||||
)
|
||||
|
||||
if (!session.object_exists) {
|
||||
if (!session.upload_url) {
|
||||
throw new Error('missing_direct_upload_url')
|
||||
}
|
||||
await axios.put(session.upload_url, file, {
|
||||
headers: session.headers,
|
||||
timeout: DESKTOP_CLIENT_RELEASE_UPLOAD_TIMEOUT_MS,
|
||||
onUploadProgress(event) {
|
||||
const total = event.total ?? file.size
|
||||
target.onProgress?.({
|
||||
loaded: event.loaded,
|
||||
total,
|
||||
percent: total ? Math.min(99, Math.round((event.loaded / total) * 100)) : undefined,
|
||||
stage: 'uploading',
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
target.onProgress?.({
|
||||
loaded: file.size,
|
||||
total: file.size,
|
||||
percent: 100,
|
||||
stage: 'verifying',
|
||||
})
|
||||
const result = await http.post<DesktopClientReleaseUploadResult>(
|
||||
'/desktop-client/releases/direct-upload/complete',
|
||||
{
|
||||
platform: target.platform,
|
||||
arch: target.arch,
|
||||
channel: target.channel,
|
||||
version: target.version,
|
||||
kind: target.kind ?? 'installer',
|
||||
oss_object_key: session.oss_object_key,
|
||||
file_name: session.file_name,
|
||||
file_size_bytes: session.file_size_bytes,
|
||||
sha256: session.sha256,
|
||||
},
|
||||
)
|
||||
target.onProgress?.({
|
||||
loaded: result.file_size_bytes,
|
||||
total: result.file_size_bytes,
|
||||
percent: 100,
|
||||
stage: 'complete',
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
async function hashFile(
|
||||
file: File,
|
||||
onProgress?: (loaded: number) => void,
|
||||
): Promise<DesktopClientReleaseFileHashes> {
|
||||
const [sha256Hasher, md5Hasher] = await Promise.all([createSHA256(), createMD5()])
|
||||
let loaded = 0
|
||||
while (loaded < file.size) {
|
||||
const end = Math.min(loaded + DESKTOP_CLIENT_RELEASE_HASH_CHUNK_SIZE_BYTES, file.size)
|
||||
const chunk = new Uint8Array(await file.slice(loaded, end).arrayBuffer())
|
||||
sha256Hasher.update(chunk)
|
||||
md5Hasher.update(chunk)
|
||||
loaded = end
|
||||
onProgress?.(loaded)
|
||||
}
|
||||
return {
|
||||
sha256: sha256Hasher.digest('hex') as string,
|
||||
contentMD5: uint8ArrayToBase64(md5Hasher.digest('binary') as Uint8Array),
|
||||
}
|
||||
}
|
||||
|
||||
function uint8ArrayToBase64(value: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
binary += String.fromCharCode(value[index])
|
||||
}
|
||||
return window.btoa(binary)
|
||||
}
|
||||
|
||||
async function uploadOSSInChunks(
|
||||
file: File,
|
||||
target: {
|
||||
platform: string
|
||||
arch: string
|
||||
channel: string
|
||||
version: string
|
||||
kind?: 'installer' | 'updater'
|
||||
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
|
||||
},
|
||||
target: DesktopClientReleaseUploadTarget,
|
||||
): Promise<DesktopClientReleaseUploadResult> {
|
||||
const session = await http.post<DesktopClientReleaseUploadSession>(
|
||||
'/desktop-client/release-uploads',
|
||||
|
||||
@@ -200,7 +200,7 @@
|
||||
:show-upload-list="false"
|
||||
accept=".dmg,.pkg,.exe,.msi,.zip,.AppImage,.appimage,.deb,.rpm,.tar.gz,.tgz"
|
||||
>
|
||||
<a-button :loading="uploading">
|
||||
<a-button :loading="uploadButtonLoading('installer')" :disabled="uploading">
|
||||
<template #icon><UploadOutlined /></template>
|
||||
{{ uploadButtonText('installer') }}
|
||||
</a-button>
|
||||
@@ -266,7 +266,7 @@
|
||||
:show-upload-list="false"
|
||||
accept=".zip,.exe,.AppImage,.appimage"
|
||||
>
|
||||
<a-button :loading="updaterUploading">
|
||||
<a-button :loading="uploadButtonLoading('updater')" :disabled="updaterUploading">
|
||||
<template #icon><UploadOutlined /></template>
|
||||
{{ uploadButtonText('updater') }}
|
||||
</a-button>
|
||||
@@ -352,6 +352,7 @@ import {
|
||||
platformLabel,
|
||||
platformOptions,
|
||||
type DesktopClientRelease,
|
||||
type DesktopClientReleaseUploadProgress,
|
||||
type DesktopReleaseArch,
|
||||
type DesktopReleasePlatform,
|
||||
type DesktopReleaseSource,
|
||||
@@ -400,6 +401,8 @@ const uploading = ref(false)
|
||||
const updaterUploading = ref(false)
|
||||
const uploadProgress = ref<number | null>(null)
|
||||
const updaterUploadProgress = ref<number | null>(null)
|
||||
const uploadStage = ref<DesktopClientReleaseUploadProgress['stage'] | null>(null)
|
||||
const updaterUploadStage = ref<DesktopClientReleaseUploadProgress['stage'] | null>(null)
|
||||
|
||||
const enabledRows = computed(() => rows.value.filter((row) => row.enabled).length)
|
||||
const forceRows = computed(() => rows.value.filter((row) => row.force_update).length)
|
||||
@@ -569,9 +572,11 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat
|
||||
if (kind === 'installer') {
|
||||
uploading.value = true
|
||||
uploadProgress.value = 0
|
||||
uploadStage.value = 'hashing'
|
||||
} else {
|
||||
updaterUploading.value = true
|
||||
updaterUploadProgress.value = 0
|
||||
updaterUploadStage.value = 'hashing'
|
||||
}
|
||||
try {
|
||||
const result = await desktopClientReleaseApi.uploadOSS(file, {
|
||||
@@ -584,8 +589,10 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat
|
||||
if (typeof progress.percent !== 'number') return
|
||||
if (kind === 'installer') {
|
||||
uploadProgress.value = progress.percent
|
||||
uploadStage.value = progress.stage ?? null
|
||||
} else {
|
||||
updaterUploadProgress.value = progress.percent
|
||||
updaterUploadStage.value = progress.stage ?? null
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -611,6 +618,8 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat
|
||||
updaterUploading.value = false
|
||||
uploadProgress.value = null
|
||||
updaterUploadProgress.value = null
|
||||
uploadStage.value = null
|
||||
updaterUploadStage.value = null
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -618,20 +627,33 @@ async function beforeUploadReleasePackage(file: File, kind: 'installer' | 'updat
|
||||
function uploadButtonText(kind: 'installer' | 'updater') {
|
||||
const isUploading = kind === 'installer' ? uploading.value : updaterUploading.value
|
||||
const progress = kind === 'installer' ? uploadProgress.value : updaterUploadProgress.value
|
||||
const stage = kind === 'installer' ? uploadStage.value : updaterUploadStage.value
|
||||
const hasUploaded =
|
||||
kind === 'installer' ? Boolean(form.oss_object_key) : Boolean(form.updater_oss_object_key)
|
||||
if (isUploading && typeof progress === 'number' && progress < 100) {
|
||||
if (isUploading && stage === 'hashing' && typeof progress === 'number') {
|
||||
return `计算 SHA256 ${progress}%`
|
||||
}
|
||||
if (isUploading && stage === 'signing') {
|
||||
return '获取直传地址...'
|
||||
}
|
||||
if (isUploading && stage === 'uploading' && typeof progress === 'number' && progress < 100) {
|
||||
return `上传中 ${progress}%`
|
||||
}
|
||||
if (isUploading && progress === 100) {
|
||||
return '校验并写入 OSS...'
|
||||
if (isUploading && (stage === 'verifying' || progress === 100)) {
|
||||
return '确认上传...'
|
||||
}
|
||||
if (isUploading) {
|
||||
return '写入 OSS...'
|
||||
return '准备上传...'
|
||||
}
|
||||
return hasUploaded ? '重新上传' : '选择文件并上传'
|
||||
}
|
||||
|
||||
function uploadButtonLoading(kind: 'installer' | 'updater') {
|
||||
const isUploading = kind === 'installer' ? uploading.value : updaterUploading.value
|
||||
const progress = kind === 'installer' ? uploadProgress.value : updaterUploadProgress.value
|
||||
return isUploading && progress !== 100
|
||||
}
|
||||
|
||||
function beforeUploadInstallerPackage(file: File) {
|
||||
return beforeUploadReleasePackage(file, 'installer')
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user