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 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('/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, ) { 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( `/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, 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( '/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 { 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 { 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) } target.onProgress?.({ loaded: file.size, total: file.size, percent: 100, stage: 'complete', chunkCount, }) return await http.post( `/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 }