YCode / Windows x64
v${version}
此页面仅从 Ed25519 验签通过的更新清单生成,不加载脚本或第三方资源。
SHA-256 ${sha256}
更新说明
${zhNotes}
Release notes
${enNotes}
import crypto from 'node:crypto' import { spawnSync } from 'node:child_process' import fs from 'node:fs' import fsp from 'node:fs/promises' import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' export const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..') export const DEFAULT_BASE_URL = 'http://192.168.10.6/ycode/releases/' export const DEFAULT_SSH_HOST = 'local001' export const DEFAULT_PRIVATE_KEY = path.join(os.homedir(), '.ycode-release', 'update-signing.key') export const DEFAULT_PUBLIC_KEY = path.join(REPO_ROOT, 'resources', 'updates', 'public-key.pem') export const BASELINE_VERSION = '0.0.1' export const MAX_INSTALLER_BYTES = 4 * 1024 * 1024 * 1024 export const MIN_PUBLIC_INSTALLER_BYTES = 20 * 1024 * 1024 export const SERVER_PUBLIC_ROOT = '/srv/ycode' export const SERVER_STAGING_ROOT = '/srv/ycode-staging' export const SERVER_RELEASES_ROOT = '/srv/ycode/releases' export const NGINX_SITE_LINK = '/etc/nginx/sites-enabled/default' export const NGINX_SNIPPET = '/etc/nginx/snippets/ycode-releases.conf' export const NGINX_MARKER = '# YCode updater releases (managed by scripts/updates)' const SEMVER_RE = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/ const SHA256_RE = /^[a-f0-9]{64}$/ const EXE_NAME_RE = /^[A-Za-z0-9._-]+\.exe$/ function fail(message) { throw new Error(message) } export function assert(condition, message) { if (!condition) fail(message) } export function parseOptions(argv, { values = [], booleans = [] } = {}) { const valueNames = new Set(values) const booleanNames = new Set(booleans) const options = {} for (let index = 0; index < argv.length; index += 1) { const argument = argv[index] if (!argument.startsWith('--')) fail(`Unexpected argument: ${argument}`) const equals = argument.indexOf('=') const name = argument.slice(2, equals === -1 ? undefined : equals) if (booleanNames.has(name)) { assert(equals === -1, `--${name} does not accept a value`) options[name] = true continue } assert(valueNames.has(name), `Unknown option: --${name}`) const value = equals === -1 ? argv[++index] : argument.slice(equals + 1) assert(value !== undefined && value !== '', `Missing value for --${name}`) options[name] = value } return options } export function normalizeBaseUrl(value = DEFAULT_BASE_URL) { let url try { url = new URL(value) } catch { fail(`Invalid base URL: ${value}`) } assert(url.protocol === 'http:' || url.protocol === 'https:', 'Base URL must use HTTP or HTTPS') assert(!url.username && !url.password, 'Base URL must not contain credentials') assert(!url.search && !url.hash, 'Base URL must not contain a query or fragment') if (!url.pathname.endsWith('/')) url.pathname += '/' return url.href } export function artifactUrlFor(baseUrl, version, filename) { parseVersion(version) assert(EXE_NAME_RE.test(filename), `Unsafe installer filename: ${filename}`) const base = normalizeBaseUrl(baseUrl) return new URL(`versions/${version}/${filename}`, base).href } export function assertHttpUrl(value, label = 'URL') { let url try { url = new URL(value) } catch { fail(`${label} is invalid`) } assert(url.protocol === 'http:' || url.protocol === 'https:', `${label} must use HTTP or HTTPS`) assert(!url.username && !url.password, `${label} must not contain credentials`) assert(!url.search, `${label} must not contain a query`) assert(!url.hash, `${label} must not contain a fragment`) return url } export function parseVersion(version) { assert(typeof version === 'string' && version.length <= 256, 'Version must be a non-empty string') const match = SEMVER_RE.exec(version) assert(match, `Version is not strict SemVer: ${version}`) const prerelease = match[4] ? match[4].split('.') : [] for (const identifier of prerelease) { assert(!/^\d+$/.test(identifier) || identifier === '0' || !identifier.startsWith('0'), `Invalid SemVer prerelease: ${version}`) } return { major: BigInt(match[1]), minor: BigInt(match[2]), patch: BigInt(match[3]), prerelease, build: match[5] ? match[5].split('.') : [] } } function compareIdentifier(left, right) { const leftNumeric = /^\d+$/.test(left) const rightNumeric = /^\d+$/.test(right) if (leftNumeric && rightNumeric) { const a = BigInt(left) const b = BigInt(right) return a < b ? -1 : a > b ? 1 : 0 } if (leftNumeric) return -1 if (rightNumeric) return 1 return left < right ? -1 : left > right ? 1 : 0 } export function compareVersions(left, right) { const a = parseVersion(left) const b = parseVersion(right) for (const key of ['major', 'minor', 'patch']) { if (a[key] !== b[key]) return a[key] < b[key] ? -1 : 1 } if (!a.prerelease.length && !b.prerelease.length) return 0 if (!a.prerelease.length) return 1 if (!b.prerelease.length) return -1 const length = Math.max(a.prerelease.length, b.prerelease.length) for (let index = 0; index < length; index += 1) { if (a.prerelease[index] === undefined) return -1 if (b.prerelease[index] === undefined) return 1 const compared = compareIdentifier(a.prerelease[index], b.prerelease[index]) if (compared !== 0) return compared } return 0 } export function normalizeProductVersion(value) { let normalized = String(value ?? '').trim() if (normalized.startsWith('v')) normalized = normalized.slice(1) const windowsRevision = /^(\d+\.\d+\.\d+)\.0$/.exec(normalized) if (windowsRevision) normalized = windowsRevision[1] return normalized } export async function sha256File(filename) { const hash = crypto.createHash('sha256') for await (const chunk of fs.createReadStream(filename)) hash.update(chunk) return hash.digest('hex') } export async function readJson(filename) { try { return JSON.parse(await fsp.readFile(filename, 'utf8')) } catch (error) { fail(`Unable to read JSON ${filename}: ${error.message}`) } } export async function writeFileAtomic(filename, data, mode = 0o644) { const directory = path.dirname(filename) await fsp.mkdir(directory, { recursive: true }) const temporary = path.join(directory, `.${path.basename(filename)}.${crypto.randomBytes(8).toString('hex')}.tmp`) const handle = await fsp.open(temporary, 'wx', mode) try { await handle.writeFile(data) await handle.sync() } finally { await handle.close() } try { await fsp.rename(temporary, filename) } catch (error) { await fsp.rm(temporary, { force: true }) throw error } } export function resolvePath(value, base = process.cwd()) { return path.resolve(base, value) } export async function assertOutsideRepo(filename, label) { const candidate = path.resolve(filename) const repo = path.resolve(REPO_ROOT) const relative = path.relative(repo, candidate) assert(relative.startsWith('..') || path.isAbsolute(relative), `${label} must be outside the repository: ${candidate}`) return candidate } export function generateEd25519KeyPair() { const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519') return { privatePem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), publicPem: publicKey.export({ type: 'spki', format: 'pem' }).toString() } } export function createPrivateKey(privateKeyPem) { const key = crypto.createPrivateKey(privateKeyPem) assert(key.asymmetricKeyType === 'ed25519', 'Update signing key must be Ed25519') return key } export function createPublicKey(publicKeyPem) { const key = crypto.createPublicKey(publicKeyPem) assert(key.asymmetricKeyType === 'ed25519', 'Update public key must be Ed25519') return key } export function signPayload(payload, privateKeyPem) { const bytes = Buffer.from(JSON.stringify(payload), 'utf8') return crypto.sign(null, bytes, createPrivateKey(privateKeyPem)).toString('base64') } export function verifyPayloadSignature(payload, signature, publicKeyPem) { assert(typeof signature === 'string', 'Manifest signature must be a string') const signatureBytes = Buffer.from(signature, 'base64') assert(signatureBytes.length === 64, 'Manifest signature must contain 64 bytes') assert(signatureBytes.toString('base64') === signature, 'Manifest signature must use canonical base64') const bytes = Buffer.from(JSON.stringify(payload), 'utf8') return crypto.verify(null, bytes, createPublicKey(publicKeyPem), signatureBytes) } export function publicKeyFingerprint(publicKeyPem) { const der = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' }) return crypto.createHash('sha256').update(der).digest('hex') } function assertExactKeys(value, expected, label) { assert(value && typeof value === 'object' && !Array.isArray(value), `${label} must be an object`) const actual = Object.keys(value) assert(actual.length === expected.length && expected.every((key) => actual.includes(key)), `${label} has unexpected or missing fields`) } export function validateManifestDocument(document, { baseUrl = DEFAULT_BASE_URL } = {}) { assertExactKeys(document, ['payload', 'signature'], 'Manifest') const payload = document.payload assertExactKeys(payload, ['schemaVersion', 'version', 'publishedAt', 'notes', 'platforms'], 'Manifest payload') assert(payload.schemaVersion === 1, 'Unsupported update manifest schemaVersion') parseVersion(payload.version) assert(typeof payload.publishedAt === 'string', 'publishedAt must be a string') const published = new Date(payload.publishedAt) assert(!Number.isNaN(published.valueOf()), 'publishedAt must be an ISO timestamp') assert(published.toISOString() === payload.publishedAt, 'publishedAt must be canonical UTC ISO') assertExactKeys(payload.notes, ['zh', 'en'], 'Manifest notes') for (const language of ['zh', 'en']) { assert(typeof payload.notes[language] === 'string' && payload.notes[language].trim(), `Manifest notes.${language} is required`) assert(payload.notes[language].length <= 16_384, `Manifest notes.${language} is too long`) } assertExactKeys(payload.platforms, ['windows-x64'], 'Manifest platforms') const platform = payload.platforms['windows-x64'] assertExactKeys(platform, ['url', 'size', 'sha256'], 'windows-x64 platform') assert(Number.isSafeInteger(platform.size) && platform.size > 0 && platform.size <= MAX_INSTALLER_BYTES, 'windows-x64 size is invalid') assert(typeof platform.sha256 === 'string' && SHA256_RE.test(platform.sha256), 'windows-x64 sha256 must be lowercase SHA-256 hex') const url = assertHttpUrl(platform.url, 'windows-x64 URL') const filename = path.posix.basename(url.pathname) const expected = artifactUrlFor(baseUrl, payload.version, filename) assert(url.href === expected, 'windows-x64 URL is outside the approved version path') return payload } export function verifySignedManifest(document, publicKeyPem, options = {}) { const payload = validateManifestDocument(document, options) assert(verifyPayloadSignature(payload, document.signature, publicKeyPem), 'Update manifest signature is invalid') return payload } export function serializeSignedManifest(document, options = {}) { validateManifestDocument(document, options) return `${JSON.stringify(document)}\n` } export async function readPublicKey(filename = DEFAULT_PUBLIC_KEY) { try { return await fsp.readFile(filename, 'utf8') } catch (error) { fail(`Unable to read update public key ${filename}: ${error.message}`) } } export async function readPrivateKey(filename = DEFAULT_PRIVATE_KEY) { try { return await fsp.readFile(filename, 'utf8') } catch (error) { fail(`Unable to read update signing key ${filename}: ${error.message}`) } } export function runProcess(command, args, options = {}) { const result = spawnSync(command, args, { cwd: options.cwd, input: options.input, encoding: 'utf8', windowsHide: true, maxBuffer: 8 * 1024 * 1024 }) if (result.error) fail(`Unable to run ${command}: ${result.error.message}`) if (result.status !== 0) { const detail = String(result.stderr || result.stdout || '').trim() fail(`${command} failed with exit code ${result.status}${detail ? `: ${detail}` : ''}`) } return result.stdout } export function validateSshHost(host = DEFAULT_SSH_HOST) { assert(/^[A-Za-z0-9_.@:-]+$/.test(host) && !host.startsWith('-'), 'SSH host contains unsafe characters') return host } export function runSsh(host, script) { return runProcess('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=10', validateSshHost(host), 'bash', '-s'], { input: script }) } export async function getProductVersion(installerPath) { const script = path.join(path.dirname(fileURLToPath(import.meta.url)), 'get-exe-version.ps1') assert(fs.existsSync(script), `Missing version helper: ${script}`) const output = runProcess('powershell.exe', [ '-NoLogo', '-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script, '-Path', installerPath ]) let parsed try { parsed = JSON.parse(output) } catch { fail('Unable to parse installer version information') } assert(parsed && typeof parsed.productVersion === 'string', 'Installer has no ProductVersion') return parsed } export async function readPackageMetadata() { const packagePath = path.join(REPO_ROOT, 'package.json') const pkg = await readJson(packagePath) assert(pkg.name === 'ycode', 'package.json is not the YCode package') assert(pkg.build?.appId === 'com.ycode.app', 'package.json appId is not com.ycode.app') parseVersion(pkg.version) return { path: packagePath, package: pkg } } export async function verifyReleaseInstaller(installerPath, version, { minimumSize = MIN_PUBLIC_INSTALLER_BYTES } = {}) { parseVersion(version) assert(compareVersions(version, BASELINE_VERSION) > 0, `Release version must be newer than the updater-less ${BASELINE_VERSION} baseline`) assert(EXE_NAME_RE.test(path.basename(installerPath)), `Unsafe installer filename: ${path.basename(installerPath)}`) let resolved try { resolved = await fsp.realpath(installerPath) } catch (error) { fail(`Unable to resolve installer: ${error.message}`) } const stat = await fsp.stat(resolved) assert(stat.isFile(), 'Installer must be a regular file') assert(stat.size >= minimumSize, `Installer is unexpectedly small (${stat.size} bytes)`) assert(stat.size <= MAX_INSTALLER_BYTES, 'Installer exceeds the 4 GiB limit') const product = await getProductVersion(resolved) const productVersion = normalizeProductVersion(product.productVersion) assert(productVersion === version, `Installer ProductVersion ${product.productVersion} does not match ${version}`) const { path: packagePath, package: pkg } = await readPackageMetadata() assert(normalizeProductVersion(pkg.version) === version, `package.json version ${pkg.version} does not match ${version}`) return { path: resolved, filename: path.basename(resolved), size: stat.size, sha256: await sha256File(resolved), productVersion, packagePath } } export function buildManifestPayload({ version, publishedAt, notes, installer, baseUrl = DEFAULT_BASE_URL }) { const url = artifactUrlFor(baseUrl, version, installer.filename) return { schemaVersion: 1, version, publishedAt, notes: { zh: notes.zh, en: notes.en }, platforms: { 'windows-x64': { url, size: installer.size, sha256: installer.sha256 } } } } export function createSignedManifest({ payload, privateKeyPem, publicKeyPem, baseUrl = DEFAULT_BASE_URL }) { validateManifestDocument({ payload, signature: Buffer.alloc(64).toString('base64') }, { baseUrl }) const signature = signPayload(payload, privateKeyPem) const document = { payload, signature } assert(verifyPayloadSignature(payload, signature, publicKeyPem), 'Generated manifest does not verify with the configured public key') validateManifestDocument(document, { baseUrl }) return document } export function canonicalNow(value = new Date()) { return value.toISOString() } function escapeHtml(value) { return String(value) .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", ''') } function formatBytes(value) { return `${(value / (1024 * 1024)).toFixed(1)} MiB (${value.toLocaleString('en-US')} bytes)` } export function renderDownloadPage(payload, options = {}) { validateManifestDocument({ payload, signature: Buffer.alloc(64).toString('base64') }, options) const platform = payload.platforms['windows-x64'] const version = escapeHtml(payload.version) const url = escapeHtml(platform.url) const sha256 = escapeHtml(platform.sha256) const publishedAt = escapeHtml(payload.publishedAt) const zhNotes = escapeHtml(payload.notes.zh) const enNotes = escapeHtml(payload.notes.en) const size = escapeHtml(formatBytes(platform.size)) return `
YCode / Windows x64
此页面仅从 Ed25519 验签通过的更新清单生成,不加载脚本或第三方资源。
SHA-256 ${sha256}
${zhNotes}
${enNotes}
当前还没有发布正式安装包。此静态页不加载脚本、不提供占位版本;发布完成后再刷新。