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:
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user