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

- 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:
2026-06-20 12:44:28 +08:00
parent f26802c76e
commit 62d0b22988
15 changed files with 884 additions and 58 deletions
+1
View File
@@ -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"
+145 -26
View File
@@ -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')
}