desktop-release.mjs 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
  1. import { createHash } from 'node:crypto'
  2. import { execFileSync } from 'node:child_process'
  3. import { createReadStream } from 'node:fs'
  4. import { readFile, stat, writeFile } from 'node:fs/promises'
  5. import { basename, join, resolve } from 'node:path'
  6. import { pathToFileURL } from 'node:url'
  7. import { load } from 'js-yaml'
  8. export function releaseSigningOptions(env = process.env) {
  9. if (!env.CSC_NAME?.startsWith('Developer ID Application:'))
  10. throw new Error('正式自动更新包需要 CSC_NAME 指定 Developer ID Application 签名身份')
  11. const apiKey = env.APPLE_API_KEY && env.APPLE_API_KEY_ID && env.APPLE_API_ISSUER
  12. const appleId = env.APPLE_ID && env.APPLE_APP_SPECIFIC_PASSWORD && env.APPLE_TEAM_ID
  13. const keychainProfile = env.APPLE_KEYCHAIN_PROFILE?.trim()
  14. if (!apiKey && !appleId && !keychainProfile) throw new Error('请配置 Apple 公证凭据:APPLE_KEYCHAIN_PROFILE,APPLE_API_KEY/APPLE_API_KEY_ID/APPLE_API_ISSUER,或 APPLE_ID/APPLE_APP_SPECIFIC_PASSWORD/APPLE_TEAM_ID')
  15. // electron-builder selects the certificate type itself and rejects its prefix
  16. // in the identity qualifier. Enforce a distribution identity, then strip it.
  17. const identity = env.CSC_NAME.slice('Developer ID Application:'.length).trim()
  18. if (!identity) throw new Error('CSC_NAME 缺少分发签名身份名称')
  19. return { forceCodeSigning: true, mac: { identity, type: 'distribution', notarize: true } }
  20. }
  21. async function digest(file, algorithm, encoding) {
  22. const hash = createHash(algorithm)
  23. for await (const chunk of createReadStream(file)) hash.update(chunk)
  24. return hash.digest(encoding)
  25. }
  26. export async function verifyUpdateArtifacts(directory, version, arch = 'arm64') {
  27. const metadata = load(await readFile(join(directory, 'latest-mac.yml'), 'utf8'))
  28. if (metadata?.version !== version || !Array.isArray(metadata.files)) throw new Error('自动更新清单版本无效')
  29. const required = [`Yaoyao-${version}-${arch}.zip`, `Yaoyao-${version}-${arch}.dmg`]
  30. for (const name of required) {
  31. const matches = metadata.files.filter(file => file.url === name)
  32. if (matches.length !== 1) throw new Error(`自动更新清单缺少唯一附件:${name}`)
  33. }
  34. const names = ['latest-mac.yml']
  35. for (const file of metadata.files) {
  36. if (typeof file.url !== 'string' || basename(file.url) !== file.url || !required.includes(file.url)) throw new Error('自动更新清单包含非本次发布的附件')
  37. const fullPath = join(directory, file.url), info = await stat(fullPath)
  38. if (file.size !== info.size || !info.size || !info.isFile()) throw new Error(`安装包大小无效:${file.url}`)
  39. if (file.sha512 !== await digest(fullPath, 'sha512', 'base64')) throw new Error(`安装包 SHA-512 不匹配:${file.url}`)
  40. names.push(file.url)
  41. const blockmap = `${file.url}.blockmap`
  42. if (!(await stat(join(directory, blockmap))).size) throw new Error(`差分更新文件为空:${blockmap}`)
  43. names.push(blockmap)
  44. }
  45. return names
  46. }
  47. export async function packageDesktopRelease() {
  48. const signing = releaseSigningOptions()
  49. const root = resolve(import.meta.dirname, '..')
  50. const { version } = JSON.parse(await readFile(join(root, 'package.json'), 'utf8'))
  51. const { build, Platform, Arch } = await import('electron-builder')
  52. await build({ projectDir: root, targets: Platform.MAC.createTarget(['dmg', 'zip'], Arch.arm64),
  53. publish: 'never', config: signing })
  54. const directory = join(root, 'desktop-release')
  55. const names = await verifyUpdateArtifacts(directory, version)
  56. const app = join(directory, 'mac-arm64', '夭夭完整版.app')
  57. const feed = load(await readFile(join(app, 'Contents/Resources/app-update.yml'), 'utf8'))
  58. if (feed.provider !== 'github' || feed.owner !== 'Lsamien' || feed.repo !== 'yaoyao') throw new Error('安装包中的更新源不正确')
  59. execFileSync('codesign', ['--verify', '--deep', '--strict', app], { stdio: 'inherit' })
  60. execFileSync('xcrun', ['stapler', 'validate', app], { stdio: 'inherit' })
  61. const sums = await Promise.all(names.map(async name => `${await digest(join(directory, name), 'sha256', 'hex')} ${name}`))
  62. await writeFile(join(directory, 'SHA256SUMS.txt'), sums.join('\n') + '\n')
  63. console.log(`正式桌面产物已验证:${[...names, 'SHA256SUMS.txt'].join('、')}。尚未上传发布。`)
  64. }
  65. if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) await packageDesktopRelease()