客户端更新切换到官方更新插件并按渠道分发
- 客户端接入 tauri-plugin-updater:原生侧注册插件,删除自研更新下载命令、下载进度事件与安装器启动逻辑 - 客户端更新服务与更新提示改走官方插件接口,删除自研清单解析、版本比较与下载实现 - 更新能力只授予客户端主窗口,移除只为自研清单放行的 OSS 白名单与 CSP 连接项 - 新增构建期更新检查开关:开发态默认关闭,agc 启动不请求更新清单、不显示更新入口 - 发布脚本按渠道生成官方更新插件清单与签名,universal macOS 产物同时挂两个平台键,缺签名失败关闭 - 发布脚本按渠道上传安装包、签名与渠道清单,并为 dev-win 生成旧协议 sha256 迁移清单 - 构建期按渠道注入更新端点配置,渠道与目标平台不匹配时发布失败关闭 - Jenkins 流水线新增渠道参数与签名凭据注入,归档补充签名与迁移清单 - 新增发布上传 dry-run 开关,只打印 ossutil 命令且不回显凭据 - 更新技术方案与开发运维文档,登记 macOS 渠道落地待办
This commit is contained in:
@@ -47,6 +47,7 @@
|
||||
"@tauri-apps/plugin-dialog": "^2.7.2",
|
||||
"@tauri-apps/plugin-http": "^2.5.9",
|
||||
"@tauri-apps/plugin-opener": "~2",
|
||||
"@tauri-apps/plugin-updater": "2.11.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"focus-trap-react": "^12.0.3",
|
||||
"lexical": "^0.47.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
@@ -28,14 +29,30 @@ 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`;
|
||||
|
||||
/**
|
||||
* 发布渠道 → 目标平台。渠道名会进入 OSS 路径并烘焙进客户端端点,
|
||||
* 一旦发布就不能改名(改名等于已发布客户端再也找不到更新)。
|
||||
*/
|
||||
const releaseChannels = {
|
||||
'dev-win': 'windows',
|
||||
'dev-mac': 'darwin',
|
||||
};
|
||||
|
||||
function ossBaseUrl() {
|
||||
return (
|
||||
process.env.AGC_UPDATE_OSS_BASE_URL?.trim() || defaultOssBaseUrl
|
||||
).replace(/\/+$/u, '');
|
||||
}
|
||||
|
||||
function readPackageJson() {
|
||||
return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
|
||||
}
|
||||
|
||||
function readReleaseNotes() {
|
||||
return process.env.AGC_UPDATE_RELEASE_NOTES?.trim() || '';
|
||||
}
|
||||
|
||||
export function compareVersions(left, right) {
|
||||
const leftParts = left.split('.').map(Number);
|
||||
const rightParts = right.split('.').map(Number);
|
||||
@@ -66,26 +83,87 @@ export function nextPatchVersion(localVersion, remoteVersion) {
|
||||
return `${major}.${minor}.${patch + 1}`;
|
||||
}
|
||||
|
||||
async function readRemoteVersion() {
|
||||
export function resolveReleasePlatform(target = releaseTarget) {
|
||||
if (target.includes('windows')) return 'windows';
|
||||
if (target.includes('apple-darwin')) return 'darwin';
|
||||
if (target.includes('linux')) return 'linux';
|
||||
throw new Error(`不支持的发布目标:${target}`);
|
||||
}
|
||||
|
||||
export function resolveReleaseChannel(
|
||||
env = process.env,
|
||||
target = releaseTarget,
|
||||
) {
|
||||
const platform = resolveReleasePlatform(target);
|
||||
const requested = env.AGC_UPDATE_CHANNEL?.trim();
|
||||
if (requested) {
|
||||
const channelPlatform = releaseChannels[requested];
|
||||
if (!channelPlatform) {
|
||||
throw new Error(
|
||||
`未知发布渠道 ${requested};当前支持:${Object.keys(releaseChannels).join('、')}`,
|
||||
);
|
||||
}
|
||||
if (channelPlatform !== platform) {
|
||||
throw new Error(
|
||||
`渠道 ${requested} 只能用于 ${channelPlatform} 目标,当前构建目标为 ${target}`,
|
||||
);
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
const defaultChannel = Object.entries(releaseChannels).find(
|
||||
([, channelPlatform]) => channelPlatform === platform,
|
||||
)?.[0];
|
||||
if (!defaultChannel) {
|
||||
throw new Error(
|
||||
`目标 ${target} 没有默认发布渠道,请显式设置 AGC_UPDATE_CHANNEL`,
|
||||
);
|
||||
}
|
||||
return defaultChannel;
|
||||
}
|
||||
|
||||
export function updateManifestUrl(channel = resolveReleaseChannel()) {
|
||||
return `${ossBaseUrl()}/${channel}/latest.json`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新插件按运行时平台键查找清单条目:universal macOS 包同时挂
|
||||
* `darwin-aarch64` 与 `darwin-x86_64`,单架构目标只挂对应键。
|
||||
*/
|
||||
export function resolveManifestPlatformKeys(target = releaseTarget) {
|
||||
if (target === 'universal-apple-darwin') {
|
||||
return ['darwin-aarch64', 'darwin-x86_64'];
|
||||
}
|
||||
if (target === 'aarch64-apple-darwin') return ['darwin-aarch64'];
|
||||
if (target === 'x86_64-apple-darwin') return ['darwin-x86_64'];
|
||||
if (target.includes('windows')) {
|
||||
return [
|
||||
target.startsWith('aarch64') ? 'windows-aarch64' : 'windows-x86_64',
|
||||
];
|
||||
}
|
||||
throw new Error(`不支持的发布目标:${target}`);
|
||||
}
|
||||
|
||||
async function readRemoteVersion(channel = resolveReleaseChannel()) {
|
||||
const manifestUrl = updateManifestUrl(channel);
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(updateManifestUrl, {
|
||||
response = await fetch(manifestUrl, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`读取 OSS 版本清单失败:${error.message}`);
|
||||
throw new Error(`读取 OSS 渠道清单失败:${error.message}`);
|
||||
}
|
||||
if (response.status === 404) return null;
|
||||
if (!response.ok) {
|
||||
throw new Error(`读取 OSS 版本清单失败:HTTP ${response.status}`);
|
||||
throw new Error(`读取 OSS 渠道清单失败:HTTP ${response.status}`);
|
||||
}
|
||||
let manifest;
|
||||
try {
|
||||
manifest = await response.json();
|
||||
} catch (error) {
|
||||
throw new Error(`OSS 版本清单不是有效 JSON:${error.message}`);
|
||||
throw new Error(`OSS 渠道清单不是有效 JSON:${error.message}`);
|
||||
}
|
||||
return parseVersion(manifest?.version, 'OSS版本清单 version');
|
||||
return parseVersion(manifest?.version, 'OSS渠道清单 version');
|
||||
}
|
||||
|
||||
function replaceVersionLine(source, version, pattern, label) {
|
||||
@@ -94,8 +172,9 @@ function replaceVersionLine(source, version, pattern, label) {
|
||||
}
|
||||
|
||||
export async function prepareReleaseVersion() {
|
||||
const channel = resolveReleaseChannel();
|
||||
const localVersion = parseVersion(readPackageJson().version, '本地版本');
|
||||
const remoteVersion = await readRemoteVersion();
|
||||
const remoteVersion = await readRemoteVersion(channel);
|
||||
const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim();
|
||||
const nextVersion = requestedVersion
|
||||
? parseVersion(requestedVersion, '指定版本')
|
||||
@@ -158,8 +237,8 @@ export async function prepareReleaseVersion() {
|
||||
|
||||
console.log(
|
||||
requestedVersion
|
||||
? `[ai-game-creator-shell] 使用指定版本 ${nextVersion}(本地 ${localVersion} / OSS ${remoteVersion ?? '不存在'})`
|
||||
: `[ai-game-creator-shell] 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`,
|
||||
? `[ai-game-creator-shell] 渠道 ${channel} 使用指定版本 ${nextVersion}(本地 ${localVersion} / OSS ${remoteVersion ?? '不存在'})`
|
||||
: `[ai-game-creator-shell] 渠道 ${channel} 版本 ${localVersion} / OSS ${remoteVersion ?? '不存在'} -> ${nextVersion}`,
|
||||
);
|
||||
return nextVersion;
|
||||
}
|
||||
@@ -187,18 +266,43 @@ export function buildTauriBuildArguments(
|
||||
];
|
||||
}
|
||||
|
||||
/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */
|
||||
export function createChannelConfig(channel = resolveReleaseChannel()) {
|
||||
return {
|
||||
plugins: {
|
||||
updater: {
|
||||
endpoints: [updateManifestUrl(channel)],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function writeChannelConfigFile(channel) {
|
||||
const configPath = path.join(
|
||||
os.tmpdir(),
|
||||
`agc-tauri-channel-${channel}.json`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`${JSON.stringify(createChannelConfig(channel), null, 2)}\n`,
|
||||
);
|
||||
return configPath;
|
||||
}
|
||||
|
||||
export function runTauriBuild(args = []) {
|
||||
const tauriArguments = buildTauriBuildArguments(args);
|
||||
if (!tauriArguments.includes('--config') && !tauriArguments.includes('-c')) {
|
||||
const channel = resolveReleaseChannel();
|
||||
const configPath = writeChannelConfigFile(channel);
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`,
|
||||
);
|
||||
tauriArguments.push('--config', configPath);
|
||||
}
|
||||
const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
const result = spawnSync(
|
||||
npmCommand,
|
||||
[
|
||||
'--prefix',
|
||||
'../..',
|
||||
'exec',
|
||||
'tauri',
|
||||
'--',
|
||||
...buildTauriBuildArguments(args),
|
||||
],
|
||||
['--prefix', '../..', 'exec', 'tauri', '--', ...tauriArguments],
|
||||
{ cwd: appRoot, stdio: 'inherit', shell: process.platform === 'win32' },
|
||||
);
|
||||
if (result.error) throw result.error;
|
||||
@@ -216,10 +320,14 @@ function listFiles(root) {
|
||||
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;
|
||||
// 更新链路要的是 updater 产物(macOS 为 .app.tar.gz),dmg 只作人工分发。
|
||||
if (releaseTarget.includes('apple-darwin')) {
|
||||
return name.endsWith('.app.tar.gz') ? 0 : 99;
|
||||
}
|
||||
if (name.endsWith('.appimage.tar.gz')) return 0;
|
||||
if (name.endsWith('.appimage')) return 1;
|
||||
if (name.endsWith('.deb')) return 2;
|
||||
if (name.endsWith('.rpm')) return 3;
|
||||
return 99;
|
||||
}
|
||||
|
||||
@@ -242,36 +350,100 @@ export function selectReleaseArtifact(files) {
|
||||
);
|
||||
}
|
||||
|
||||
export function createUpdateManifest(artifactPath) {
|
||||
function readUpdaterSignature(artifactPath) {
|
||||
const signaturePath = `${artifactPath}.sig`;
|
||||
if (!fs.existsSync(signaturePath)) {
|
||||
throw new Error(
|
||||
`缺少更新包签名:${signaturePath};需要 bundle.createUpdaterArtifacts 与签名私钥(TAURI_SIGNING_PRIVATE_KEY / TAURI_SIGNING_PRIVATE_KEY_PATH)`,
|
||||
);
|
||||
}
|
||||
const signature = fs.readFileSync(signaturePath, 'utf8').trim();
|
||||
if (!signature) throw new Error(`更新包签名为空:${signaturePath}`);
|
||||
return signature;
|
||||
}
|
||||
|
||||
export function createUpdateManifest(
|
||||
artifactPath,
|
||||
{
|
||||
channel = resolveReleaseChannel(),
|
||||
target = releaseTarget,
|
||||
publishedAt = new Date().toISOString(),
|
||||
} = {},
|
||||
) {
|
||||
const signature = readUpdaterSignature(artifactPath);
|
||||
const version = readPackageJson().version;
|
||||
const fileName = path.basename(artifactPath);
|
||||
const url = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`;
|
||||
const platforms = {};
|
||||
for (const key of resolveManifestPlatformKeys(target)) {
|
||||
platforms[key] = { signature, url };
|
||||
}
|
||||
const notes = readReleaseNotes();
|
||||
return {
|
||||
version,
|
||||
...(notes ? { notes } : {}),
|
||||
pub_date: publishedAt,
|
||||
platforms,
|
||||
};
|
||||
}
|
||||
|
||||
/** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */
|
||||
export function createLegacyUpdateManifest(
|
||||
artifactPath,
|
||||
{ channel = resolveReleaseChannel() } = {},
|
||||
) {
|
||||
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, '/');
|
||||
const notes = readReleaseNotes();
|
||||
return {
|
||||
version,
|
||||
downloadUrl: `${baseUrl}/${encodeURIComponent(version)}/${encodedFileName}`,
|
||||
downloadUrl: `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`,
|
||||
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() }
|
||||
: {}),
|
||||
...(notes ? { releaseNotes: notes } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function generateUpdateManifest() {
|
||||
const channel = resolveReleaseChannel();
|
||||
const artifact = selectReleaseArtifact(listFiles(bundleRoot));
|
||||
if (!artifact) {
|
||||
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
|
||||
}
|
||||
const manifest = createUpdateManifest(artifact);
|
||||
const manifest = createUpdateManifest(artifact, { channel });
|
||||
const manifestPath = path.join(bundleRoot, 'latest.json');
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
console.log(`[ai-game-creator-shell] 已生成 ${manifestPath}`);
|
||||
const legacyManifest =
|
||||
channel === 'dev-win'
|
||||
? createLegacyUpdateManifest(artifact, { channel })
|
||||
: null;
|
||||
const legacyManifestPath = legacyManifest
|
||||
? path.join(bundleRoot, 'legacy-latest.json')
|
||||
: null;
|
||||
if (legacyManifest && legacyManifestPath) {
|
||||
fs.writeFileSync(
|
||||
legacyManifestPath,
|
||||
`${JSON.stringify(legacyManifest, null, 2)}\n`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`,
|
||||
);
|
||||
console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
|
||||
return { artifact, manifestPath, manifest };
|
||||
if (legacyManifestPath) {
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 旧协议迁移清单:${legacyManifestPath}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
channel,
|
||||
artifact,
|
||||
manifest,
|
||||
manifestPath,
|
||||
legacyManifest,
|
||||
legacyManifestPath,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
|
||||
@@ -1,24 +1,61 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { test } from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
compareVersions,
|
||||
createChannelConfig,
|
||||
createLegacyUpdateManifest,
|
||||
createUpdateManifest,
|
||||
nextPatchVersion,
|
||||
resolveManifestPlatformKeys,
|
||||
resolveReleaseChannel,
|
||||
selectReleaseArtifact,
|
||||
updateManifestUrl,
|
||||
} from './build-release.mjs';
|
||||
|
||||
test('selects an explicit release artifact when configured', () => {
|
||||
const artifactPath = new URL('../package.json', import.meta.url).pathname;
|
||||
const previous = process.env.AGC_UPDATE_ARTIFACT;
|
||||
process.env.AGC_UPDATE_ARTIFACT = artifactPath;
|
||||
try {
|
||||
assert.equal(selectReleaseArtifact([]), artifactPath);
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.AGC_UPDATE_ARTIFACT;
|
||||
else process.env.AGC_UPDATE_ARTIFACT = previous;
|
||||
const windowsTarget = 'x86_64-pc-windows-msvc';
|
||||
const universalTarget = 'universal-apple-darwin';
|
||||
|
||||
function withEnv(overrides, run) {
|
||||
const previous = new Map();
|
||||
for (const [key, value] of Object.entries(overrides)) {
|
||||
previous.set(key, process.env[key]);
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
try {
|
||||
return run();
|
||||
} finally {
|
||||
for (const [key, value] of previous) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function withSignedArtifact(fileName, run) {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-release-test-'));
|
||||
try {
|
||||
const artifact = path.join(directory, fileName);
|
||||
writeFileSync(artifact, 'installation package');
|
||||
writeFileSync(`${artifact}.sig`, 'signature-content\n');
|
||||
return run(artifact);
|
||||
} finally {
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test('selects an explicit release artifact when configured', () => {
|
||||
const artifactPath = fileURLToPath(
|
||||
new URL('../package.json', import.meta.url),
|
||||
);
|
||||
withEnv({ AGC_UPDATE_ARTIFACT: artifactPath }, () => {
|
||||
assert.equal(selectReleaseArtifact([]), artifactPath);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not select unsupported files', () => {
|
||||
@@ -28,47 +65,123 @@ test('does not select unsupported files', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('manifest contains version, download URL and integrity fields', () => {
|
||||
const manifest = createUpdateManifest(
|
||||
new URL('../package.json', import.meta.url).pathname,
|
||||
test('resolves the channel from the target platform and rejects mismatches', () => {
|
||||
assert.equal(resolveReleaseChannel({}, windowsTarget), 'dev-win');
|
||||
assert.equal(resolveReleaseChannel({}, universalTarget), 'dev-mac');
|
||||
assert.equal(
|
||||
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, universalTarget),
|
||||
'dev-mac',
|
||||
);
|
||||
assert.match(manifest.version, /^\d+\.\d+\.\d+$/u);
|
||||
assert.match(
|
||||
manifest.downloadUrl,
|
||||
new RegExp(`/agc/${manifest.version}/package\\.json$`, 'u'),
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, windowsTarget),
|
||||
/只能用于 darwin 目标/u,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'beta-win' }, windowsTarget),
|
||||
/未知发布渠道/u,
|
||||
);
|
||||
assert.equal(manifest.sha256.length, 64);
|
||||
assert.equal(typeof manifest.size, 'number');
|
||||
});
|
||||
|
||||
test('manifest preserves multiline release notes', () => {
|
||||
const previous = process.env.AGC_UPDATE_RELEASE_NOTES;
|
||||
process.env.AGC_UPDATE_RELEASE_NOTES = '第一行\n第二行\r\n第三行';
|
||||
try {
|
||||
const manifest = createUpdateManifest(
|
||||
new URL('../package.json', import.meta.url).pathname,
|
||||
test('channel manifest URL and build-time endpoint follow the channel', () => {
|
||||
withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => {
|
||||
assert.equal(
|
||||
updateManifestUrl('dev-win'),
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json',
|
||||
);
|
||||
assert.deepEqual(createChannelConfig('dev-mac'), {
|
||||
plugins: {
|
||||
updater: {
|
||||
endpoints: [
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/latest.json',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('universal macOS builds publish one artifact under both platform keys', () => {
|
||||
assert.deepEqual(resolveManifestPlatformKeys(universalTarget), [
|
||||
'darwin-aarch64',
|
||||
'darwin-x86_64',
|
||||
]);
|
||||
assert.deepEqual(resolveManifestPlatformKeys(windowsTarget), [
|
||||
'windows-x86_64',
|
||||
]);
|
||||
});
|
||||
|
||||
test('channel manifest carries version, platform keys and signature', () => {
|
||||
withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => {
|
||||
withEnv({ AGC_UPDATE_RELEASE_NOTES: '修复与改进' }, () => {
|
||||
const manifest = createUpdateManifest(artifact, {
|
||||
channel: 'dev-win',
|
||||
target: windowsTarget,
|
||||
publishedAt: '2026-09-17T00:00:00.000Z',
|
||||
});
|
||||
assert.match(manifest.version, /^\d+\.\d+\.\d+$/u);
|
||||
assert.equal(manifest.notes, '修复与改进');
|
||||
assert.equal(manifest.pub_date, '2026-09-17T00:00:00.000Z');
|
||||
assert.deepEqual(Object.keys(manifest.platforms), ['windows-x86_64']);
|
||||
assert.equal(
|
||||
manifest.platforms['windows-x86_64'].signature,
|
||||
'signature-content',
|
||||
);
|
||||
assert.match(
|
||||
manifest.platforms['windows-x86_64'].url,
|
||||
new RegExp(`/agc/dev-win/${manifest.version}/`, 'u'),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('missing signature fails the channel manifest closed', () => {
|
||||
const directory = mkdtempSync(path.join(os.tmpdir(), 'agc-release-test-'));
|
||||
try {
|
||||
const artifact = path.join(directory, '陶泥儿_0.1.48_x64-setup.exe');
|
||||
writeFileSync(artifact, 'installation package');
|
||||
assert.throws(
|
||||
() =>
|
||||
createUpdateManifest(artifact, {
|
||||
channel: 'dev-win',
|
||||
target: windowsTarget,
|
||||
}),
|
||||
/缺少更新包签名/u,
|
||||
);
|
||||
assert.equal(manifest.releaseNotes, '第一行\n第二行\r\n第三行');
|
||||
} finally {
|
||||
if (previous === undefined) delete process.env.AGC_UPDATE_RELEASE_NOTES;
|
||||
else process.env.AGC_UPDATE_RELEASE_NOTES = previous;
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('next release version follows the higher local or OSS version', () => {
|
||||
test('legacy manifest keeps the sha256 contract of published clients', () => {
|
||||
withSignedArtifact('陶泥儿_0.1.48_x64-setup.exe', (artifact) => {
|
||||
const legacy = createLegacyUpdateManifest(artifact, {
|
||||
channel: 'dev-win',
|
||||
});
|
||||
assert.match(legacy.version, /^\d+\.\d+\.\d+$/u);
|
||||
assert.equal(legacy.sha256.length, 64);
|
||||
assert.equal(legacy.size, 'installation package'.length);
|
||||
assert.match(legacy.downloadUrl, /\/agc\/dev-win\/[\d.]+\//u);
|
||||
});
|
||||
});
|
||||
|
||||
test('next release version follows the higher local or channel version', () => {
|
||||
assert.equal(compareVersions('0.1.15', '0.1.12'), 1);
|
||||
assert.equal(nextPatchVersion('0.1.12', '0.1.15'), '0.1.16');
|
||||
assert.equal(nextPatchVersion('0.1.18', '0.1.15'), '0.1.19');
|
||||
assert.equal(nextPatchVersion('0.1.12', null), '0.1.13');
|
||||
});
|
||||
|
||||
test('release upload forces overwrite for versioned artifact and latest pointer', () => {
|
||||
test('release upload forces overwrite for artifact, signature and channel pointers', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./release-upload.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.equal(
|
||||
(source.match(/runOssutil\(\['cp', '--force'/gu) ?? []).length,
|
||||
2,
|
||||
(source.match(/runOssutil\(\[\s*'cp',\s*'--force'/gu) ?? []).length,
|
||||
4,
|
||||
);
|
||||
assert.match(source, /agc\/\$\{channel\}\/latest\.json/u);
|
||||
assert.match(source, /agc\/latest\.json/u);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* `agc` 开发启动下发给 Vite 的客户端特性开关默认值。
|
||||
*
|
||||
* 开发态默认关闭客户端更新检查:`npm run agc` / `agc:serve` 启动的客户端不请求 OSS
|
||||
* 更新清单,也不显示更新入口。需要联调更新流程时显式传
|
||||
* `VITE_AGC_ENABLE_APP_UPDATE_CHECK=1`;此处不覆盖已经显式配置的取值。
|
||||
*/
|
||||
const agcAppUpdateCheckEnvKey = 'VITE_AGC_ENABLE_APP_UPDATE_CHECK';
|
||||
|
||||
function withAgcDevFeatureFlags(env = process.env) {
|
||||
if (String(env[agcAppUpdateCheckEnvKey] ?? '').trim()) {
|
||||
return env;
|
||||
}
|
||||
return {
|
||||
...env,
|
||||
[agcAppUpdateCheckEnvKey]: '0',
|
||||
};
|
||||
}
|
||||
|
||||
export { agcAppUpdateCheckEnvKey, withAgcDevFeatureFlags };
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* 发布上传的 OSS 命令行整理:把 ossutil 参数与凭据整理成可执行或可打印的形式,
|
||||
* 便于在 dry-run 下核对将要执行的上传,同时保证任何输出都不回显凭据明文。
|
||||
*/
|
||||
const redactedCredential = '<redacted>';
|
||||
|
||||
export function readReleaseDryRun(env = process.env) {
|
||||
const value = env.AGC_RELEASE_DRY_RUN?.trim().toLowerCase();
|
||||
return value === '1' || value === 'true';
|
||||
}
|
||||
|
||||
function quoteArgument(value) {
|
||||
return /[\s"']/u.test(value) ? JSON.stringify(value) : value;
|
||||
}
|
||||
|
||||
export function formatOssutilCommand({ binary, args, endpoint, credentials }) {
|
||||
const parts = [binary, ...args, '--endpoint', endpoint];
|
||||
if (credentials) {
|
||||
parts.push(
|
||||
'--access-key-id',
|
||||
redactedCredential,
|
||||
'--access-key-secret',
|
||||
redactedCredential,
|
||||
);
|
||||
}
|
||||
return parts.map(quoteArgument).join(' ');
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { test } from 'node:test';
|
||||
|
||||
import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs';
|
||||
|
||||
test('dry run only accepts explicit truthy values', () => {
|
||||
assert.equal(readReleaseDryRun({}), false);
|
||||
assert.equal(readReleaseDryRun({ AGC_RELEASE_DRY_RUN: '1' }), true);
|
||||
assert.equal(readReleaseDryRun({ AGC_RELEASE_DRY_RUN: ' true ' }), true);
|
||||
assert.equal(readReleaseDryRun({ AGC_RELEASE_DRY_RUN: '0' }), false);
|
||||
assert.equal(readReleaseDryRun({ AGC_RELEASE_DRY_RUN: '' }), false);
|
||||
});
|
||||
|
||||
test('printed upload command keeps arguments and hides credentials', () => {
|
||||
const command = formatOssutilCommand({
|
||||
binary: 'ossutil',
|
||||
args: [
|
||||
'cp',
|
||||
'--force',
|
||||
'陶泥儿 0.1.48.exe',
|
||||
'oss://agc-dev/agc/dev-win/x.exe',
|
||||
],
|
||||
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
|
||||
credentials: true,
|
||||
});
|
||||
assert.match(command, /^ossutil cp --force /u);
|
||||
assert.match(command, /"陶泥儿 0\.1\.48\.exe"/u);
|
||||
assert.match(command, /oss:\/\/agc-dev\/agc\/dev-win\/x\.exe/u);
|
||||
assert.match(
|
||||
command,
|
||||
/--access-key-id <redacted> --access-key-secret <redacted>/u,
|
||||
);
|
||||
});
|
||||
|
||||
test('uploader gates every ossutil call behind the dry run switch', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./release-upload.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /const dryRun = readReleaseDryRun\(\);/u);
|
||||
assert.match(source, /if \(dryRun\) \{/u);
|
||||
assert.match(source, /dry-run:未写入任何 OSS 对象/u);
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs';
|
||||
|
||||
const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev';
|
||||
const endpoint =
|
||||
process.env.AGC_OSS_ENDPOINT?.trim() || 'oss-rg-china-mainland.aliyuncs.com';
|
||||
@@ -8,6 +10,7 @@ if (!/^[a-z0-9][a-z0-9.-]{1,62}$/u.test(bucket) || /[\r\n\0]/u.test(endpoint)) {
|
||||
throw new Error('OSS bucket 或 endpoint 配置无效');
|
||||
}
|
||||
process.env.AGC_UPDATE_OSS_BASE_URL ||= `https://${bucket}.${endpoint}/agc`;
|
||||
const dryRun = readReleaseDryRun();
|
||||
|
||||
const { generateUpdateManifest, prepareReleaseVersion, runTauriBuild } =
|
||||
await import('./build-release.mjs');
|
||||
@@ -19,6 +22,18 @@ function runOssutil(args) {
|
||||
if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) {
|
||||
throw new Error('OSS AccessKey ID 和 Secret 必须同时提供');
|
||||
}
|
||||
if (dryRun) {
|
||||
// 演练:只打印将要执行的上传,凭据以占位符呈现,不写入 OSS。
|
||||
console.log(
|
||||
`[dry-run] ${formatOssutilCommand({
|
||||
binary,
|
||||
args,
|
||||
endpoint,
|
||||
credentials: Boolean(accessKeyId),
|
||||
})}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const credentialArgs = accessKeyId
|
||||
? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret]
|
||||
: [];
|
||||
@@ -38,11 +53,40 @@ function runOssutil(args) {
|
||||
|
||||
await prepareReleaseVersion();
|
||||
runTauriBuild([]);
|
||||
const { artifact, manifestPath, manifest } = generateUpdateManifest();
|
||||
const artifactKey = `agc/${manifest.version}/${path.basename(artifact)}`;
|
||||
const { artifact, channel, legacyManifestPath, manifest, manifestPath } =
|
||||
generateUpdateManifest();
|
||||
const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`;
|
||||
// Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过;
|
||||
// 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。
|
||||
runOssutil(['cp', '--force', artifact, `oss://${bucket}/${artifactKey}`]);
|
||||
runOssutil(['cp', '--force', manifestPath, `oss://${bucket}/agc/latest.json`]);
|
||||
runOssutil([
|
||||
'cp',
|
||||
'--force',
|
||||
`${artifact}.sig`,
|
||||
`oss://${bucket}/${artifactKey}.sig`,
|
||||
]);
|
||||
runOssutil([
|
||||
'cp',
|
||||
'--force',
|
||||
manifestPath,
|
||||
`oss://${bucket}/agc/${channel}/latest.json`,
|
||||
]);
|
||||
console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/${artifactKey}`);
|
||||
console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/agc/latest.json`);
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 已上传 oss://${bucket}/agc/${channel}/latest.json`,
|
||||
);
|
||||
if (legacyManifestPath) {
|
||||
// 迁移桥:让仍走旧 sha256 清单的已发布客户端升级到新协议,一个版本周期后删除。
|
||||
runOssutil([
|
||||
'cp',
|
||||
'--force',
|
||||
legacyManifestPath,
|
||||
`oss://${bucket}/agc/latest.json`,
|
||||
]);
|
||||
console.log(
|
||||
`[ai-game-creator-shell] 已上传迁移指针 oss://${bucket}/agc/latest.json`,
|
||||
);
|
||||
}
|
||||
if (dryRun) {
|
||||
console.log('[ai-game-creator-shell] dry-run:未写入任何 OSS 对象');
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import http from 'node:http';
|
||||
import net from 'node:net';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { withAgcDevFeatureFlags } from './dev-feature-flags.mjs';
|
||||
import { resolveAgcDevEndpoint, withAgcDevEndpointEnv } from './dev-port.mjs';
|
||||
|
||||
const appRoot = fileURLToPath(new URL('..', import.meta.url));
|
||||
@@ -104,7 +105,7 @@ const child = spawn(
|
||||
],
|
||||
{
|
||||
cwd: appRoot,
|
||||
env: withAgcDevEndpointEnv(endpoint),
|
||||
env: withAgcDevFeatureFlags(withAgcDevEndpointEnv(endpoint)),
|
||||
stdio: 'inherit',
|
||||
// Node 18.20+/20+/24 on Windows rejects spawning .cmd (npm.cmd) without a shell (EINVAL).
|
||||
shell: true,
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
stopWindowsProcessTree,
|
||||
stopWindowsWorktreeProcesses,
|
||||
} from '../../../scripts/dev-windows-process.mjs';
|
||||
import { withAgcDevFeatureFlags } from './dev-feature-flags.mjs';
|
||||
import {
|
||||
agcVitePortEnvKey,
|
||||
readAgcDevEndpoint,
|
||||
@@ -978,7 +979,10 @@ async function startVite(apiTarget, endpoint = readAgcDevEndpoint()) {
|
||||
'--port',
|
||||
String(endpoint.port),
|
||||
],
|
||||
{ cwd: appRoot, env: withAgcDevEndpointEnv(endpoint) },
|
||||
{
|
||||
cwd: appRoot,
|
||||
env: withAgcDevFeatureFlags(withAgcDevEndpointEnv(endpoint)),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+239
-11
@@ -814,6 +814,16 @@ dependencies = [
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.9.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
@@ -837,7 +847,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics-types",
|
||||
"foreign-types 0.5.0",
|
||||
"libc",
|
||||
@@ -850,7 +860,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -1405,6 +1415,16 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetime"
|
||||
version = "0.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.9"
|
||||
@@ -1747,7 +1767,6 @@ dependencies = [
|
||||
"oxc_parser",
|
||||
"oxc_semantic",
|
||||
"oxc_span",
|
||||
"percent-encoding",
|
||||
"platform-agent",
|
||||
"platform-llm",
|
||||
"portable-pty",
|
||||
@@ -1766,6 +1785,7 @@ dependencies = [
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-http",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-updater",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml 0.8.2",
|
||||
@@ -1776,7 +1796,7 @@ dependencies = [
|
||||
"url",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"zip",
|
||||
"zip 2.4.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2220,9 +2240,11 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"socket2",
|
||||
"system-configuration",
|
||||
"tokio",
|
||||
"tower-service",
|
||||
"tracing",
|
||||
"windows-registry",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2506,6 +2528,36 @@ dependencies = [
|
||||
"windows-sys 0.45.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-macros",
|
||||
"jni-sys 0.4.1",
|
||||
"log",
|
||||
"simd_cesu8",
|
||||
"thiserror 2.0.18",
|
||||
"walkdir",
|
||||
"windows-link 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-macros"
|
||||
version = "0.22.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"rustc_version",
|
||||
"simd_cesu8",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.1"
|
||||
@@ -2819,6 +2871,12 @@ dependencies = [
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minisign-verify"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22f9645cb765ea72b8111f36c522475d2daa0d22c957a9826437e97534bc4e9e"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
@@ -3234,6 +3292,18 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-osa-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f112d1746737b0da274ef79a23aac283376f335f4095a083a267a082f21db0c0"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-quartz-core"
|
||||
version = "0.3.2"
|
||||
@@ -3378,6 +3448,20 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "osakit"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "732c71caeaa72c065bb69d7ea08717bd3f4863a4f451402fc9513e29dbd5261b"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-foundation",
|
||||
"objc2-osa-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "outref"
|
||||
version = "0.5.2"
|
||||
@@ -4360,15 +4444,20 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"rustls-platform-verifier",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -4482,6 +4571,33 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0"
|
||||
dependencies = [
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"jni 0.22.4",
|
||||
"log",
|
||||
"once_cell",
|
||||
"rustls",
|
||||
"rustls-native-certs",
|
||||
"rustls-platform-verifier-android",
|
||||
"rustls-webpki",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-platform-verifier-android"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.13"
|
||||
@@ -4609,7 +4725,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
@@ -4952,6 +5068,22 @@ version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "simd_cesu8"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520"
|
||||
dependencies = [
|
||||
"rustc_version",
|
||||
"simdutf8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simdutf8"
|
||||
version = "0.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
|
||||
|
||||
[[package]]
|
||||
name = "similar"
|
||||
version = "2.7.0"
|
||||
@@ -5175,6 +5307,27 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"core-foundation 0.9.4",
|
||||
"system-configuration-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-configuration-sys"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "6.2.2"
|
||||
@@ -5196,7 +5349,7 @@ checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"block2",
|
||||
"core-foundation",
|
||||
"core-foundation 0.10.1",
|
||||
"core-graphics",
|
||||
"crossbeam-channel",
|
||||
"dbus",
|
||||
@@ -5206,7 +5359,7 @@ dependencies = [
|
||||
"gdkwayland-sys",
|
||||
"gdkx11-sys",
|
||||
"gtk",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"ndk",
|
||||
@@ -5239,6 +5392,17 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
"xattr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.12.16"
|
||||
@@ -5262,7 +5426,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"heck 0.5.0",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"log",
|
||||
"mime",
|
||||
@@ -5477,6 +5641,39 @@ dependencies = [
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b28d8cabdeb0564f03ae261963de4bc3d98321cd3d213e76a81b7d344e5df606"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"http",
|
||||
"infer",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"osakit",
|
||||
"percent-encoding",
|
||||
"reqwest 0.13.4",
|
||||
"rustls",
|
||||
"semver",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tar",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"time",
|
||||
"tokio",
|
||||
"url",
|
||||
"windows-sys 0.60.2",
|
||||
"zip 4.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-runtime"
|
||||
version = "2.11.3"
|
||||
@@ -5487,7 +5684,7 @@ dependencies = [
|
||||
"dpi",
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"objc2",
|
||||
"objc2-ui-kit",
|
||||
"objc2-web-kit",
|
||||
@@ -5510,7 +5707,7 @@ checksum = "fe41e015bf8fc4d6477ff4926a0ef769dc64ff34c7b0038b6f7cacae892acb5c"
|
||||
dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"log",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
@@ -6586,6 +6783,15 @@ dependencies = [
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.9"
|
||||
@@ -7192,7 +7398,7 @@ dependencies = [
|
||||
"gtk",
|
||||
"http",
|
||||
"javascriptcore-rs",
|
||||
"jni",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"ndk",
|
||||
"objc2",
|
||||
@@ -7256,6 +7462,16 @@ version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
||||
|
||||
[[package]]
|
||||
name = "xattr"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rustix",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.3"
|
||||
@@ -7437,6 +7653,18 @@ dependencies = [
|
||||
"zopfli",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"crc32fast",
|
||||
"indexmap 2.14.0",
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.21"
|
||||
|
||||
@@ -50,7 +50,6 @@ similar = "2.7"
|
||||
platform-llm = { path = "../../../server-rs/crates/platform-llm" }
|
||||
platform-agent = { path = "../../../server-rs/crates/platform-agent" }
|
||||
portable-pty = "0.9"
|
||||
percent-encoding = "2"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "multipart", "native-tls", "stream"] }
|
||||
regex = "1"
|
||||
shared-contracts = { path = "../../../server-rs/crates/shared-contracts", default-features = false }
|
||||
@@ -58,6 +57,7 @@ tauri = { version = "2.11.2", features = [] }
|
||||
tauri-plugin-dialog = "2.7.1"
|
||||
tauri-plugin-http = { version = "2.5.9", default-features = false, features = ["charset", "cookies", "http2", "rustls-tls"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-updater = "2.11.0"
|
||||
tempfile = "3"
|
||||
toml = "0.8"
|
||||
ttf-parser = "0.25.1"
|
||||
|
||||
@@ -14,13 +14,13 @@
|
||||
"allow": [
|
||||
{ "url": "https://dev.genarrative.world/api/*" },
|
||||
{ "url": "https://www.genarrative.world/api/*" },
|
||||
{ "url": "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/*" },
|
||||
{ "url": "https://*/api/*" },
|
||||
{ "url": "http://localhost:*/*" },
|
||||
{ "url": "http://127.0.0.1:*/*" }
|
||||
]
|
||||
},
|
||||
"opener:default",
|
||||
"updater:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save"
|
||||
]
|
||||
|
||||
@@ -12,6 +12,8 @@ use std::sync::{mpsc, Arc, Mutex, OnceLock};
|
||||
use std::thread;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
// crate 根的 trait 导入会被 `use super::*` 的子模块继承(template_library 的流式下载依赖
|
||||
// `StreamExt`,通知与 Agent 事件依赖 `Emitter`),不要因为根模块自身不再直接用到就删掉。
|
||||
use futures::StreamExt;
|
||||
use platform_agent::{
|
||||
build_game_creation_seed_task_graph, plan_game_creation_agent_pass,
|
||||
@@ -45,189 +47,16 @@ use shared_contracts::game_creation_app::{
|
||||
GAME_CREATION_AGENT_TOOL_CALL_MAX, GAME_CREATION_APP_COMMANDS,
|
||||
GAME_CREATION_APP_LIMITED_RUN_COMMANDS, GAME_CREATION_RESOURCE_LAYOUT_SCHEMA_VERSION,
|
||||
};
|
||||
// `Emitter` 同时被 `use super::*` 的子模块依赖(通知、Agent 事件等都从 crate 根取该 trait),
|
||||
// 不要因为根模块自身不再直接 `.emit(..)` 就删掉它。
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri_plugin_dialog::DialogExt;
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
|
||||
const AGC_UPDATE_OSS_HOST: &str = "agc-dev.oss-rg-china-mainland.aliyuncs.com";
|
||||
const AGC_UPDATE_MAX_DOWNLOAD_BYTES: u64 = 512 * 1024 * 1024;
|
||||
const AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT: &str = "agc-update-download-progress";
|
||||
|
||||
fn build_agc_update_download_client() -> reqwest::Client {
|
||||
reqwest::Client::new()
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AgcUpdateDownloadProgress {
|
||||
downloaded_bytes: u64,
|
||||
total_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn launch_agc_installer(path: &Path, relaunch_path: &Path) -> Result<(), String> {
|
||||
use std::os::windows::process::CommandExt;
|
||||
|
||||
let executable = path.to_string_lossy().replace('\'', "''");
|
||||
let relaunch_executable = relaunch_path.to_string_lossy().replace('\'', "''");
|
||||
let script = format!(
|
||||
"$ErrorActionPreference = 'Stop'; $installer = Start-Process -Verb RunAs -Wait -PassThru -FilePath '{executable}' -ArgumentList @('/S'); if ($installer.ExitCode -eq 0 -and (Test-Path -LiteralPath '{relaunch_executable}')) {{ Start-Process -FilePath '{relaunch_executable}' }}; exit $installer.ExitCode"
|
||||
);
|
||||
Command::new("powershell.exe")
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-NonInteractive",
|
||||
"-WindowStyle",
|
||||
"Hidden",
|
||||
"-Command",
|
||||
script.as_str(),
|
||||
])
|
||||
.creation_flags(0x0800_0000)
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|error| format!("无法启动更新安装程序:{error}"))
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn launch_agc_installer(path: &Path, _relaunch_path: &Path) -> Result<(), String> {
|
||||
Command::new(path)
|
||||
.arg("/S")
|
||||
.spawn()
|
||||
.map(|_| ())
|
||||
.map_err(|error| format!("无法启动更新安装程序:{error}"))
|
||||
}
|
||||
|
||||
/// 更新完成后的进程重启:Windows 由 NSIS 安装程序代为重启,macOS / Linux 由客户端在安装后调用。
|
||||
#[tauri::command]
|
||||
async fn download_agc_update(
|
||||
app: tauri::AppHandle,
|
||||
download_url: String,
|
||||
expected_sha256: Option<String>,
|
||||
expected_size: Option<u64>,
|
||||
) -> Result<String, String> {
|
||||
let parsed =
|
||||
url::Url::parse(download_url.trim()).map_err(|_| "更新下载地址无效".to_string())?;
|
||||
if parsed.scheme() != "https" || parsed.host_str() != Some(AGC_UPDATE_OSS_HOST) {
|
||||
return Err("更新下载地址必须来自受信任的 OSS".to_string());
|
||||
}
|
||||
let encoded_filename = parsed
|
||||
.path_segments()
|
||||
.and_then(|segments| segments.last())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "更新下载地址缺少文件名".to_string())?
|
||||
.to_string();
|
||||
let filename = percent_encoding::percent_decode_str(&encoded_filename)
|
||||
.decode_utf8()
|
||||
.map_err(|_| "更新文件名无效".to_string())?
|
||||
.into_owned();
|
||||
if filename.contains('/') || filename.contains('\\') || filename.contains("..") {
|
||||
return Err("更新文件名无效".to_string());
|
||||
}
|
||||
if filename.is_empty() || filename.len() > 128 {
|
||||
return Err("更新文件名无效".to_string());
|
||||
}
|
||||
let response = build_agc_update_download_client()
|
||||
.get(parsed)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|_| "下载更新失败".to_string())?;
|
||||
if !response.status().is_success() {
|
||||
return Err("下载更新失败".to_string());
|
||||
}
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|length| length > AGC_UPDATE_MAX_DOWNLOAD_BYTES)
|
||||
{
|
||||
return Err("更新文件超过大小限制".to_string());
|
||||
}
|
||||
let download_dir = app
|
||||
.path()
|
||||
.temp_dir()
|
||||
.map_err(|_| "无法定位临时目录".to_string())?
|
||||
.join("genarrative-agc-update");
|
||||
fs::create_dir_all(&download_dir).map_err(|_| "无法创建临时目录".to_string())?;
|
||||
let target = download_dir.join(&filename);
|
||||
let temporary = download_dir.join(format!(
|
||||
"{}.{}.download",
|
||||
filename,
|
||||
uuid::Uuid::new_v4().simple()
|
||||
));
|
||||
let mut file = File::create(&temporary).map_err(|_| "保存更新文件失败".to_string())?;
|
||||
let mut hasher = sha2::Sha256::new();
|
||||
let total_bytes = response.content_length();
|
||||
let mut downloaded_bytes = 0_u64;
|
||||
let _ = app.emit(
|
||||
AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT,
|
||||
AgcUpdateDownloadProgress {
|
||||
downloaded_bytes,
|
||||
total_bytes,
|
||||
},
|
||||
);
|
||||
let mut stream = response.bytes_stream();
|
||||
while let Some(chunk_result) = stream.next().await {
|
||||
let chunk = match chunk_result {
|
||||
Ok(chunk) => chunk,
|
||||
Err(_) => {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err("读取更新文件失败".to_string());
|
||||
}
|
||||
};
|
||||
downloaded_bytes = match downloaded_bytes.checked_add(chunk.len() as u64) {
|
||||
Some(value) if value <= AGC_UPDATE_MAX_DOWNLOAD_BYTES => value,
|
||||
_ => {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err("更新文件超过大小限制".to_string());
|
||||
}
|
||||
};
|
||||
hasher.update(&chunk);
|
||||
if file.write_all(&chunk).is_err() {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err("保存更新文件失败".to_string());
|
||||
}
|
||||
let _ = app.emit(
|
||||
AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT,
|
||||
AgcUpdateDownloadProgress {
|
||||
downloaded_bytes,
|
||||
total_bytes,
|
||||
},
|
||||
);
|
||||
}
|
||||
if file.flush().is_err() {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err("保存更新文件失败".to_string());
|
||||
}
|
||||
drop(file);
|
||||
if let Some(expected_size) = expected_size {
|
||||
if downloaded_bytes != expected_size {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err("更新文件大小校验失败".to_string());
|
||||
}
|
||||
}
|
||||
if let Some(expected_sha256) = expected_sha256 {
|
||||
let expected_sha256 = expected_sha256.trim().to_ascii_lowercase();
|
||||
if !expected_sha256.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
|| expected_sha256.len() != 64
|
||||
{
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err("更新文件摘要无效".to_string());
|
||||
}
|
||||
let actual = format!("{:x}", hasher.finalize());
|
||||
if actual != expected_sha256 {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err("更新文件完整性校验失败".to_string());
|
||||
}
|
||||
}
|
||||
if target.exists() {
|
||||
let _ = fs::remove_file(&target);
|
||||
}
|
||||
if let Err(error) = fs::rename(&temporary, &target) {
|
||||
let _ = fs::remove_file(&temporary);
|
||||
return Err(format!("提交更新文件失败:{error}"));
|
||||
}
|
||||
let relaunch_path =
|
||||
std::env::current_exe().map_err(|error| format!("无法定位客户端程序:{error}"))?;
|
||||
launch_agc_installer(&target, &relaunch_path)?;
|
||||
app.exit(0);
|
||||
Ok(target.to_string_lossy().into_owned())
|
||||
fn restart_agc_app(app: tauri::AppHandle) {
|
||||
app.restart();
|
||||
}
|
||||
|
||||
/// Rust 侧普通文本日志:保留 stderr 输出,同时将同一行持久化到 AppData。
|
||||
@@ -2556,6 +2385,7 @@ fn main() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_http::init())
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.plugin(context_menu::init())
|
||||
.manage(game_creator_preview_registry())
|
||||
.manage(ProjectResourcePreviewReadManager::default())
|
||||
@@ -2841,7 +2671,7 @@ fn main() {
|
||||
replace_local_project_version_resource,
|
||||
get_local_game_project_revision,
|
||||
get_local_game_manifest,
|
||||
download_agc_update,
|
||||
restart_agc_app,
|
||||
append_application_log,
|
||||
read_diagnostic_logs,
|
||||
report_client_error,
|
||||
@@ -3013,50 +2843,6 @@ mod diagnostic_log_tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod update_client_tests {
|
||||
use super::*;
|
||||
use std::io::{Read, Write};
|
||||
|
||||
#[tokio::test]
|
||||
async fn update_download_client_omits_agc_marker() {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("bind update fixture");
|
||||
let address = listener.local_addr().expect("update fixture address");
|
||||
let server = std::thread::spawn(move || {
|
||||
let (mut stream, _) = listener.accept().expect("accept update request");
|
||||
stream
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(2)))
|
||||
.expect("set update fixture timeout");
|
||||
let mut bytes = Vec::new();
|
||||
let mut buffer = [0_u8; 1024];
|
||||
while !bytes.windows(4).any(|window| window == b"\r\n\r\n") {
|
||||
let read = stream.read(&mut buffer).expect("read update request");
|
||||
assert!(read > 0, "update request closed before headers");
|
||||
bytes.extend_from_slice(&buffer[..read]);
|
||||
}
|
||||
stream
|
||||
.write_all(
|
||||
b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\nConnection: close\r\n\r\n",
|
||||
)
|
||||
.expect("write update response");
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
});
|
||||
|
||||
let client = build_agc_update_download_client();
|
||||
let response = client
|
||||
.get(format!("http://{address}/update.exe"))
|
||||
.send()
|
||||
.await
|
||||
.expect("send update request");
|
||||
let request = server.join().expect("join update fixture");
|
||||
|
||||
assert_eq!(response.status(), reqwest::StatusCode::NO_CONTENT);
|
||||
assert!(!request
|
||||
.to_ascii_lowercase()
|
||||
.contains("x-genarrative-client:"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
pub mod ui_editor;
|
||||
|
||||
@@ -24,13 +24,14 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: data: blob: https://agc-dev.oss-rg-china-mainland.aliyuncs.com; media-src 'self' asset: data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*",
|
||||
"devCsp": "default-src 'self' customprotocol: asset: http://127.0.0.1:*; img-src 'self' asset: http://127.0.0.1:* data: blob: https://agc-dev.oss-rg-china-mainland.aliyuncs.com; media-src 'self' asset: http://127.0.0.1:* data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*"
|
||||
"csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: data: blob: https://agc-dev.oss-rg-china-mainland.aliyuncs.com; media-src 'self' asset: data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*",
|
||||
"devCsp": "default-src 'self' customprotocol: asset: http://127.0.0.1:*; img-src 'self' asset: http://127.0.0.1:* data: blob: https://agc-dev.oss-rg-china-mainland.aliyuncs.com; media-src 'self' asset: http://127.0.0.1:* data: blob:; connect-src 'self' https://agc-dev.oss-rg-china-mainland.aliyuncs.com http://127.0.0.1:* ws://127.0.0.1:*; script-src 'self'; style-src 'self' 'unsafe-inline'; frame-src 'self' http://127.0.0.1:*"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"createUpdaterArtifacts": true,
|
||||
"resources": {
|
||||
"design-agent": "design-agent"
|
||||
},
|
||||
@@ -50,5 +51,16 @@
|
||||
"../../desktop-shell/src-tauri/icons/icon.ico",
|
||||
"../../desktop-shell/src-tauri/icons/icon.png"
|
||||
]
|
||||
},
|
||||
"plugins": {
|
||||
"updater": {
|
||||
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDRFN0NFOEUzNDczNDg4Q0IKUldUTGlEUkg0K2g4VGpaQ3FiTXdoNnJTV0JDSWU4VjQrTkcrMkovS2RleFloUXVhdWZIVGpMOTYK",
|
||||
"endpoints": [
|
||||
"https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json"
|
||||
],
|
||||
"windows": {
|
||||
"installMode": "quiet"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* AGC 客户端构建期特性开关。
|
||||
*
|
||||
* 开关只读取 `VITE_*` 构建期变量,运行时不改变;未显式配置时按运行环境回落:
|
||||
* 开发态(`npm run agc` / `agc:serve` 的 Vite dev server 提供前端)取 `devValue`,
|
||||
* 正式包取反。
|
||||
*/
|
||||
function resolveFeatureFlag(
|
||||
flag: string | undefined,
|
||||
{ devValue, dev }: { devValue: boolean; dev: boolean },
|
||||
) {
|
||||
const value = flag?.trim();
|
||||
if (value === '1') return true;
|
||||
if (value === '0') return false;
|
||||
return dev ? devValue : !devValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户端更新检查(启动时的更新提示与“关于”里的手动检查)总开关。
|
||||
*
|
||||
* 开发态默认关闭:`agc` 启动的客户端不请求 OSS 更新清单,也不显示更新入口。
|
||||
* 需要联调更新流程时用 `VITE_AGC_ENABLE_APP_UPDATE_CHECK=1` 显式打开,
|
||||
* 正式包也可用 `=0` 关闭。
|
||||
*/
|
||||
export function resolveAppUpdateCheckEnabled(
|
||||
flag: string | undefined = import.meta.env.VITE_AGC_ENABLE_APP_UPDATE_CHECK,
|
||||
dev: boolean = import.meta.env.DEV,
|
||||
) {
|
||||
return resolveFeatureFlag(flag, { devValue: false, dev });
|
||||
}
|
||||
|
||||
export const appUpdateCheckEnabled = resolveAppUpdateCheckEnabled();
|
||||
@@ -3,21 +3,12 @@ import { useEffect, useState } from 'react';
|
||||
|
||||
import { APP_VERSION } from '../app/appMetadata';
|
||||
import {
|
||||
AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT,
|
||||
type AppUpdateInfo,
|
||||
type AppUpdateProgress,
|
||||
checkForAppUpdate,
|
||||
downloadAppUpdate,
|
||||
installAppUpdate,
|
||||
subscribeToAppUpdate,
|
||||
} from '../services/appUpdate';
|
||||
import {
|
||||
canSubscribeTauriEvents,
|
||||
subscribeTauriEvent,
|
||||
} from '../services/tauriEventSubscription';
|
||||
|
||||
type DownloadProgress = {
|
||||
downloadedBytes: number;
|
||||
totalBytes?: number;
|
||||
};
|
||||
|
||||
type DownloadState = 'idle' | 'downloading' | 'completed' | 'error';
|
||||
|
||||
@@ -29,7 +20,7 @@ function formatBytes(bytes: number) {
|
||||
export function AppUpdateNotice() {
|
||||
const [update, setUpdate] = useState<AppUpdateInfo | null>(null);
|
||||
const [downloadState, setDownloadState] = useState<DownloadState>('idle');
|
||||
const [downloadProgress, setDownloadProgress] = useState<DownloadProgress>({
|
||||
const [downloadProgress, setDownloadProgress] = useState<AppUpdateProgress>({
|
||||
downloadedBytes: 0,
|
||||
});
|
||||
const [downloadError, setDownloadError] = useState('');
|
||||
@@ -48,30 +39,11 @@ export function AppUpdateNotice() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canSubscribeTauriEvents() || !update) return;
|
||||
let disposed = false;
|
||||
let unlisten: (() => void) | undefined;
|
||||
void subscribeTauriEvent<DownloadProgress>(
|
||||
AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT,
|
||||
(event) => {
|
||||
if (!disposed) setDownloadProgress(event.payload);
|
||||
},
|
||||
).then((cleanup) => {
|
||||
if (disposed) cleanup();
|
||||
else unlisten = cleanup;
|
||||
});
|
||||
return () => {
|
||||
disposed = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, [update]);
|
||||
|
||||
if (!update) return null;
|
||||
const currentUpdate = update;
|
||||
|
||||
const isDownloading = downloadState === 'downloading';
|
||||
const totalBytes = downloadProgress.totalBytes ?? currentUpdate.size;
|
||||
const totalBytes = downloadProgress.totalBytes;
|
||||
const progress = totalBytes
|
||||
? Math.min(
|
||||
100,
|
||||
@@ -82,10 +54,10 @@ export function AppUpdateNotice() {
|
||||
async function handleDownload() {
|
||||
if (isDownloading) return;
|
||||
setDownloadError('');
|
||||
setDownloadProgress({ downloadedBytes: 0, totalBytes });
|
||||
setDownloadProgress({ downloadedBytes: 0 });
|
||||
setDownloadState('downloading');
|
||||
try {
|
||||
await downloadAppUpdate(currentUpdate.downloadUrl, currentUpdate);
|
||||
await installAppUpdate(setDownloadProgress);
|
||||
setDownloadState('completed');
|
||||
} catch (error) {
|
||||
setDownloadError(error instanceof Error ? error.message : String(error));
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from 'react';
|
||||
|
||||
import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png';
|
||||
import { appUpdateCheckEnabled } from '../app/featureFlags';
|
||||
import { ActiveProjectRunsPanel } from '../features/app-shell/ActiveProjectRunsPanel';
|
||||
import { subscribeTauriEvent } from '../services/tauriEventSubscription';
|
||||
import { AppUpdateNotice } from './AppUpdateNotice';
|
||||
@@ -146,7 +147,7 @@ export function WindowChrome({ children }: WindowChromeProps) {
|
||||
return (
|
||||
<WindowChromeContext.Provider value={contextValue}>
|
||||
<div className="window-chrome">
|
||||
<AppUpdateNotice />
|
||||
{appUpdateCheckEnabled ? <AppUpdateNotice /> : null}
|
||||
<header className="window-chrome__bar" aria-label="窗口标题栏">
|
||||
<div className="window-chrome__leading">
|
||||
<div className="window-chrome__brand" aria-label="陶泥儿 GameAgent">
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
closeDialogOnEscape,
|
||||
useEscapeToClose,
|
||||
} from '../../app/dialogs';
|
||||
import { appUpdateCheckEnabled } from '../../app/featureFlags';
|
||||
import { resolveTauriInvoke } from '../../app/tauri';
|
||||
import {
|
||||
type AgcPluginPanel,
|
||||
@@ -1332,18 +1333,20 @@ export function RuntimeConfigDialog({
|
||||
<dd>桌面客户端</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="runtime-settings-about-update">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void checkAppUpdateManually()}
|
||||
disabled={appUpdateChecking}
|
||||
>
|
||||
{appUpdateChecking ? '正在检查…' : '检查更新'}
|
||||
</button>
|
||||
<span role="status" aria-live="polite">
|
||||
{appUpdateStatus}
|
||||
</span>
|
||||
</div>
|
||||
{appUpdateCheckEnabled ? (
|
||||
<div className="runtime-settings-about-update">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void checkAppUpdateManually()}
|
||||
disabled={appUpdateChecking}
|
||||
>
|
||||
{appUpdateChecking ? '正在检查…' : '检查更新'}
|
||||
</button>
|
||||
<span role="status" aria-live="polite">
|
||||
{appUpdateStatus}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,124 +1,58 @@
|
||||
import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http';
|
||||
import { openUrl } from '@tauri-apps/plugin-opener';
|
||||
import {
|
||||
check,
|
||||
type DownloadEvent,
|
||||
type Update,
|
||||
} from '@tauri-apps/plugin-updater';
|
||||
|
||||
import { APP_VERSION } from '../app/appMetadata';
|
||||
import { appUpdateCheckEnabled } from '../app/featureFlags';
|
||||
import { resolveTauriInvoke } from '../app/tauri';
|
||||
|
||||
/** OSS 上的 AGC 更新清单;发布时可覆盖为同一受信任 OSS 域名下的地址。 */
|
||||
export const AGC_UPDATE_MANIFEST_URL =
|
||||
import.meta.env.VITE_AGC_UPDATE_MANIFEST_URL?.trim() ||
|
||||
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/latest.json';
|
||||
export const AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT =
|
||||
'agc-update-download-progress';
|
||||
|
||||
export type AppUpdateManifest = {
|
||||
/** 更新提示所需的元数据;清单请求、版本比较、下载、校验与安装都由官方更新插件在原生侧完成。 */
|
||||
export type AppUpdateInfo = {
|
||||
version: string;
|
||||
downloadUrl: string;
|
||||
sha256?: string;
|
||||
size?: number;
|
||||
currentVersion: string;
|
||||
releaseNotes?: string;
|
||||
};
|
||||
|
||||
export type AppUpdateInfo = AppUpdateManifest & {
|
||||
currentVersion: string;
|
||||
export type AppUpdateProgress = {
|
||||
downloadedBytes: number;
|
||||
totalBytes?: number;
|
||||
};
|
||||
|
||||
let pendingUpdate: Update | null = null;
|
||||
let updateCheckPromise: Promise<AppUpdateInfo | null> | null = null;
|
||||
const updateListeners = new Set<(update: AppUpdateInfo | null) => void>();
|
||||
|
||||
function parseVersion(value: string) {
|
||||
const match = value
|
||||
.trim()
|
||||
.replace(/^v/iu, '')
|
||||
.match(/^(\d+)\.(\d+)(?:\.(\d+))?/u);
|
||||
return match
|
||||
? [Number(match[1]), Number(match[2]), Number(match[3] ?? 0)]
|
||||
: null;
|
||||
}
|
||||
|
||||
export function isNewerVersion(candidate: string, current: string) {
|
||||
const next = parseVersion(candidate);
|
||||
const installed = parseVersion(current);
|
||||
if (!next || !installed) return false;
|
||||
for (let index = 0; index < next.length; index += 1) {
|
||||
const nextValue = next[index] ?? 0;
|
||||
const installedValue = installed[index] ?? 0;
|
||||
if (nextValue !== installedValue) return nextValue > installedValue;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function parseAppUpdateManifest(
|
||||
value: unknown,
|
||||
): AppUpdateManifest | null {
|
||||
if (!value || typeof value !== 'object') return null;
|
||||
const record = value as Record<string, unknown>;
|
||||
const version =
|
||||
typeof record.version === 'string' ? record.version.trim() : '';
|
||||
const downloadUrl =
|
||||
typeof record.downloadUrl === 'string' ? record.downloadUrl.trim() : '';
|
||||
if (!version || !downloadUrl) return null;
|
||||
try {
|
||||
const url = new URL(downloadUrl);
|
||||
if (url.protocol !== 'https:') return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const sha256 =
|
||||
typeof record.sha256 === 'string'
|
||||
? record.sha256.trim().toLowerCase()
|
||||
: undefined;
|
||||
if (sha256 && !/^[a-f0-9]{64}$/u.test(sha256)) return null;
|
||||
const size =
|
||||
typeof record.size === 'number' &&
|
||||
Number.isSafeInteger(record.size) &&
|
||||
record.size > 0
|
||||
? record.size
|
||||
: undefined;
|
||||
const releaseNotes =
|
||||
typeof record.releaseNotes === 'string'
|
||||
? record.releaseNotes.trim()
|
||||
: undefined;
|
||||
function toAppUpdateInfo(update: Update): AppUpdateInfo {
|
||||
return {
|
||||
version,
|
||||
downloadUrl,
|
||||
...(sha256 ? { sha256 } : {}),
|
||||
...(size ? { size } : {}),
|
||||
...(releaseNotes ? { releaseNotes } : {}),
|
||||
version: update.version,
|
||||
currentVersion: update.currentVersion,
|
||||
...(update.body ? { releaseNotes: update.body } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchUpdateManifest() {
|
||||
const response =
|
||||
typeof window !== 'undefined' && window.__TAURI__
|
||||
? await tauriHttpFetch(AGC_UPDATE_MANIFEST_URL, {
|
||||
method: 'GET',
|
||||
headers: { Accept: 'application/json' },
|
||||
})
|
||||
: await fetch(AGC_UPDATE_MANIFEST_URL, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
if (!response.ok) throw new Error(`更新清单请求失败:${response.status}`);
|
||||
return parseAppUpdateManifest(await response.json());
|
||||
async function runAppUpdateCheck(): Promise<AppUpdateInfo | null> {
|
||||
try {
|
||||
const update = await check();
|
||||
pendingUpdate = update;
|
||||
const info = update ? toAppUpdateInfo(update) : null;
|
||||
updateListeners.forEach((listener) => listener(info));
|
||||
return info;
|
||||
} catch {
|
||||
// 清单 404、渠道缺少当前平台条目、网络或签名错误都按“无更新”收口,不阻塞启动。
|
||||
pendingUpdate = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 同一客户端生命周期内只请求一次,避免 StrictMode 或多窗口重复检测。 */
|
||||
/** 同一客户端生命周期内只请求一次清单;`force` 供「关于」页手动检查使用。 */
|
||||
export function checkForAppUpdate(
|
||||
options: { force?: boolean } = {},
|
||||
): Promise<AppUpdateInfo | null> {
|
||||
// 开发态(`agc` 启动)默认关闭更新检查:不请求清单,也不显示更新入口。
|
||||
if (!appUpdateCheckEnabled) return Promise.resolve(null);
|
||||
if (options.force) updateCheckPromise = null;
|
||||
if (!updateCheckPromise) {
|
||||
updateCheckPromise = fetchUpdateManifest()
|
||||
.then((manifest) => {
|
||||
const update =
|
||||
manifest && isNewerVersion(manifest.version, APP_VERSION)
|
||||
? { ...manifest, currentVersion: APP_VERSION }
|
||||
: null;
|
||||
updateListeners.forEach((listener) => listener(update));
|
||||
return update;
|
||||
})
|
||||
.catch(() => null);
|
||||
}
|
||||
updateCheckPromise ??= runAppUpdateCheck();
|
||||
return updateCheckPromise;
|
||||
}
|
||||
|
||||
@@ -129,28 +63,45 @@ export function subscribeToAppUpdate(
|
||||
return () => updateListeners.delete(listener);
|
||||
}
|
||||
|
||||
export async function downloadAppUpdate(
|
||||
downloadUrl: string,
|
||||
integrity: Pick<AppUpdateManifest, 'sha256' | 'size'> = {},
|
||||
/**
|
||||
* 下载并安装最近一次检测到的更新。
|
||||
*
|
||||
* Windows 上安装程序接管后客户端退出并由安装程序重启;macOS / Linux 在安装完成后由本函数重启进程。
|
||||
*/
|
||||
export async function installAppUpdate(
|
||||
onProgress: (progress: AppUpdateProgress) => void = () => undefined,
|
||||
) {
|
||||
const url = new URL(downloadUrl);
|
||||
if (url.protocol !== 'https:') throw new Error('更新下载地址必须使用 HTTPS');
|
||||
if (typeof window !== 'undefined' && window.__TAURI__) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (invoke) {
|
||||
return await invoke<string>('download_agc_update', {
|
||||
downloadUrl: url.toString(),
|
||||
expectedSha256: integrity.sha256,
|
||||
expectedSize: integrity.size,
|
||||
});
|
||||
} else {
|
||||
await openUrl(url.toString());
|
||||
const update = pendingUpdate;
|
||||
if (!update) throw new Error('没有可安装的更新');
|
||||
let downloadedBytes = 0;
|
||||
let totalBytes: number | undefined;
|
||||
const report = () =>
|
||||
onProgress({
|
||||
downloadedBytes,
|
||||
...(totalBytes ? { totalBytes } : {}),
|
||||
});
|
||||
await update.downloadAndInstall((event: DownloadEvent) => {
|
||||
if (event.event === 'Started') {
|
||||
downloadedBytes = 0;
|
||||
totalBytes = event.data.contentLength;
|
||||
} else if (event.event === 'Progress') {
|
||||
downloadedBytes += event.data.chunkLength;
|
||||
}
|
||||
return;
|
||||
}
|
||||
window.open(url.toString(), '_blank', 'noopener,noreferrer');
|
||||
report();
|
||||
});
|
||||
// 失败时保留待装更新,让「重试」仍能走同一条安装链路。
|
||||
pendingUpdate = null;
|
||||
restartAppAfterUpdate();
|
||||
}
|
||||
|
||||
function restartAppAfterUpdate() {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) return;
|
||||
// Windows 的 install 已在启动安装程序后退出进程,这里只覆盖 macOS / Linux 的重启收敛。
|
||||
void invoke('restart_agc_app').catch(() => undefined);
|
||||
}
|
||||
|
||||
export function resetAppUpdateCheckForTests() {
|
||||
pendingUpdate = null;
|
||||
updateCheckPromise = null;
|
||||
}
|
||||
|
||||
@@ -1,47 +1,137 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { check } from '@tauri-apps/plugin-updater';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import {
|
||||
isNewerVersion,
|
||||
parseAppUpdateManifest,
|
||||
resetAppUpdateCheckForTests,
|
||||
} from '../src/services/appUpdate';
|
||||
vi.mock('@tauri-apps/plugin-updater', () => ({ check: vi.fn() }));
|
||||
|
||||
afterEach(() => resetAppUpdateCheckForTests());
|
||||
const checkMock = vi.mocked(check);
|
||||
|
||||
describe('AGC update manifest', () => {
|
||||
it('compares semantic versions and accepts v prefixes', () => {
|
||||
expect(isNewerVersion('v0.1.13', '0.1.12')).toBe(true);
|
||||
expect(isNewerVersion('0.1.12', '0.1.12')).toBe(false);
|
||||
expect(isNewerVersion('0.1.11', '0.1.12')).toBe(false);
|
||||
type FakeDownloadEvent =
|
||||
| { event: 'Started'; data: { contentLength?: number } }
|
||||
| { event: 'Progress'; data: { chunkLength: number } }
|
||||
| { event: 'Finished' };
|
||||
|
||||
function fakeUpdate() {
|
||||
return {
|
||||
version: '99.0.0',
|
||||
currentVersion: '0.1.47',
|
||||
body: '修复与改进',
|
||||
downloadAndInstall: vi.fn(
|
||||
async (onEvent: (event: FakeDownloadEvent) => void) => {
|
||||
onEvent({ event: 'Started', data: { contentLength: 100 } });
|
||||
onEvent({ event: 'Progress', data: { chunkLength: 40 } });
|
||||
onEvent({ event: 'Progress', data: { chunkLength: 60 } });
|
||||
onEvent({ event: 'Finished' });
|
||||
},
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function stubTauriWindow() {
|
||||
const invoke = vi.fn(async () => undefined);
|
||||
vi.stubGlobal('window', { __TAURI__: { core: { invoke } } });
|
||||
return invoke;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
vi.resetModules();
|
||||
checkMock.mockReset();
|
||||
});
|
||||
|
||||
describe('AGC 客户端更新', () => {
|
||||
it('开发态开关关闭时不请求清单', async () => {
|
||||
vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '0');
|
||||
vi.resetModules();
|
||||
const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import(
|
||||
'../src/services/appUpdate'
|
||||
);
|
||||
|
||||
await expect(checkForAppUpdate()).resolves.toBeNull();
|
||||
expect(checkMock).not.toHaveBeenCalled();
|
||||
resetAppUpdateCheckForTests();
|
||||
});
|
||||
|
||||
it('validates an OSS manifest and rejects non-HTTPS downloads', () => {
|
||||
expect(
|
||||
parseAppUpdateManifest({
|
||||
version: '0.1.13',
|
||||
downloadUrl: 'https://oss.example/agc.exe',
|
||||
}),
|
||||
).toMatchObject({
|
||||
version: '0.1.13',
|
||||
downloadUrl: 'https://oss.example/agc.exe',
|
||||
it('开关打开时把插件返回的更新映射给界面,且同一生命周期只查一次', async () => {
|
||||
vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1');
|
||||
vi.resetModules();
|
||||
checkMock.mockResolvedValue(fakeUpdate() as never);
|
||||
const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import(
|
||||
'../src/services/appUpdate'
|
||||
);
|
||||
|
||||
await expect(checkForAppUpdate()).resolves.toEqual({
|
||||
version: '99.0.0',
|
||||
currentVersion: '0.1.47',
|
||||
releaseNotes: '修复与改进',
|
||||
});
|
||||
expect(
|
||||
parseAppUpdateManifest({
|
||||
version: '0.1.13',
|
||||
downloadUrl: 'http://oss.example/agc.exe',
|
||||
}),
|
||||
).toBeNull();
|
||||
await checkForAppUpdate();
|
||||
expect(checkMock).toHaveBeenCalledTimes(1);
|
||||
resetAppUpdateCheckForTests();
|
||||
});
|
||||
|
||||
it('preserves multiline release notes', () => {
|
||||
expect(
|
||||
parseAppUpdateManifest({
|
||||
version: '0.1.13',
|
||||
downloadUrl: 'https://oss.example/agc.exe',
|
||||
releaseNotes: '第一行\n第二行\r\n第三行',
|
||||
}),
|
||||
).toMatchObject({
|
||||
releaseNotes: '第一行\n第二行\r\n第三行',
|
||||
});
|
||||
it('清单缺失或网络失败时静默按无更新收口', async () => {
|
||||
vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1');
|
||||
vi.resetModules();
|
||||
checkMock.mockRejectedValue(new Error('updater: manifest 404'));
|
||||
const { checkForAppUpdate, resetAppUpdateCheckForTests } = await import(
|
||||
'../src/services/appUpdate'
|
||||
);
|
||||
|
||||
await expect(checkForAppUpdate()).resolves.toBeNull();
|
||||
resetAppUpdateCheckForTests();
|
||||
});
|
||||
|
||||
it('安装时按下载事件上报进度并在完成后重启进程', async () => {
|
||||
vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1');
|
||||
vi.resetModules();
|
||||
const invoke = stubTauriWindow();
|
||||
const update = fakeUpdate();
|
||||
checkMock.mockResolvedValue(update as never);
|
||||
const { checkForAppUpdate, installAppUpdate, resetAppUpdateCheckForTests } =
|
||||
await import('../src/services/appUpdate');
|
||||
|
||||
await checkForAppUpdate();
|
||||
const progress: Array<{ downloadedBytes: number; totalBytes?: number }> =
|
||||
[];
|
||||
await installAppUpdate((value) => progress.push(value));
|
||||
|
||||
expect(update.downloadAndInstall).toHaveBeenCalledTimes(1);
|
||||
expect(progress).toEqual([
|
||||
{ downloadedBytes: 0, totalBytes: 100 },
|
||||
{ downloadedBytes: 40, totalBytes: 100 },
|
||||
{ downloadedBytes: 100, totalBytes: 100 },
|
||||
{ downloadedBytes: 100, totalBytes: 100 },
|
||||
]);
|
||||
expect(invoke).toHaveBeenCalledWith('restart_agc_app');
|
||||
resetAppUpdateCheckForTests();
|
||||
});
|
||||
|
||||
it('没有待安装更新时安装请求失败关闭', async () => {
|
||||
vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1');
|
||||
vi.resetModules();
|
||||
const { installAppUpdate, resetAppUpdateCheckForTests } = await import(
|
||||
'../src/services/appUpdate'
|
||||
);
|
||||
|
||||
await expect(installAppUpdate()).rejects.toThrow('没有可安装的更新');
|
||||
resetAppUpdateCheckForTests();
|
||||
});
|
||||
|
||||
it('下载失败后仍可重试安装', async () => {
|
||||
vi.stubEnv('VITE_AGC_ENABLE_APP_UPDATE_CHECK', '1');
|
||||
vi.resetModules();
|
||||
const update = fakeUpdate();
|
||||
update.downloadAndInstall.mockRejectedValue(new Error('下载更新失败'));
|
||||
checkMock.mockResolvedValue(update as never);
|
||||
const { checkForAppUpdate, installAppUpdate, resetAppUpdateCheckForTests } =
|
||||
await import('../src/services/appUpdate');
|
||||
|
||||
await checkForAppUpdate();
|
||||
await expect(installAppUpdate()).rejects.toThrow('下载更新失败');
|
||||
// 重试仍能拿到待装更新,而不是报“没有可安装的更新”。
|
||||
await expect(installAppUpdate()).rejects.toThrow('下载更新失败');
|
||||
expect(update.downloadAndInstall).toHaveBeenCalledTimes(2);
|
||||
resetAppUpdateCheckForTests();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, test } from 'vitest';
|
||||
|
||||
import {
|
||||
agcAppUpdateCheckEnvKey,
|
||||
withAgcDevFeatureFlags,
|
||||
} from '../scripts/dev-feature-flags.mjs';
|
||||
|
||||
describe('AGC dev 特性开关环境', () => {
|
||||
test('未显式配置时下发关闭检测更新的默认值', () => {
|
||||
expect(withAgcDevFeatureFlags({ KEEP_ME: 'yes' })).toMatchObject({
|
||||
KEEP_ME: 'yes',
|
||||
[agcAppUpdateCheckEnvKey]: '0',
|
||||
});
|
||||
});
|
||||
|
||||
test('保留显式配置的开关取值,忽略空白取值', () => {
|
||||
expect(
|
||||
withAgcDevFeatureFlags({ [agcAppUpdateCheckEnvKey]: '1' }),
|
||||
).toMatchObject({ [agcAppUpdateCheckEnvKey]: '1' });
|
||||
expect(
|
||||
withAgcDevFeatureFlags({ [agcAppUpdateCheckEnvKey]: ' ' }),
|
||||
).toMatchObject({ [agcAppUpdateCheckEnvKey]: '0' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveAppUpdateCheckEnabled } from '../src/app/featureFlags';
|
||||
|
||||
describe('AGC 客户端特性开关', () => {
|
||||
it('开发态(agc 启动)默认关闭检测更新', () => {
|
||||
expect(resolveAppUpdateCheckEnabled('', true)).toBe(false);
|
||||
});
|
||||
|
||||
it('正式包默认开启检测更新', () => {
|
||||
expect(resolveAppUpdateCheckEnabled('', false)).toBe(true);
|
||||
});
|
||||
|
||||
it('显式配置的开关优先于环境默认值', () => {
|
||||
expect(resolveAppUpdateCheckEnabled('1', true)).toBe(true);
|
||||
expect(resolveAppUpdateCheckEnabled('0', false)).toBe(false);
|
||||
});
|
||||
|
||||
it('忽略无法识别的开关取值并回落到环境默认值', () => {
|
||||
expect(resolveAppUpdateCheckEnabled('2', true)).toBe(false);
|
||||
expect(resolveAppUpdateCheckEnabled(' ', false)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
# 【实施计划】AGC 客户端更新切换到官方更新插件
|
||||
|
||||
| 字段 | 值 |
|
||||
| --------- | ----------------------------------------------------------------------------------- |
|
||||
| Milestone | `docs/project-memory/plans/【里程碑】AGC客户端更新切换到官方更新插件-2026-09-17.md` |
|
||||
| Status | ready |
|
||||
| Owner | Codex |
|
||||
|
||||
## 修改边界
|
||||
|
||||
- 允许修改:AGC 客户端原生侧(依赖、插件注册、更新相关命令与其测试)、AGC 前端更新服务与更新提示、「关于」页检查入口、capability 与 Tauri 配置、AGC 客户端测试、主规范与开发运维文档。
|
||||
- 明确不修改:发布脚本与 Jenkins(渠道化属于下一个里程碑)、OSS 对象布局、SpacetimeDB、`/api/external/v1`、网站与其它 App。
|
||||
|
||||
## 实现顺序
|
||||
|
||||
1. 生成发布签名密钥对:私钥落在仓库外 `%USERPROFILE%\.tauri\`,公钥写入客户端配置(公钥发布后不可更换)。
|
||||
2. 原生侧:加入官方更新插件依赖并注册;删除自研更新下载命令、下载进度事件、安装器启动逻辑与其专属测试;新增供 macOS 安装后重启的应用命令。
|
||||
3. 配置与权限:打开更新产物生成,写入公钥、渠道端点(默认 Windows 渠道)与 Windows 静默安装模式;capability 增加更新权限,并移除只为自研清单放行的 OSS 白名单与 CSP 连接项。
|
||||
4. 前端:更新服务改为调用官方插件(检查、下载、进度、安装、重启收敛),删除自研清单解析、版本比较与下载实现;更新提示改用插件进度回调;保留开发态特性开关语义。
|
||||
5. 测试:改写更新服务定向用例(开关关闭不发请求、更新元数据映射、失败静默、进度与重启、无待装更新时失败关闭)。
|
||||
6. 文档:更新技术方案与开发运维说明,删除自研链路描述。
|
||||
|
||||
## 验证命令
|
||||
|
||||
1. `npm --prefix apps/ai-game-creator-shell run typecheck`(含 `check-config.mjs` 与 skill-pack 校验)
|
||||
2. `npx vitest run apps/ai-game-creator-shell/tests/appUpdate.test.ts apps/ai-game-creator-shell/tests/featureFlags.test.ts apps/ai-game-creator-shell/tests/dev-feature-flags.test.ts`
|
||||
3. `cargo check --manifest-path apps/ai-game-creator-shell/src-tauri/Cargo.toml`
|
||||
4. `npx eslint` / `npx prettier --check`(改动文件)
|
||||
5. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check`
|
||||
6. 运行时:`npm run agc` 启动不产生更新清单请求;检索确认自研命令、事件与白名单条目无残留。
|
||||
|
||||
## 风险与回滚点
|
||||
|
||||
- 公钥不可更换:密钥已生成但尚未发布任何签名版本,若需要带密码的私钥仍可在首次发布前重新生成。
|
||||
- Windows 安装模式由插件配置决定(本里程碑固定 `quiet`,与旧 PowerShell `/S` 一致);若改为 `passive` 会多出安装进度条 UI。
|
||||
- 插件在 Windows 上安装成功后自行退出进程,前端不再有机会更新界面;提示面板的完成态只在 macOS / Linux 可见。
|
||||
- 回滚点:改动集中在客户端与配置,回滚后即可退回自研链路;旧 OSS `agc/latest.json` 在发布管线渠道化前不删除。
|
||||
@@ -0,0 +1,37 @@
|
||||
# 【实施计划】AGC 更新发布管线渠道化
|
||||
|
||||
| 字段 | 值 |
|
||||
| --------- | ------------------------------------------------------------------------- |
|
||||
| Milestone | `docs/project-memory/plans/【里程碑】AGC更新发布管线渠道化-2026-09-17.md` |
|
||||
| Status | ready |
|
||||
| Owner | Codex |
|
||||
|
||||
## 修改边界
|
||||
|
||||
- 允许修改:AGC 发布脚本(`apps/ai-game-creator-shell/scripts/build-release.mjs`、`release-upload.mjs` 及其测试)、AGC 发布流水线 `jenkins/Jenkinsfile.ai-game-creator-shell-build`、开发运维与技术方案文档。
|
||||
- 明确不修改:客户端插件接入与前端更新服务(上一里程碑已完成)、SpacetimeDB、`/api/external/v1`、网站与其它 App、其它 Jenkins Job。
|
||||
- 不执行 OSS 上传:本里程碑只交付脚本、流水线定义与本地可验证产物;真实发布需要单独授权与凭据。
|
||||
|
||||
## 实现顺序
|
||||
|
||||
1. 发布脚本:解析并校验渠道(渠道与目标平台绑定,未显式指定时按平台取默认渠道),把渠道写进远端清单地址与构建期端点配置。
|
||||
2. 清单生成:按渠道产出官方更新插件清单(版本、发布说明、发布时间、平台键与签名),universal macOS 产物同时挂两个平台键;缺少签名或签名为空时失败关闭。
|
||||
3. 迁移桥:Windows 渠道额外产出旧协议 sha256 清单,指向同一渠道的最新安装包,供已发布客户端升级到新协议。
|
||||
4. 上传:按渠道写版本目录(安装包与签名)与渠道 latest 指针,旧协议指针单独覆盖写。
|
||||
5. 流水线:新增渠道参数与签名凭据注入,归档安装包、签名、渠道清单与 commit。
|
||||
6. 测试与文档:更新发布脚本单测(渠道校验、清单结构、签名缺失失败关闭、旧协议清单),同步开发运维与技术方案。
|
||||
|
||||
## 验证命令
|
||||
|
||||
1. `node --test apps/ai-game-creator-shell/scripts/build-release.test.mjs apps/ai-game-creator-shell/scripts/cargo-features.test.mjs`
|
||||
2. 本地清单 smoke:伪造 bundle 目录 + 真实签名私钥,断言渠道清单与旧协议清单结构、缺少签名时失败关闭
|
||||
3. `npm --prefix apps/ai-game-creator-shell run typecheck`
|
||||
4. `npm run ai-game-creator-shell:build -- --no-bundle`(渠道端点注入后的构建 smoke;不改版本、不读远端清单、不生成清单)
|
||||
5. `npm run check:encoding`、`npm run check:doc-index`、`git diff --check`、prettier 与 eslint(改动文件)
|
||||
|
||||
## 风险与回滚点
|
||||
|
||||
- 版本递增按渠道独立:`dev-win` 与 `dev-mac` 的清单地址不同,互不影响;旧协议指针只由 `dev-win` 写入。
|
||||
- 签名缺失即失败关闭:构建机未注入签名私钥时发布中止,不产生半成品清单。
|
||||
- 渠道端点写进产物:渠道名一旦发布不可改名(改名等于已发布客户端再也找不到更新)。
|
||||
- 回滚点:发布脚本与流水线都在本里程碑内,回滚后客户端仍可用原先的自研清单协议;迁移桥可独立停用。
|
||||
@@ -0,0 +1,46 @@
|
||||
# 【里程碑】AGC macOS 渠道更新落地
|
||||
|
||||
| 字段 | 值 |
|
||||
| ----------- | ------------------------------------------------------------------ |
|
||||
| Version | 1.0 |
|
||||
| Status | deferred |
|
||||
| Date | 2026-09-17 |
|
||||
| Parent Spec | `docs/technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md` |
|
||||
|
||||
## 目标
|
||||
|
||||
`dev-mac` 渠道可产出并发布 macOS 更新包,客户端在 macOS 上完成检查、安装与重启接管新版本。
|
||||
|
||||
## 范围
|
||||
|
||||
- macOS 更新产物:按 universal 目标构建(Intel 与 Apple Silicon 共用一个包),更新包与其签名按渠道约定生成并上传,清单把同一对象挂到两个 macOS 平台键。
|
||||
- macOS 安装后的重启收敛:安装完成后由客户端重启进程运行新版本,不依赖安装程序代为重启。
|
||||
- macOS 代码签名与公证依赖的确认与记录:未签名或未公证的产物视为不可发布。
|
||||
- macOS 构建执行环境(本机 mac 或新增 macOS 节点)与渠道发布的衔接方式。
|
||||
|
||||
## 不在范围内
|
||||
|
||||
- Windows 渠道行为调整。
|
||||
- 微软商店或 App Store 分发。
|
||||
- 更新包体积优化与增量更新。
|
||||
|
||||
## 依赖与前置条件
|
||||
|
||||
- 客户端插件化与发布管线渠道化两个里程碑已验收。
|
||||
- macOS 签名证书与公证凭据可用;若不满足,本里程碑只能交付构建与清单能力,并明确标注未验证项。
|
||||
- macOS 通用包所需的双架构工具链(两个 darwin 目标)在构建机上可用。
|
||||
|
||||
本里程碑暂缓执行:macOS 构建机与签名 / 公证凭据尚未就绪,改由后续独立变更承接;暂缓期间 dev-mac 渠道不发布。
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] `dev-mac` 渠道清单包含两个 macOS 平台条目且指向同一个 universal 安装包与签名,对象在 OSS 上一致可下载。
|
||||
- [ ] macOS 客户端能完成一次真实更新:检查、下载、安装、重启后运行新版本,且升级后产物仍是 universal 包。
|
||||
- [ ] 覆盖写渠道 latest 指针后,旧版本 macOS 客户端可升级到新版本;Windows 与 macOS 渠道互不干扰。
|
||||
- [ ] 未签名或未公证产物在发布阶段失败关闭,或在不满足条件时明确记录为未验证项而非静默通过。
|
||||
|
||||
## 证据要求
|
||||
|
||||
- 自动化:macOS 更新产物选择与清单生成用例、仓库门禁。
|
||||
- 运行时:macOS 上一次真实更新闭环(含重启后版本核对),OSS 对象与清单核对。
|
||||
- 边界:签名校验失败、公证缺失、渠道缺少 macOS 平台条目、跨架构不匹配时的表现。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user