Files
geo/apps/ops-web/src/lib/desktop-client-releases.ts
T
root 62d0b22988
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
feat: Implement direct upload functionality for desktop client releases
- 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.
2026-06-20 12:44:28 +08:00

477 lines
14 KiB
TypeScript

import axios from 'axios'
import { createMD5, createSHA256 } from 'hash-wasm'
import { http } from '@/lib/http'
export type DesktopReleasePlatform = 'darwin' | 'win32' | 'linux'
export type DesktopReleaseArch = 'universal' | 'x64' | 'arm64' | 'ia32'
export type DesktopReleaseSource = 'oss' | 'custom'
export interface DesktopClientRelease {
id: number
platform: DesktopReleasePlatform
arch: DesktopReleaseArch
channel: string
version: string
min_supported_version?: string | null
download_source: DesktopReleaseSource
oss_object_key?: string | null
custom_download_url?: string | null
download_url?: string | null
file_name?: string | null
file_size_bytes?: number | null
sha256?: string | null
updater_download_source?: DesktopReleaseSource | null
updater_oss_object_key?: string | null
updater_custom_download_url?: string | null
updater_download_url?: string | null
updater_file_name?: string | null
updater_file_size_bytes?: number | null
updater_sha256?: string | null
release_notes?: string | null
force_update: boolean
enabled: boolean
published_at?: string | null
created_at: string
updated_at: string
}
export interface DesktopClientReleasePayload {
platform: DesktopReleasePlatform
arch: DesktopReleaseArch
channel: string
version: string
min_supported_version?: string | null
download_source: DesktopReleaseSource
oss_object_key?: string | null
custom_download_url?: string | null
file_name?: string | null
file_size_bytes?: number | null
sha256?: string | null
updater_download_source?: DesktopReleaseSource | null
updater_oss_object_key?: string | null
updater_custom_download_url?: string | null
updater_file_name?: string | null
updater_file_size_bytes?: number | null
updater_sha256?: string | null
release_notes?: string | null
force_update: boolean
enabled: boolean
}
export interface DesktopClientReleaseList {
items: DesktopClientRelease[]
total: number
page: number
size: number
}
export interface DesktopClientReleaseUploadResult {
oss_object_key: string
file_name: string
file_size_bytes: number
sha256: string
}
export interface DesktopClientReleaseDownloadURLResult {
download_url: string
expires_in?: number | null
}
const DESKTOP_CLIENT_RELEASE_UPLOAD_TIMEOUT_MS = 30 * 60 * 1000
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?: '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
chunk_count: number
uploaded_chunks: number[]
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' },
{ label: 'Linux', value: 'linux' },
]
export const archOptions = [
{ label: 'Universal', value: 'universal' },
{ label: 'x64', value: 'x64' },
{ label: 'arm64', value: 'arm64' },
{ label: 'ia32', value: 'ia32' },
]
export const desktopClientReleaseApi = {
list(params: {
keyword?: string
platform?: string
channel?: string
enabled?: string
page?: number
size?: number
}) {
return http.get<DesktopClientReleaseList>('/desktop-client/releases', params)
},
create(payload: DesktopClientReleasePayload) {
return http.post<DesktopClientRelease, DesktopClientReleasePayload>(
'/desktop-client/releases',
payload,
)
},
update(id: number, payload: DesktopClientReleasePayload) {
return http.patch<DesktopClientRelease, DesktopClientReleasePayload>(
`/desktop-client/releases/${id}`,
payload,
)
},
setEnabled(id: number, enabled: boolean) {
return http.post<DesktopClientRelease, { enabled: boolean }>(
`/desktop-client/releases/${id}/enabled`,
{ enabled },
)
},
async uploadOSS(
file: File,
target: DesktopClientReleaseUploadTarget,
) {
return uploadOSSDirect(file, target)
},
async uploadOSSLegacy(
file: File,
target: DesktopClientReleaseUploadTarget,
) {
const form = new FormData()
form.append('file', file, file.name)
form.append('platform', target.platform)
form.append('arch', target.arch)
form.append('channel', target.channel)
form.append('version', target.version)
form.append('kind', target.kind ?? 'installer')
const response = await http.raw.post<{
code: number
message: string
data: DesktopClientReleaseUploadResult
}>('/desktop-client/releases/upload', form, {
timeout: DESKTOP_CLIENT_RELEASE_UPLOAD_TIMEOUT_MS,
onUploadProgress(event) {
const total = event.total
target.onProgress?.({
loaded: event.loaded,
total,
percent: total ? Math.min(100, Math.round((event.loaded / total) * 100)) : undefined,
stage: 'uploading',
})
},
})
return response.data.data
},
resolveDownloadURL(id: number) {
return http.get<DesktopClientReleaseDownloadURLResult>(
`/desktop-client/releases/${id}/download-url`,
)
},
delete(id: number) {
return http.delete<{ id: number }>(`/desktop-client/releases/${id}`)
},
}
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: DesktopClientReleaseUploadTarget,
): Promise<DesktopClientReleaseUploadResult> {
const session = await http.post<DesktopClientReleaseUploadSession>(
'/desktop-client/release-uploads',
{
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,
chunk_size_bytes: DESKTOP_CLIENT_RELEASE_CHUNK_SIZE_BYTES,
},
)
const chunkSize = session.chunk_size_bytes || DESKTOP_CLIENT_RELEASE_CHUNK_SIZE_BYTES
const chunkCount = session.chunk_count || Math.ceil(file.size / chunkSize)
const uploaded = new Set(session.uploaded_chunks ?? [])
let confirmedBytes = 0
for (const index of uploaded) {
confirmedBytes += chunkByteSize(file.size, chunkSize, index, chunkCount)
}
emitChunkProgress(target, confirmedBytes, file.size, chunkCount)
try {
for (let index = 0; index < chunkCount; index += 1) {
if (uploaded.has(index)) continue
await uploadChunkWithRetry(
file,
session.upload_id,
index,
chunkSize,
chunkCount,
confirmedBytes,
target,
)
confirmedBytes += chunkByteSize(file.size, chunkSize, index, chunkCount)
emitChunkProgress(target, confirmedBytes, file.size, chunkCount, index)
}
target.onProgress?.({
loaded: file.size,
total: file.size,
percent: 100,
stage: 'complete',
chunkCount,
})
return await http.post<DesktopClientReleaseUploadResult>(
`/desktop-client/release-uploads/${session.upload_id}/complete`,
undefined,
{ timeout: DESKTOP_CLIENT_RELEASE_COMPLETE_TIMEOUT_MS },
)
} catch (error) {
void http.delete(`/desktop-client/release-uploads/${session.upload_id}`).catch(() => undefined)
throw error
}
}
async function uploadChunkWithRetry(
file: File,
uploadID: string,
index: number,
chunkSize: number,
chunkCount: number,
confirmedBytes: number,
target: {
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
},
) {
let lastError: unknown
for (let attempt = 0; attempt <= DESKTOP_CLIENT_RELEASE_CHUNK_RETRIES; attempt += 1) {
try {
await uploadChunk(file, uploadID, index, chunkSize, chunkCount, confirmedBytes, target)
return
} catch (error) {
lastError = error
if (attempt === DESKTOP_CLIENT_RELEASE_CHUNK_RETRIES) break
await delay(400 * (attempt + 1))
}
}
throw lastError
}
async function uploadChunk(
file: File,
uploadID: string,
index: number,
chunkSize: number,
chunkCount: number,
confirmedBytes: number,
target: {
onProgress?: (progress: DesktopClientReleaseUploadProgress) => void
},
) {
const start = index * chunkSize
const end = Math.min(start + chunkSize, file.size)
const chunk = file.slice(start, end)
const form = new FormData()
form.append('file', chunk, file.name)
await http.raw.post(`/desktop-client/release-uploads/${uploadID}/chunks/${index}`, form, {
timeout: DESKTOP_CLIENT_RELEASE_CHUNK_TIMEOUT_MS,
onUploadProgress(event) {
const loaded = confirmedBytes + Math.min(event.loaded, chunk.size)
target.onProgress?.({
loaded,
total: file.size,
percent: file.size ? Math.min(99, Math.round((loaded / file.size) * 100)) : undefined,
stage: 'uploading',
chunkIndex: index,
chunkCount,
})
},
})
}
function emitChunkProgress(
target: { onProgress?: (progress: DesktopClientReleaseUploadProgress) => void },
loaded: number,
total: number,
chunkCount: number,
chunkIndex?: number,
) {
target.onProgress?.({
loaded,
total,
percent: total ? Math.min(99, Math.round((loaded / total) * 100)) : undefined,
stage: 'uploading',
chunkIndex,
chunkCount,
})
}
function chunkByteSize(fileSize: number, chunkSize: number, index: number, chunkCount: number) {
if (index < 0 || index >= chunkCount) return 0
const start = index * chunkSize
return Math.max(0, Math.min(chunkSize, fileSize - start))
}
function delay(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms))
}
export function platformLabel(value: string): string {
return platformOptions.find((item) => item.value === value)?.label ?? value
}