desktop-release.mjs 4.2 KB

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