a7e810b999
实现 AGC 启动版本检测与 OSS 安装包下载。 - 每次客户端打开时从 OSS latest.json 检查新版本 - 新版本提示支持 releaseNotes,并通过 Tauri 命令下载到系统下载目录 - 下载地址限制受信任 OSS、HTTPS、大小与可选 SHA-256 校验 - 补充 Tauri capability/CSP、单测和发布文档 Closes #224 Reviewed-on: http://192.168.35.82/git/GenarrativeAI/Genarrative/pulls/230
263 lines
8.6 KiB
JavaScript
263 lines
8.6 KiB
JavaScript
import { spawnSync } from 'node:child_process';
|
||
import { createHash } from 'node:crypto';
|
||
import fs from 'node:fs';
|
||
import path from 'node:path';
|
||
import { fileURLToPath } from 'node:url';
|
||
|
||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||
const defaultReleaseTarget = 'x86_64-pc-windows-msvc';
|
||
const releaseTarget =
|
||
process.env.AGC_BUILD_TARGET?.trim() || defaultReleaseTarget;
|
||
const bundleRoot = path.join(
|
||
appRoot,
|
||
'src-tauri',
|
||
'target',
|
||
releaseTarget,
|
||
'release',
|
||
'bundle',
|
||
);
|
||
const packageJsonPath = path.join(appRoot, 'package.json');
|
||
const rootPackageLockPath = path.resolve(appRoot, '../..', 'package-lock.json');
|
||
const tauriConfigPath = path.join(appRoot, 'src-tauri', 'tauri.conf.json');
|
||
const cargoManifestPath = path.join(appRoot, 'src-tauri', 'Cargo.toml');
|
||
const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock');
|
||
const defaultOssBaseUrl =
|
||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc';
|
||
const updateManifestUrl =
|
||
process.env.VITE_AGC_UPDATE_MANIFEST_URL?.trim() ||
|
||
`${process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl}/latest.json`;
|
||
|
||
function readPackageJson() {
|
||
return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||
}
|
||
|
||
export function compareVersions(left, right) {
|
||
const leftParts = left.split('.').map(Number);
|
||
const rightParts = right.split('.').map(Number);
|
||
for (let index = 0; index < 3; index += 1) {
|
||
if (leftParts[index] !== rightParts[index]) {
|
||
return leftParts[index] > rightParts[index] ? 1 : -1;
|
||
}
|
||
}
|
||
return 0;
|
||
}
|
||
|
||
function parseVersion(value, label) {
|
||
if (typeof value !== 'string' || !/^\d+\.\d+\.\d+$/u.test(value)) {
|
||
throw new Error(`${label} 不是有效的三段版本号:${String(value)}`);
|
||
}
|
||
return value;
|
||
}
|
||
|
||
export function nextPatchVersion(localVersion, remoteVersion) {
|
||
const local = parseVersion(localVersion, '本地版本');
|
||
const remote =
|
||
remoteVersion == null ? null : parseVersion(remoteVersion, 'OSS版本');
|
||
const base = remote && compareVersions(remote, local) > 0 ? remote : local;
|
||
const [major, minor, patch] = base.split('.').map(Number);
|
||
if (patch === Number.MAX_SAFE_INTEGER) {
|
||
throw new Error(`版本号 patch 已达到上限:${base}`);
|
||
}
|
||
return `${major}.${minor}.${patch + 1}`;
|
||
}
|
||
|
||
async function readRemoteVersion() {
|
||
let response;
|
||
try {
|
||
response = await fetch(updateManifestUrl, {
|
||
headers: { Accept: 'application/json' },
|
||
});
|
||
} catch (error) {
|
||
throw new Error(`读取 OSS 版本清单失败:${error.message}`);
|
||
}
|
||
if (response.status === 404) return null;
|
||
if (!response.ok) {
|
||
throw new Error(`读取 OSS 版本清单失败:HTTP ${response.status}`);
|
||
}
|
||
let manifest;
|
||
try {
|
||
manifest = await response.json();
|
||
} catch (error) {
|
||
throw new Error(`OSS 版本清单不是有效 JSON:${error.message}`);
|
||
}
|
||
return parseVersion(manifest?.version, 'OSS版本清单 version');
|
||
}
|
||
|
||
function replaceVersionLine(source, version, pattern, label) {
|
||
if (!pattern.test(source)) throw new Error(`未找到${label}版本字段`);
|
||
return source.replace(pattern, `$1${version}$3`);
|
||
}
|
||
|
||
export async function prepareReleaseVersion() {
|
||
const localVersion = parseVersion(readPackageJson().version, '本地版本');
|
||
const remoteVersion = await readRemoteVersion();
|
||
const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim();
|
||
const nextVersion = requestedVersion
|
||
? parseVersion(requestedVersion, '指定版本')
|
||
: nextPatchVersion(localVersion, remoteVersion);
|
||
|
||
const packageSource = fs.readFileSync(packageJsonPath, 'utf8');
|
||
fs.writeFileSync(
|
||
packageJsonPath,
|
||
replaceVersionLine(
|
||
packageSource,
|
||
nextVersion,
|
||
/("version"\s*:\s*")([^"]+)(")/u,
|
||
'package.json',
|
||
),
|
||
);
|
||
|
||
const rootPackageLockSource = fs.readFileSync(rootPackageLockPath, 'utf8');
|
||
fs.writeFileSync(
|
||
rootPackageLockPath,
|
||
replaceVersionLine(
|
||
rootPackageLockSource,
|
||
nextVersion,
|
||
/("apps\/ai-game-creator-shell"\s*:\s*\{\s*\n\s*"name"\s*:\s*"@genarrative\/ai-game-creator-shell"\s*,\s*\n\s*"version"\s*:\s*")([^"]+)(")/u,
|
||
'root package-lock.json',
|
||
),
|
||
);
|
||
|
||
const tauriSource = fs.readFileSync(tauriConfigPath, 'utf8');
|
||
fs.writeFileSync(
|
||
tauriConfigPath,
|
||
replaceVersionLine(
|
||
tauriSource,
|
||
nextVersion,
|
||
/("version"\s*:\s*")([^"]+)(")/u,
|
||
'tauri.conf.json',
|
||
),
|
||
);
|
||
|
||
const cargoSource = fs.readFileSync(cargoManifestPath, 'utf8');
|
||
fs.writeFileSync(
|
||
cargoManifestPath,
|
||
replaceVersionLine(
|
||
cargoSource,
|
||
nextVersion,
|
||
/(^\[package\][\s\S]*?^version\s*=\s*")([^"]+)(")/mu,
|
||
'Cargo.toml',
|
||
),
|
||
);
|
||
|
||
const cargoLockSource = fs.readFileSync(cargoLockPath, 'utf8');
|
||
fs.writeFileSync(
|
||
cargoLockPath,
|
||
replaceVersionLine(
|
||
cargoLockSource,
|
||
nextVersion,
|
||
/(^name\s*=\s*"genarrative-ai-game-creator-shell"\s*\nversion\s*=\s*")([^"]+)(")/mu,
|
||
'Cargo.lock',
|
||
),
|
||
);
|
||
|
||
console.log(
|
||
requestedVersion
|
||
? `[ai-game-creator-shell] 使用指定版本 ${nextVersion}(本地 ${localVersion} / OSS ${remoteVersion ?? '不存在'})`
|
||
: `[ai-game-creator-shell] 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`,
|
||
);
|
||
return nextVersion;
|
||
}
|
||
|
||
export function runTauriBuild(args = []) {
|
||
const noBundle = args.includes('--no-bundle');
|
||
const hasTarget = args.includes('--target');
|
||
const targetArgs = noBundle || hasTarget ? [] : ['--target', releaseTarget];
|
||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||
const result = spawnSync(
|
||
npmCommand,
|
||
[
|
||
'--prefix',
|
||
'../..',
|
||
'exec',
|
||
'tauri',
|
||
'--',
|
||
'build',
|
||
...targetArgs,
|
||
...args,
|
||
],
|
||
{ cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' },
|
||
);
|
||
if (result.error) throw result.error;
|
||
if (result.status !== 0) process.exit(result.status ?? 1);
|
||
}
|
||
|
||
function listFiles(root) {
|
||
if (!fs.existsSync(root)) return [];
|
||
return fs.readdirSync(root, { withFileTypes: true }).flatMap((entry) => {
|
||
const fullPath = path.join(root, entry.name);
|
||
return entry.isDirectory() ? listFiles(fullPath) : [fullPath];
|
||
});
|
||
}
|
||
|
||
function artifactPriority(filePath) {
|
||
const name = path.basename(filePath).toLowerCase();
|
||
if (releaseTarget.includes('windows')) return name.endsWith('.exe') ? 0 : 99;
|
||
if (process.platform === 'darwin') return name.endsWith('.dmg') ? 0 : 99;
|
||
if (name.endsWith('.appimage')) return 0;
|
||
if (name.endsWith('.deb')) return 1;
|
||
if (name.endsWith('.rpm')) return 2;
|
||
return 99;
|
||
}
|
||
|
||
export function selectReleaseArtifact(files) {
|
||
const explicit = process.env.AGC_UPDATE_ARTIFACT?.trim();
|
||
if (explicit) {
|
||
const resolved = path.resolve(explicit);
|
||
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isFile()) {
|
||
throw new Error(`AGC_UPDATE_ARTIFACT 不是有效文件:${resolved}`);
|
||
}
|
||
return resolved;
|
||
}
|
||
return (
|
||
[...files]
|
||
.filter((filePath) => artifactPriority(filePath) < 99)
|
||
.sort((left, right) => {
|
||
const priority = artifactPriority(left) - artifactPriority(right);
|
||
return priority || left.localeCompare(right);
|
||
})[0] ?? null
|
||
);
|
||
}
|
||
|
||
export function createUpdateManifest(artifactPath) {
|
||
const bytes = fs.readFileSync(artifactPath);
|
||
const version = readPackageJson().version;
|
||
const fileName = path.basename(artifactPath);
|
||
const baseUrl = (
|
||
process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl
|
||
).replace(/\/+$/u, '');
|
||
const encodedFileName = encodeURIComponent(fileName).replace(/%2F/giu, '/');
|
||
return {
|
||
version,
|
||
downloadUrl: `${baseUrl}/${encodeURIComponent(version)}/${encodedFileName}`,
|
||
sha256: createHash('sha256').update(bytes).digest('hex'),
|
||
size: bytes.length,
|
||
...(process.env.AGC_UPDATE_RELEASE_NOTES?.trim()
|
||
? { releaseNotes: process.env.AGC_UPDATE_RELEASE_NOTES.trim() }
|
||
: {}),
|
||
};
|
||
}
|
||
|
||
export function generateUpdateManifest() {
|
||
const artifact = selectReleaseArtifact(listFiles(bundleRoot));
|
||
if (!artifact) {
|
||
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
|
||
}
|
||
const manifest = createUpdateManifest(artifact);
|
||
const manifestPath = path.join(bundleRoot, 'latest.json');
|
||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||
console.log(`[ai-game-creator-shell] 已生成 ${manifestPath}`);
|
||
console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
|
||
return { artifact, manifestPath, manifest };
|
||
}
|
||
|
||
if (
|
||
process.argv[1] &&
|
||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
||
) {
|
||
const args = process.argv.slice(2);
|
||
if (!args.includes('--no-bundle')) await prepareReleaseVersion();
|
||
runTauriBuild(args);
|
||
if (!args.includes('--no-bundle')) generateUpdateManifest();
|
||
}
|