import axios from 'axios' import { createSHA256 } from 'hash-wasm' import { OpsApiError, 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 const DIRECT_UPLOAD_NOT_FOUND_ERROR_CODE = 40078 const DIRECT_UPLOAD_NOT_FOUND_ERROR_MESSAGE = 'direct_upload_not_found' export interface DesktopClientReleaseUploadProgress { loaded: number total?: number percent?: number stage?: 'hashing' | 'signing' | 'uploading' | 'verifying' | 'complete' message?: string 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 oss_object_key: string file_name: string file_size_bytes: number sha256: string object_exists: boolean expires_at: string } interface DesktopClientReleaseFileHashes { sha256: string } interface DesktopClientReleaseCompleteJob { job_id: string status: 'running' | 'succeeded' | 'failed' stage: string message: string percent: number result?: DesktopClientReleaseUploadResult error_code?: string error?: string created_at: string updated_at: 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('/desktop-client/releases', params) }, create(payload: DesktopClientReleasePayload) { return http.post( '/desktop-client/releases', payload, ) }, update(id: number, payload: DesktopClientReleasePayload) { return http.patch( `/desktop-client/releases/${id}`, payload, ) }, setEnabled(id: number, enabled: boolean) { return http.post( `/desktop-client/releases/${id}/enabled`, { enabled }, ) }, async uploadOSS( file: File, target: DesktopClientReleaseUploadTarget, ) { try { return await uploadOSSDirect(file, target) } catch (error) { if (!shouldFallbackToChunkedUpload(error)) { throw error } target.onProgress?.({ loaded: 0, total: file.size, percent: 0, stage: 'uploading', message: '直传未完成,已切换为后端分片上传', }) return uploadOSSInChunks(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( `/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 { 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( '/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, }, ) if (!session.object_exists) { if (!session.upload_url) { throw new Error('missing_direct_upload_url') } try { await axios.put(session.upload_url, file, { headers: directUploadHeaders(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', }) }, }) } catch (uploadError) { try { return await completeDirectUpload(session, target) } catch (completeError) { if (isDirectUploadNotFoundError(completeError)) { throw uploadError } throw completeError } } } return completeDirectUpload(session, target) } async function completeDirectUpload( session: DesktopClientReleaseDirectUploadSession, target: DesktopClientReleaseUploadTarget, ): Promise { const job = await http.post( '/desktop-client/releases/direct-upload/complete-jobs', { 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, }, ) return pollCompleteJob(job.job_id, session.file_size_bytes, target) } function isDirectUploadNotFoundError(error: unknown): boolean { return ( error instanceof OpsApiError && (error.code === DIRECT_UPLOAD_NOT_FOUND_ERROR_CODE || error.message === DIRECT_UPLOAD_NOT_FOUND_ERROR_MESSAGE) ) } function directUploadHeaders(headers: Record | undefined): Record { if (!headers) return {} return Object.fromEntries( Object.entries(headers).filter(([key]) => key.toLowerCase() !== 'content-md5'), ) } function shouldFallbackToChunkedUpload(error: unknown): boolean { if (axios.isAxiosError(error)) return true if (isDirectUploadNotFoundError(error)) return true return error instanceof Error && error.message === 'missing_direct_upload_url' } async function pollCompleteJob( jobID: string, totalBytes: number, target: DesktopClientReleaseUploadTarget, chunkCount?: number, ): Promise { for (;;) { const job = await http.get( `/desktop-client/release-upload-complete-jobs/${jobID}`, ) target.onProgress?.({ loaded: totalBytes, total: totalBytes, percent: Math.min(99, Math.max(1, job.percent)), stage: 'verifying', message: job.message, chunkCount, }) if (job.status === 'succeeded' && job.result) { target.onProgress?.({ loaded: job.result.file_size_bytes, total: job.result.file_size_bytes, percent: 100, stage: 'complete', message: job.message || '上传完成', chunkCount, }) return job.result } if (job.status === 'failed') { throw new OpsApiError({ message: job.error_code || 'desktop_client_release_upload_failed', code: job.error_code === DIRECT_UPLOAD_NOT_FOUND_ERROR_MESSAGE ? DIRECT_UPLOAD_NOT_FOUND_ERROR_CODE : undefined, detail: job.error || job.message || '上传确认失败', }) } await delay(1000) } } async function hashFile( file: File, onProgress?: (loaded: number) => void, ): Promise { const sha256Hasher = await createSHA256() 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) loaded = end onProgress?.(loaded) } return { sha256: sha256Hasher.digest('hex') as string, } } async function uploadOSSInChunks( file: File, target: DesktopClientReleaseUploadTarget, ): Promise { const session = await http.post( '/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) } const job = await http.post( `/desktop-client/release-uploads/${session.upload_id}/complete-jobs`, undefined, { timeout: DESKTOP_CLIENT_RELEASE_COMPLETE_TIMEOUT_MS }, ) return await pollCompleteJob(job.job_id, file.size, target, chunkCount) } 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 }