Files
RemoteDesk/packaging/windows/create-update-manifest.mjs
T
曾志威 5db6b9ef68
ci / rust (push) Canceled after 0s
ci / web (push) Canceled after 0s
ci / package-preview (push) Canceled after 0s
ci / package-installer (push) Canceled after 0s
ci / linux-agent (push) Canceled after 0s
ci / edge-service (push) Canceled after 0s
ci / coturn-pop (push) Canceled after 0s
ci / package-windows-host (push) Canceled after 0s
Initial commit
2026-08-14 00:35:42 +08:00

134 lines
5.2 KiB
JavaScript

import { createHash, createPrivateKey, createPublicKey, sign } from 'node:crypto'
import { readFile, stat, writeFile } from 'node:fs/promises'
import path from 'node:path'
import process from 'node:process'
const usage = `usage: node create-update-manifest.mjs \
--installer <RemoteDesk.msi> \
--installer-url <https-url> \
--private-key <ed25519-pkcs8.pem> \
--version <major.minor.patch> \
--output <stable.json> \
[--public-key-output <public-key.txt>] \
[--channel <stable>] [--target <windows-x64>] [--notes-file <notes.txt>]`
function parseArgs(values) {
const options = {}
for (let index = 0; index < values.length; index += 1) {
const name = values[index]
if (name === '--help' || name === '-h') return { help: true }
if (!name.startsWith('--')) throw new Error(`unknown argument: ${name}`)
const value = values[index + 1]
if (!value || value.startsWith('--')) throw new Error(`${name} requires a value`)
if (Object.hasOwn(options, name)) throw new Error(`${name} was provided more than once`)
options[name] = value
index += 1
}
return options
}
function required(options, name) {
const value = options[name]
if (!value) throw new Error(`${name} is required`)
return value
}
function validateShortText(value, maximum, name) {
if (!value || value.length > maximum || /[\u0000-\u001f\u007f]/u.test(value)) {
throw new Error(`${name} is invalid`)
}
return value
}
function validateUrl(value) {
if (value.length > 2048 || /[\u0000-\u001f\u007f]/u.test(value)) {
throw new Error('installer URL is invalid')
}
const url = new URL(value)
if (url.protocol !== 'https:' || !url.hostname) throw new Error('installer URL must use HTTPS')
if (url.username || url.password || url.search || url.hash) {
throw new Error('installer URL must not contain credentials, query, or fragment')
}
if (!url.pathname.toLowerCase().endsWith('.msi')) {
throw new Error('installer URL must identify an MSI package')
}
return url.toString()
}
function rawEd25519PublicKey(privateKey) {
const jwk = createPublicKey(privateKey).export({ format: 'jwk' })
if (jwk.kty !== 'OKP' || jwk.crv !== 'Ed25519' || typeof jwk.x !== 'string') {
throw new Error('private key is not an Ed25519 key')
}
const raw = Buffer.from(jwk.x, 'base64url')
if (raw.length !== 32) throw new Error('Ed25519 public key must contain 32 bytes')
return raw
}
async function main() {
const options = parseArgs(process.argv.slice(2))
if (options.help) {
process.stdout.write(`${usage}\n`)
return
}
const installerPath = path.resolve(required(options, '--installer'))
const installerUrl = validateUrl(required(options, '--installer-url'))
const privateKeyPath = path.resolve(required(options, '--private-key'))
const outputPath = path.resolve(required(options, '--output'))
const publicKeyOutput = options['--public-key-output']
? path.resolve(options['--public-key-output'])
: null
const version = required(options, '--version')
if (!/^\d+\.\d+\.\d+$/u.test(version)) throw new Error('version must be major.minor.patch')
const channel = validateShortText(options['--channel'] ?? 'stable', 32, 'channel')
const target = validateShortText(options['--target'] ?? 'windows-x64', 32, 'target')
const installer = await readFile(installerPath)
const installerStats = await stat(installerPath)
if (!installerStats.isFile() || installerStats.size === 0) throw new Error('installer is empty')
if (installerStats.size > 1024 * 1024 * 1024) throw new Error('installer exceeds 1 GiB')
const notes = options['--notes-file']
? await readFile(path.resolve(options['--notes-file']), 'utf8')
: undefined
if (notes && (Buffer.byteLength(notes) > 4096 || /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/u.test(notes))) {
throw new Error('release notes are invalid')
}
const privateKey = createPrivateKey(await readFile(privateKeyPath, 'utf8'))
if (privateKey.asymmetricKeyType !== 'ed25519') throw new Error('private key must use Ed25519')
const publicKey = rawEd25519PublicKey(privateKey)
const payload = Buffer.from(JSON.stringify({
product: 'remotedesk',
channel,
version,
published_at: new Date().toISOString(),
target,
installer: {
url: installerUrl,
sha256: createHash('sha256').update(installer).digest('hex'),
size_bytes: installerStats.size,
},
...(notes ? { notes } : {}),
}), 'utf8')
const signature = sign(null, payload, privateKey)
if (signature.length !== 64) throw new Error('Ed25519 signature must contain 64 bytes')
const envelope = {
schema: 1,
payload: payload.toString('base64'),
signature: signature.toString('base64'),
}
await writeFile(outputPath, `${JSON.stringify(envelope, null, 2)}\n`, { encoding: 'utf8', flag: 'wx' })
const publicKeyBase64 = publicKey.toString('base64')
if (publicKeyOutput) {
await writeFile(publicKeyOutput, `${publicKeyBase64}\n`, { encoding: 'ascii', flag: 'wx' })
}
process.stdout.write(`Manifest: ${outputPath}\n`)
process.stdout.write(`Update public key: ${publicKeyBase64}\n`)
}
main().catch((error) => {
process.stderr.write(`error: ${error instanceof Error ? error.message : String(error)}\n`)
process.exitCode = 1
})