Merge branch 'master' into codex/slice-mode-explicit-decision-20260917
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Successful in 7m44s
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Successful in 7m53s
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Failing after 7m58s
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Failing after 7m59s
Project CI / Native shell tests (pull_request) Failing after 1m21s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 2m13s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 2m40s
Project CI / Frontend tests (pull_request) Failing after 3m5s
Project CI / Repository checks (pull_request) Successful in 3m36s
Project CI / AI game creator shell web tests (pull_request) Failing after 3m18s
Project CI / Backend tests (pull_request) Successful in 8m28s

This commit is contained in:
2026-09-17 18:10:24 +08:00
117 changed files with 6468 additions and 781 deletions
+3
View File
@@ -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",
@@ -57,6 +58,7 @@
"react-colorful": "^5.8.0",
"react-dom": "^19.0.0",
"react-markdown": "^10.1.0",
"react-window": "^1.8.11",
"rehype-highlight": "^7.0.2",
"remark-gfm": "^4.0.1",
"vite": "^6.2.0",
@@ -70,6 +72,7 @@
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/react-window": "^1.8.8",
"tailwindcss": "^4.1.14",
"typescript": "~5.8.2",
"vitest": "^0.34.6"
@@ -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
View File
@@ -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"
@@ -6,6 +6,9 @@ publish = false
[features]
default = []
# 模板库假数据注入(仅本地页面压测/演示用):只有显式开启该 feature 才会编译并在读取清单后
# 把条目循环补齐成假数据;计数由 AGC_TEMPLATE_LIBRARY_SYNTHETIC_COUNT 控制(默认 1000)。
template-library-fixtures = []
cocos-editor = ["cocos-editor-bridge/process-discovery"]
cocos-editor-execute = ["cocos-editor", "cocos-editor-bridge/windows-bootstrap"]
cocos-editor-injection = ["cocos-editor-execute", "cocos-editor-bridge/windows-injection"]
@@ -47,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 }
@@ -55,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"
]
+14 -223
View File
@@ -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。
@@ -284,6 +113,7 @@ mod resource_inspect;
mod resource_preview_scheduler;
mod runner;
mod swarm_cli;
mod template_library;
mod tool_plan_handoff;
mod user_input;
mod windows;
@@ -323,6 +153,7 @@ use resource_inspect::*;
use resource_preview_scheduler::*;
use runner::*;
use swarm_cli::*;
use template_library::*;
use user_input::*;
use windows::*;
#[tauri::command]
@@ -2557,6 +2388,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())
@@ -2663,7 +2495,10 @@ fn main() {
start_game_creator_external_mcp,
stop_game_creator_external_mcp,
create_automatic_local_game_project,
create_automatic_local_game_project_from_template,
init_local_game_project,
fetch_game_template_library,
download_game_template,
import_local_godot_project,
import_local_cocos_project,
is_local_project_directory_non_empty,
@@ -2839,7 +2674,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,
@@ -3011,50 +2846,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;
File diff suppressed because it is too large Load Diff
@@ -24,13 +24,14 @@
}
],
"security": {
"csp": "default-src 'self' customprotocol: asset:; img-src 'self' asset: data: blob:; 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:; 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,133 @@
{
"schemaVersion": "agc-template-library.v1",
"library": "agc-game-templates",
"libraryVersion": 1,
"updatedAt": "2026-09-17T03:22:43Z",
"templates": [
{
"id": "blank-2d-canvas",
"title": "空白二维画布工程",
"summary": "原生 Canvas 二维空白工程:自适应画布、按设备像素比缩放与 requestAnimationFrame 主循环已就绪。",
"tags": [
"空白",
"起步工程",
"2d",
"canvas"
],
"runtime": "html",
"engine": "canvas",
"engineVersion": "",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T03:22:43Z",
"entry": "game/index.html",
"zipKey": "templates/v1/blank-2d-canvas/template.zip",
"zipSizeBytes": 1534,
"zipSha256": "ff8f84e4793941acaf161738c2795f65c5d5390de8614f51aa9e3a5771767134",
"coverKey": "templates/v1/blank-2d-canvas/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "afb753dc6d3de0f9fb6e03ec94f2be7221dd04c9d4ab3cf92311879f0af25192",
"metadataKey": "templates/v1/blank-2d-canvas/template.json"
},
{
"id": "blank-3d-scene",
"title": "空白三维场景工程",
"summary": "Three.js 空白场景:空场景、透视相机、网格地面与自适应视口已就绪,适合从零搭三维玩法。",
"tags": [
"空白",
"起步工程",
"3d",
"three.js"
],
"runtime": "html",
"engine": "three.js",
"engineVersion": "0.180.0",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T03:22:43Z",
"entry": "game/index.html",
"zipKey": "templates/v1/blank-3d-scene/template.zip",
"zipSizeBytes": 1644,
"zipSha256": "f3f295f4e5adcf1445d75229dc1b583376a9bc96d3f27a257d69ee3b7cace892",
"coverKey": "templates/v1/blank-3d-scene/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "1429232adaf6df4457e45b4fc8d7ee9fab2e6bffce9f3f0016e91b81bb66c6a7",
"metadataKey": "templates/v1/blank-3d-scene/template.json"
},
{
"id": "blank-web",
"title": "空白网页工程",
"summary": "最小网页工程(HTML + CSS + 原生 JS + Vite),没有任何引擎依赖,适合从零写玩法。",
"tags": [
"空白",
"起步工程",
"网页",
"原生"
],
"runtime": "html",
"engine": "none",
"engineVersion": "",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T03:22:43Z",
"entry": "game/index.html",
"zipKey": "templates/v1/blank-web/template.zip",
"zipSizeBytes": 1212,
"zipSha256": "6fa4391f30342e8dcbdcf735f990d2534ea50405f119e4fa5879b83e8f00119e",
"coverKey": "templates/v1/blank-web/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "326a2753386618971b1311043effa46c2e74bc1cfb116862c0ee4097dcd5086a",
"metadataKey": "templates/v1/blank-web/template.json"
},
{
"id": "phaser-2d-starter",
"title": "Phaser 2D 起步工程",
"summary": "AGC 新建项目使用的默认二维起步工程(Phaser 4 + Vite),解压后即为可运行项目根。",
"tags": [
"起步工程",
"2d",
"phaser",
"像素"
],
"runtime": "html",
"engine": "phaser",
"engineVersion": "4.2.1",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T03:22:43Z",
"entry": "game/index.html",
"zipKey": "templates/v1/phaser-2d-starter/template.zip",
"zipSizeBytes": 8770,
"zipSha256": "9026856c3c0b3a42401172e36ce8b450a65e9f11eb9096624d8990d51449d8ce",
"coverKey": "templates/v1/phaser-2d-starter/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "fdb422027bf54bf755b2b91cd21fa10fdd7ce3f5c7e3ccd9ac3ffba602b12b96",
"metadataKey": "templates/v1/phaser-2d-starter/template.json"
},
{
"id": "threejs-3d-starter",
"title": "Three.js 3D 起步工程",
"summary": "网页三维起步工程(Three.js + Vite),自带可旋转立方体场景、方向光与自适应视口。",
"tags": [
"起步工程",
"3d",
"three.js",
"网页"
],
"runtime": "html",
"engine": "three.js",
"engineVersion": "0.180.0",
"templateVersion": "0.1.0",
"updatedAt": "2026-09-17T03:22:43Z",
"entry": "game/index.html",
"zipKey": "templates/v1/threejs-3d-starter/template.zip",
"zipSizeBytes": 1697,
"zipSha256": "03096152b17cd6d55e7f6ccd518485136133cb54fd2a5a5d0ac8e0974149155c",
"coverKey": "templates/v1/threejs-3d-starter/cover.svg",
"coverWidth": 960,
"coverHeight": 540,
"coverSha256": "7ba013e8a8b515d7aff7146fe401afe5ba3416b9c69db181179705e1bce01beb",
"metadataKey": "templates/v1/threejs-3d-starter/template.json"
}
]
}
@@ -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));
@@ -1,8 +1,15 @@
import { getCurrentWindow } from '@tauri-apps/api/window';
import { Copy, Minus, Square, X } from 'lucide-react';
import { type ReactNode, useCallback, useEffect, useState } from 'react';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} 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';
@@ -49,14 +56,21 @@ export function WindowChrome({ children }: WindowChromeProps) {
setTitleState(normalizedTitle || WINDOW_CHROME_DEFAULT_TITLE);
}, []);
const contextValue: WindowChromeContextValue = {
isWindowChrome: true,
title,
setTitle,
walletSlot,
activeProjectRuns,
setActiveProjectRuns,
};
/**
* context value 必须 memo:内联对象会让所有 `useWindowChrome()` 消费方在标题栏
* 每次渲染时都重新拿到新对象,进而连带重跑它们依赖 context 的 effect。
*/
const contextValue = useMemo<WindowChromeContextValue>(
() => ({
isWindowChrome: true,
title,
setTitle,
walletSlot,
activeProjectRuns,
setActiveProjectRuns,
}),
[title, setTitle, walletSlot, activeProjectRuns],
);
const [isMaximized, setIsMaximized] = useState(false);
@@ -133,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">
@@ -38,6 +38,14 @@ export function useDirectActiveTurns({
const [snapshotReadFailed, setSnapshotReadFailed] = useState(false);
const mountedRef = useRef(true);
const inFlightRef = useRef<Promise<void> | null>(null);
/**
* 上一次成功读取到的快照签名。
*
* 轮询每 5 秒跑一次,如果每次都 `setActiveTurns(新数组)`,即使内容一模一样也会
* 换掉数组身份:所有依赖 `activeTurns` 的 effect 都会跟着重跑(窗口标题栏的活动项目
* 面板就是这么被反复重发布的)。这里只在内容真的变了才更新状态。
*/
const lastSnapshotSignatureRef = useRef<string>('');
const retryTimerRef = useRef<number | null>(null);
useEffect(() => {
@@ -72,7 +80,12 @@ export function useDirectActiveTurns({
if (!mountedRef.current) {
return;
}
setActiveTurns(Array.isArray(turns) ? turns : []);
const nextTurns = Array.isArray(turns) ? turns : [];
const nextSignature = JSON.stringify(nextTurns);
if (nextSignature !== lastSnapshotSignatureRef.current) {
lastSnapshotSignatureRef.current = nextSignature;
setActiveTurns(nextTurns);
}
setSnapshotReadFailed(false);
inFlightRef.current = null;
return;
@@ -99,8 +112,10 @@ export function useDirectActiveTurns({
useEffect(() => {
if (!enabled || !invoke) {
setActiveTurns([]);
setSnapshotReadFailed(false);
lastSnapshotSignatureRef.current = '';
// 空态也要保持引用稳定:已经空了就不要再换一个新数组。
setActiveTurns((current) => (current.length === 0 ? current : []));
setSnapshotReadFailed((current) => (current ? false : current));
return;
}
void refreshActiveTurns();
@@ -32,8 +32,10 @@ import {
type ProjectManifestSnapshotSource,
rereadAuthoritativeProjectManifestSnapshot,
} from '../../view/project-development/projectResourceLiveUpdateModel';
import TemplateLibraryView from '../../view/template-library';
import { useDirectActiveTurns } from '../agent-runtime/directActiveTurns';
import { RuntimeConfigDialog } from '../runtime-config/RuntimeConfigDialog';
import { useTemplateLibrary } from '../template-library/useTemplateLibrary';
import { AccountWalletBar, AccountWalletDialogs } from './AccountWallet';
import {
DeveloperAgentDialogs,
@@ -90,6 +92,11 @@ export function WorkspaceLauncherShell({
setAgentChatProjectPath: developerAgent.setAgentChatProjectPath,
rememberRecentWorkspace,
});
const templateLibrary = useTemplateLibrary({
onProjectCreated: async (result) => {
await homeProject.enterCreatedTemplateProject(result);
},
});
const {
projectPath,
setProjectPath,
@@ -222,17 +229,29 @@ export function WorkspaceLauncherShell({
[setProjectPath],
);
/**
* 项目卡片面板发布给窗口标题栏的回调必须走 ref。
*
* `openProject` 来自 `useHomeProjectCreation` 的普通函数(每次渲染都是新身份),
* 所以 `openActiveProject` 的引用每渲染都变;如果它进 effect 依赖,就会变成
* 「effect 每渲染重跑 → cleanup/setActiveProjectRuns 改 WindowChrome 状态 → 重新渲染」
* 的无限 setState 循环(React 报 `Maximum update depth exceeded`)。
* 这里只让 effect 依赖真正的数据,回调通过 ref 取最新实现。
*/
const openActiveProjectRef = useRef(openActiveProject);
openActiveProjectRef.current = openActiveProject;
useEffect(() => {
setActiveProjectRuns({
activeTurns,
currentProjectPath: currentProjectContext?.projectPath ?? null,
readFailed: snapshotReadFailed,
onOpenProject: openActiveProject,
onOpenProject: (projectPath: string) =>
openActiveProjectRef.current(projectPath),
});
}, [
activeTurns,
currentProjectContext?.projectPath,
openActiveProject,
setActiveProjectRuns,
snapshotReadFailed,
]);
@@ -580,6 +599,13 @@ export function WorkspaceLauncherShell({
void openProject(path, 'open');
}}
onProjectPick={() => void homeProject.pickAndOpenProject()}
templateRecommendations={templateLibrary.templates}
templateLibraryLoading={
templateLibrary.status === 'loading' ||
templateLibrary.status === 'idle'
}
templateLibraryError={templateLibrary.error}
onTemplateLibraryOpen={() => setLauncherView('template-library')}
/>
) : launcherView === 'projects' ? (
<ProjectsPage
@@ -587,6 +613,11 @@ export function WorkspaceLauncherShell({
homeProject={homeProject}
recentProjects={recentProjects}
/>
) : launcherView === 'template-library' ? (
<TemplateLibraryView
controller={templateLibrary}
onBack={() => setLauncherView('home')}
/>
) : launcherView === 'agent-chat' ? (
<DeveloperAgentPanel
controller={developerAgent}
@@ -555,6 +555,35 @@ export function useHomeProjectCreation({
}
}
/**
* 模板库建出的项目:模板文件与项目脚手架已在 Rust 侧一次落盘,
* 这里只负责登记最近项目并走标准进项目通道(含会话预览核验与代次闸门)。
*/
async function enterCreatedTemplateProject(result: InitLocalProjectResult) {
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在陶泥儿客户端内运行');
}
await enterProjectDevelopment({
projectPath: result.projectPath,
projectName:
result.manifest.name || projectNameFromPath(result.projectPath),
projectKind: 'web',
manifest: result.manifest,
projectRevision: await readCurrentProjectRevision(
invoke,
result.projectPath,
),
creationType: null,
startMode: null,
initialPrompt: '',
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
createdAt: Date.now(),
});
}
async function openProject(nextProjectPath: string, mode: 'open' | 'create') {
if (mode === 'create') {
await createProjectFromProjectPage(nextProjectPath);
@@ -1005,6 +1034,7 @@ export function useHomeProjectCreation({
renameProject,
pickAndOpenProject,
pickAndCreateProject,
enterCreatedTemplateProject,
confirmCreateInNonEmptyFolder,
cancelCreateInNonEmptyFolder,
};
@@ -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>
@@ -0,0 +1,96 @@
/**
* 模板库卡片网格的布局计算(纯函数)。
*
* 页面用 `react-window` 的 `FixedSizeGrid` 做虚拟滚动:只渲染可视区域的行,
* 因此这里负责把「容器宽度 + 条目数」换算成列数、列宽、行高与行数,
* 保证卡片尺寸、封面比例与行高完全确定(虚拟列表要求固定行高)。
*/
import type { GameTemplateEntry } from './templateLibraryModel';
/** 卡片最小宽度(与 CSS 里旧的 `minmax(250px,1fr)` 口径一致)。 */
export const TEMPLATE_CARD_MIN_WIDTH = 250;
/** 卡片之间的水平/垂直间隙。 */
export const TEMPLATE_CARD_GAP = 14;
/** 封面宽高比:16:9。 */
export const TEMPLATE_CARD_COVER_RATIO = 9 / 16;
/** 卡片封面以下的文字与按钮区固定高度。 */
export const TEMPLATE_CARD_TEXT_HEIGHT = 150;
/** 额外预渲染的行数,减小快速滚动时的白屏。 */
export const TEMPLATE_GRID_OVERSCAN_ROWS = 2;
export type TemplateGridLayout = {
columnCount: number;
/** FixedSizeGrid 的列宽(含卡片右侧间隙)。 */
columnWidth: number;
/** FixedSizeGrid 的行高(含卡片下方间隙)。 */
rowHeight: number;
rowCount: number;
};
export function computeTemplateGridColumns(containerWidth: number): number {
if (!Number.isFinite(containerWidth) || containerWidth <= 0) {
return 1;
}
const columns = Math.floor(
(containerWidth + TEMPLATE_CARD_GAP) /
(TEMPLATE_CARD_MIN_WIDTH + TEMPLATE_CARD_GAP),
);
return Math.max(1, columns);
}
export function computeTemplateRowHeight(columnWidth: number): number {
const cardWidth = Math.max(
TEMPLATE_CARD_MIN_WIDTH,
Math.round(columnWidth) - TEMPLATE_CARD_GAP,
);
return (
Math.ceil(cardWidth * TEMPLATE_CARD_COVER_RATIO) +
TEMPLATE_CARD_TEXT_HEIGHT +
TEMPLATE_CARD_GAP
);
}
export function computeTemplateGridLayout({
containerWidth,
itemCount,
}: {
containerWidth: number;
itemCount: number;
}): TemplateGridLayout {
const columnCount = computeTemplateGridColumns(containerWidth);
const columnWidth = Math.max(1, Math.floor(containerWidth / columnCount));
return {
columnCount,
columnWidth,
rowHeight: computeTemplateRowHeight(columnWidth),
rowCount: Math.max(0, Math.ceil(Math.max(0, itemCount) / columnCount)),
};
}
/** 按行切分,行尾补 `null` 占位,保证虚拟列表的列索引与条目一一对应。 */
export function buildTemplateRows(
templates: readonly GameTemplateEntry[],
columnCount: number,
): Array<Array<GameTemplateEntry | null>> {
if (columnCount <= 0) {
return [];
}
const rows: Array<Array<GameTemplateEntry | null>> = [];
for (let index = 0; index < templates.length; index += columnCount) {
const row: Array<GameTemplateEntry | null> = [];
for (let column = 0; column < columnCount; column += 1) {
row.push(templates[index + column] ?? null);
}
rows.push(row);
}
return rows;
}
/** 虚拟列表的稳定 key:行内槽位固定,避免筛选后复用错卡片。 */
export function templateGridItemKey(
rowIndex: number,
columnIndex: number,
): string {
return `template-cell-${rowIndex}-${columnIndex}`;
}
@@ -0,0 +1,208 @@
/**
* AGC 模板库的前端模型:清单类型、搜索与筛选的纯函数。
*
* 真源在 OSS 清单与 Rust 侧(`fetch_game_template_library`);这里只做展示层派生,
* 不缓存业务真相,也不拼远端地址(URL 由 Rust 侧按受信任 OSS 前缀给出)。
*/
export type GameTemplateLibrarySource = 'network' | 'cache';
export type GameTemplateEntry = {
id: string;
title: string;
summary: string;
tags: string[];
runtime: string;
engine: string;
engineVersion: string;
templateVersion: string;
updatedAt: string;
entry: string;
zipUrl: string;
zipSizeBytes: number;
zipSha256: string;
coverUrl: string;
coverWidth: number;
coverHeight: number;
installed: boolean;
installedVersion: string | null;
installedAtMillis: number | null;
};
export type GameTemplateLibrarySnapshot = {
schemaVersion: string;
library: string;
libraryVersion: number;
updatedAt: string;
fetchedAtMillis: number;
source: GameTemplateLibrarySource;
templates: GameTemplateEntry[];
};
export type InstalledGameTemplate = {
templateId: string;
templateVersion: string;
installedAtMillis: number;
zipSha256: string;
fileCount: number;
projectDir: string;
};
export type TemplateLibraryFilters = {
query: string;
tags: readonly string[];
runtime: string;
installedOnly: boolean;
};
export const EMPTY_TEMPLATE_LIBRARY_FILTERS: TemplateLibraryFilters = {
query: '',
tags: [],
runtime: '',
installedOnly: false,
};
const RUNTIME_LABELS: Record<string, string> = {
html: '网页',
unity: 'Unity',
godot: 'Godot',
cocos: 'Cocos',
};
export function templateRuntimeLabel(runtime: string): string {
const normalized = runtime.trim().toLowerCase();
if (!normalized) return '未标注运行时';
return RUNTIME_LABELS[normalized] ?? runtime.trim();
}
/**
* 空白分隔的多个关键词之间是「与」关系:每个词都必须命中标题、简介、标签或引擎,
* 这样「三消 像素」不会退化成命中任意一个就出现的宽泛搜索。
*/
export function templateMatchesQuery(
template: GameTemplateEntry,
query: string,
): boolean {
const terms = query
.toLowerCase()
.split(/\s+/u)
.filter((term) => term.length > 0);
if (terms.length === 0) {
return true;
}
const haystack = [
template.title,
template.summary,
template.engine,
template.runtime,
template.tags.join(' '),
]
.join(' ')
.toLowerCase();
return terms.every((term) => haystack.includes(term));
}
export function filterGameTemplates(
templates: readonly GameTemplateEntry[],
filters: TemplateLibraryFilters,
): GameTemplateEntry[] {
const selectedTags = filters.tags
.map((tag) => tag.trim().toLowerCase())
.filter((tag) => tag.length > 0);
const runtime = filters.runtime.trim().toLowerCase();
return templates.filter((template) => {
if (filters.installedOnly && !template.installed) {
return false;
}
if (runtime && template.runtime.trim().toLowerCase() !== runtime) {
return false;
}
if (selectedTags.length > 0) {
const templateTags = template.tags.map((tag) => tag.toLowerCase());
if (!selectedTags.some((tag) => templateTags.includes(tag))) {
return false;
}
}
return templateMatchesQuery(template, filters.query);
});
}
/** 标签按出现次数降序,次数相同按名称排序,保证筛选条顺序稳定。 */
export function collectGameTemplateTags(
templates: readonly GameTemplateEntry[],
): string[] {
const counts = new Map<string, number>();
for (const template of templates) {
for (const tag of template.tags) {
const trimmed = tag.trim();
if (!trimmed) continue;
counts.set(trimmed, (counts.get(trimmed) ?? 0) + 1);
}
}
return [...counts.entries()]
.sort(
([leftTag, leftCount], [rightTag, rightCount]) =>
rightCount - leftCount || leftTag.localeCompare(rightTag, 'zh-CN'),
)
.map(([tag]) => tag);
}
export function collectGameTemplateRuntimes(
templates: readonly GameTemplateEntry[],
): string[] {
const runtimes = new Set<string>();
for (const template of templates) {
const runtime = template.runtime.trim().toLowerCase();
if (runtime) runtimes.add(runtime);
}
return [...runtimes].sort((left, right) =>
left.localeCompare(right, 'zh-CN'),
);
}
export function formatGameTemplateSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes <= 0) {
return '--';
}
if (bytes < 1024) {
return `${Math.round(bytes)} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export function isTemplateLibraryFiltersEmpty(
filters: TemplateLibraryFilters,
): boolean {
return (
!filters.query.trim() &&
filters.tags.length === 0 &&
!filters.runtime.trim() &&
!filters.installedOnly
);
}
export function toggleGameTemplateTag(
filters: TemplateLibraryFilters,
tag: string,
): TemplateLibraryFilters {
const exists = filters.tags.includes(tag);
return {
...filters,
tags: exists
? filters.tags.filter((value) => value !== tag)
: [...filters.tags, tag],
};
}
/**
* 已安装版本低于清单版本时必须重新下载;已安装且版本一致才算可直接使用。
*/
export function needsTemplateDownload(template: GameTemplateEntry): boolean {
return (
!template.installed ||
template.installedVersion !== template.templateVersion
);
}

Some files were not shown because too many files have changed in this diff Show More