547 lines
16 KiB
TypeScript
547 lines
16 KiB
TypeScript
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
|
|
|
|
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<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
|
|
}
|
|
|
|
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<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,
|
|
) {
|
|
try {
|
|
return await uploadOSSDirect(file, target)
|
|
} catch (error) {
|
|
if (!shouldFallbackToChunkedUpload(error)) {
|
|
throw error
|
|
}
|
|
target.onProgress?.({
|
|
loaded: 0,
|
|
total: file.size,
|
|
percent: 0,
|
|
stage: 'uploading',
|
|
})
|
|
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<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,
|
|
},
|
|
)
|
|
|
|
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<DesktopClientReleaseUploadResult> {
|
|
const job = await http.post<DesktopClientReleaseCompleteJob>(
|
|
'/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 === 40078
|
|
}
|
|
|
|
function directUploadHeaders(headers: Record<string, string> | undefined): Record<string, string> {
|
|
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
|
|
return error instanceof Error && error.message === 'missing_direct_upload_url'
|
|
}
|
|
|
|
async function pollCompleteJob(
|
|
jobID: string,
|
|
totalBytes: number,
|
|
target: DesktopClientReleaseUploadTarget,
|
|
chunkCount?: number,
|
|
): Promise<DesktopClientReleaseUploadResult> {
|
|
for (;;) {
|
|
const job = await http.get<DesktopClientReleaseCompleteJob>(
|
|
`/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',
|
|
detail: job.error || job.message || '上传确认失败',
|
|
})
|
|
}
|
|
await delay(1000)
|
|
}
|
|
}
|
|
|
|
async function hashFile(
|
|
file: File,
|
|
onProgress?: (loaded: number) => void,
|
|
): Promise<DesktopClientReleaseFileHashes> {
|
|
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<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)
|
|
}
|
|
|
|
const job = await http.post<DesktopClientReleaseCompleteJob>(
|
|
`/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
|
|
}
|