完善客户端发布渠道并接入模板库灰度
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m36s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m37s
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m42s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m51s
Project CI / AI game creator shell Rust smoke (push) Successful in 1m55s
Project CI / AI game creator shell Rust crates (push) Successful in 4m15s
Project CI / Repository checks (push) Successful in 4m45s
Project CI / Frontend tests (push) Successful in 7m47s
Project CI / Native shell tests (push) Successful in 9m33s
Project CI / Backend tests (push) Successful in 10m9s
Project CI / AI game creator shell web tests (push) Successful in 4m38s

区分发布渠道与系统,支持 dev、release 和自定义渠道
允许网站通过服务端配置选择客户端下载检测渠道
接入模板库灰度权限并阻断退出和切号后的异步操作
补齐发布、下载、灰度与会话竞态测试及当前规范
This commit is contained in:
kdletters
2026-09-20 12:12:49 +08:00
parent fc0ce4ee5f
commit 623e007fae
30 changed files with 1554 additions and 323 deletions
@@ -154,6 +154,27 @@ test('灰度发布页可通过功能入口生成画布 Agent Gate Key', async ()
);
});
test('灰度发布页可选择模板库并默认启用零比例灰度', async () => {
const user = userEvent.setup();
render(
<AdminGrayReleaseConfigPage token="admin-token" onUnauthorized={vi.fn()} />,
);
await screen.findByRole('button', { name: 'editor.new-toolbar' });
await user.selectOptions(screen.getByLabelText('Gate Key 前缀'), ['agc']);
expect((screen.getByLabelText('Gate Key') as HTMLInputElement).value).toBe(
'agc:template-library',
);
expect(
(screen.getByLabelText('Gate Key 目标') as HTMLSelectElement).value,
).toBe('template-library');
expect((screen.getByLabelText('启用') as HTMLInputElement).checked).toBe(
true,
);
expect((screen.getByLabelText('灰度比例') as HTMLInputElement).value).toBe(
'0',
);
});
test('灰度发布页保存时转换数组和百分比', async () => {
const user = userEvent.setup();
vi.mocked(upsertAdminFeatureGateConfig).mockResolvedValueOnce({
@@ -27,9 +27,17 @@ interface GateTargetOption {
const GATE_PREFIX_LABELS: Record<string, string> = {
'image-editor': '画布',
agc: '客户端',
};
const FIXED_GATE_TARGETS: GateTargetOption[] = [
{
prefix: 'agc',
suffix: 'template-library',
key: 'agc:template-library',
label: '模板库',
description: '客户端模板库灰度',
},
{
prefix: 'image-editor',
suffix: 'agent-sidebar',
@@ -180,7 +188,7 @@ export function AdminGrayReleaseConfigPage({
setSelectedGateKey('');
setGatePrefix(option.prefix);
setGateKey(option.key);
setEnabled(false);
setEnabled(option.key === 'agc:template-library');
setRolloutPercent('0');
setAllowUserIds('');
setAllowUserTags('');
@@ -68,7 +68,7 @@ export function resolveReleaseContext(args = [], env = process.env) {
);
return Object.freeze({
target,
channel: resolveReleaseChannel(env, target),
channel: resolveReleaseChannel(env),
bundleRoot: path.join(
appRoot,
'src-tauri',
@@ -87,14 +87,14 @@ const cargoLockPath = path.join(appRoot, 'src-tauri', 'Cargo.lock');
const defaultOssBaseUrl =
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc';
/**
* 发布渠道 → 目标平台。渠道名会进入 OSS 路径并烘焙进客户端端点,
* 一旦发布就不能改名(改名等于已发布客户端再也找不到更新)。
*/
const releaseChannels = {
'dev-win': 'windows',
'dev-mac': 'darwin',
};
const reservedChannelNames = new Set([
'win',
'mac',
'windows',
'macos',
'darwin',
'linux',
]);
/**
* 影响 Windows 客户端产物的路径。调度管线的发布范围判定与这里的提交摘要必须
@@ -162,39 +162,36 @@ export function resolveReleasePlatform(target = defaultTarget()) {
throw new Error(`不支持的发布目标:${target}`);
}
export function resolveReleaseChannel(
env = process.env,
target = defaultTarget(),
) {
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) {
export function resolveReleaseChannel(env = process.env) {
const channel = env.AGC_UPDATE_CHANNEL?.trim() ?? 'dev';
if (
!/^[a-z][a-z0-9-]{0,31}$/u.test(channel) ||
channel.endsWith('-') ||
reservedChannelNames.has(channel) ||
/-(win|mac)$/u.test(channel)
) {
throw new Error(
`目标 ${target} 没有默认发布渠道,请显式设置 AGC_UPDATE_CHANNEL`,
'发布渠道无效:请使用 dev、release 或最多 32 位的小写字母、数字和连字符名称,系统名称不属于渠道',
);
}
return defaultChannel;
return channel;
}
export function updateManifestUrl(channel = resolveReleaseChannel()) {
return `${ossBaseUrl()}/${channel}/latest.json`;
/** 系统分区延续已发布客户端端点,渠道本身不包含系统。 */
export function resolveReleasePartition(
channel = resolveReleaseChannel(),
target = defaultTarget(),
) {
channel = resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel });
validateReleaseTarget(target);
return `${channel}-${resolveReleasePlatform(target) === 'windows' ? 'win' : 'mac'}`;
}
export function updateManifestUrl(
channel = resolveReleaseChannel(),
target = defaultTarget(),
) {
return `${ossBaseUrl()}/${resolveReleasePartition(channel, target)}/latest.json`;
}
/**
@@ -245,8 +242,8 @@ async function readManifestVersion(manifestUrl, label) {
}
/** 上一次发布的渠道清单:拿版本做高水位、拿 commit 生成自动更新摘要。 */
async function readRemoteChannelManifest(channel = resolveReleaseChannel()) {
return fetchManifest(updateManifestUrl(channel), 'OSS 渠道清单');
async function readRemoteChannelManifest(channel, target) {
return fetchManifest(updateManifestUrl(channel, target), 'OSS 渠道清单');
}
/**
@@ -258,14 +255,17 @@ async function readRemoteChannelManifest(channel = resolveReleaseChannel()) {
*/
export async function resolvePreviousReleaseCommit(
channel = resolveReleaseChannel(),
{ override = process.env.AGC_UPDATE_PREVIOUS_COMMIT } = {},
{
override = process.env.AGC_UPDATE_PREVIOUS_COMMIT,
target = defaultTarget(),
} = {},
) {
const explicit = override?.trim();
if (explicit && /^[0-9a-f]{7,40}$/u.test(explicit)) {
return explicit;
}
try {
const manifest = await readRemoteChannelManifest(channel);
const manifest = await readRemoteChannelManifest(channel, target);
const commit =
typeof manifest?.commit === 'string' ? manifest.commit.trim() : '';
return /^[0-9a-f]{7,40}$/u.test(commit) ? commit : null;
@@ -287,12 +287,14 @@ export async function resolvePreviousReleaseCommit(
*/
export async function resolveRemoteHighWaterVersion(
channel = resolveReleaseChannel(),
target = defaultTarget(),
) {
const channelVersion = await readManifestVersion(
updateManifestUrl(channel),
updateManifestUrl(channel, target),
'OSS 渠道清单',
);
if (channel !== 'dev-win') return channelVersion;
if (channel !== 'dev' || resolveReleasePlatform(target) !== 'windows')
return channelVersion;
const legacyVersion = await readManifestVersion(
legacyBridgeManifestUrl(),
'OSS 迁移指针',
@@ -310,9 +312,9 @@ function replaceVersionLine(source, version, pattern, label) {
}
export async function prepareReleaseVersion(context = resolveReleaseContext()) {
const { channel } = context;
const { channel, target } = context;
const localVersion = parseVersion(readPackageJson().version, '本地版本');
const remoteVersion = await resolveRemoteHighWaterVersion(channel);
const remoteVersion = await resolveRemoteHighWaterVersion(channel, target);
const requestedVersion = process.env.AGC_RELEASE_VERSION?.trim();
const nextVersion = requestedVersion
? parseVersion(requestedVersion, '指定版本')
@@ -401,24 +403,27 @@ export function buildTauriBuildArguments(
}
/** 渠道端点必须由构建期注入:官方更新插件的端点配置不支持运行期改渠道。 */
export function createChannelConfig(channel = resolveReleaseChannel()) {
export function createChannelConfig(
channel = resolveReleaseChannel(),
target = defaultTarget(),
) {
return {
plugins: {
updater: {
endpoints: [updateManifestUrl(channel)],
endpoints: [updateManifestUrl(channel, target)],
},
},
};
}
function writeChannelConfigFile(channel) {
function writeChannelConfigFile(channel, target) {
const configPath = path.join(
os.tmpdir(),
`agc-tauri-channel-${channel}.json`,
`agc-tauri-channel-${channel}-${target}.json`,
);
fs.writeFileSync(
configPath,
`${JSON.stringify(createChannelConfig(channel), null, 2)}\n`,
`${JSON.stringify(createChannelConfig(channel, target), null, 2)}\n`,
);
return configPath;
}
@@ -435,8 +440,8 @@ export function runTauriBuild(
throw new Error('构建参数与发布上下文目标不一致');
}
const tauriArguments = buildTauriBuildArguments(args, context.target);
const { channel } = context;
const configPath = writeChannelConfigFile(channel);
const { channel, target } = context;
const configPath = writeChannelConfigFile(channel, target);
console.log(
`[ai-game-creator-shell] 渠道 ${channel} 端点配置:${configPath}`,
);
@@ -552,7 +557,7 @@ export function createUpdateManifest(
artifactPath,
{
target = defaultTarget(),
channel = resolveReleaseChannel(process.env, target),
channel = resolveReleaseChannel(),
publishedAt = new Date().toISOString(),
notes = readReleaseNotes(),
commit = readHeadCommit(),
@@ -560,7 +565,7 @@ export function createUpdateManifest(
} = {},
) {
validateReleaseTarget(target);
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }, target);
const partition = resolveReleasePartition(channel, target);
const signature = readUpdaterSignature(artifactPath);
const version = readPackageJson().version;
const firstInstallArtifact = selectFirstInstallArtifact(
@@ -568,8 +573,8 @@ export function createUpdateManifest(
{ target, version, artifact: artifactPath },
);
const fileName = path.basename(artifactPath);
const url = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`;
const downloadUrl = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(path.basename(firstInstallArtifact))}`;
const url = `${ossBaseUrl()}/${partition}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`;
const downloadUrl = `${ossBaseUrl()}/${partition}/${encodeURIComponent(version)}/${encodeURIComponent(path.basename(firstInstallArtifact))}`;
const platforms = {};
const downloads = {};
for (const key of resolveManifestPlatformKeys(target)) {
@@ -702,14 +707,22 @@ export function formatRecentReleaseNotes(commits) {
/** 旧协议(sha256)清单:只用于把已发布客户端带到新渠道协议,一个版本周期后整条删除。 */
export function createLegacyUpdateManifest(
artifactPath,
{ channel = resolveReleaseChannel(), notes = readReleaseNotes() } = {},
{
channel = resolveReleaseChannel(),
target = defaultTarget(),
notes = readReleaseNotes(),
} = {},
) {
const partition = resolveReleasePartition(channel, target);
if (partition !== 'dev-win') {
throw new Error('旧协议迁移清单只属于 dev 渠道的 Windows 系统');
}
const bytes = fs.readFileSync(artifactPath);
const version = readPackageJson().version;
const fileName = path.basename(artifactPath);
return {
version,
downloadUrl: `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`,
downloadUrl: `${ossBaseUrl()}/${partition}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`,
sha256: createHash('sha256').update(bytes).digest('hex'),
size: bytes.length,
...(notes ? { releaseNotes: notes } : {}),
@@ -731,7 +744,9 @@ export async function generateUpdateManifest(
artifact,
});
const manualNotes = readReleaseNotes();
const previousCommit = await resolvePreviousReleaseCommit(channel);
const previousCommit = await resolvePreviousReleaseCommit(channel, {
target,
});
const commits = collectReleaseCommits(previousCommit);
const recentCommits = previousCommit ? null : collectRecentReleaseCommits();
const notes =
@@ -757,8 +772,8 @@ export async function generateUpdateManifest(
notes ? `${notes}\n` : '(本次没有可用的更新摘要)\n',
);
const legacyManifest =
channel === 'dev-win'
? createLegacyUpdateManifest(artifact, { channel, notes })
channel === 'dev' && resolveReleasePlatform(target) === 'windows'
? createLegacyUpdateManifest(artifact, { channel, target, notes })
: null;
const legacyManifestPath = legacyManifest
? path.join(bundleRoot, 'legacy-latest.json')
@@ -789,6 +804,7 @@ export async function generateUpdateManifest(
}
return {
channel,
target,
artifact,
downloadArtifact,
manifest,
@@ -30,6 +30,7 @@ import {
resolvePreviousReleaseCommit,
resolveReleaseChannel,
resolveReleaseContext,
resolveReleasePartition,
resolveRemoteHighWaterVersion,
runTauriBuild,
selectFirstInstallArtifact,
@@ -126,32 +127,60 @@ test('does not select unsupported files', () => {
);
});
test('resolves the channel from the target platform and rejects mismatches', () => {
assert.equal(resolveReleaseChannel({}, windowsTarget), 'dev-win');
assert.equal(resolveReleaseChannel({}, universalTarget), 'dev-mac');
test('channels are independent of platform and accept release and custom names', () => {
assert.equal(resolveReleaseChannel({}), 'dev');
for (const channel of ['dev', 'release', 'beta-2', 'a'.repeat(32)]) {
assert.equal(
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }),
channel,
);
assert.equal(
resolveReleasePartition(channel, windowsTarget),
`${channel}-win`,
);
assert.equal(
resolveReleasePartition(channel, 'aarch64-apple-darwin'),
`${channel}-mac`,
);
}
assert.equal(
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, universalTarget),
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: ' release ' }),
'release',
);
for (const channel of [
'',
' ',
'win',
'mac',
'windows',
'macos',
'darwin',
'linux',
'dev-win',
'dev-mac',
);
assert.throws(
() =>
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'dev-mac' }, windowsTarget),
/只能用于 darwin 目标/u,
);
assert.throws(
() =>
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: 'beta-win' }, windowsTarget),
/未知发布渠道/u,
);
'Release',
'../dev',
'a/b',
'a_b',
'-beta',
'beta-',
'1beta',
'a'.repeat(33),
]) {
assert.throws(
() => resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }),
/发布渠道无效/u,
);
}
});
test('channel manifest URL and build-time endpoint follow the channel', () => {
withEnv({ AGC_UPDATE_OSS_BASE_URL: undefined }, () => {
assert.equal(
updateManifestUrl('dev-win'),
updateManifestUrl('dev', windowsTarget),
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/latest.json',
);
assert.deepEqual(createChannelConfig('dev-mac'), {
assert.deepEqual(createChannelConfig('dev', 'aarch64-apple-darwin'), {
plugins: {
updater: {
endpoints: [
@@ -160,6 +189,15 @@ test('channel manifest URL and build-time endpoint follow the channel', () => {
},
},
});
assert.equal(
updateManifestUrl('release', windowsTarget),
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/release-win/latest.json',
);
assert.equal(
createChannelConfig('beta-2', 'x86_64-apple-darwin').plugins.updater
.endpoints[0],
'https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/beta-2-mac/latest.json',
);
});
});
@@ -185,7 +223,7 @@ test('release context resolves explicit targets before environment/default and f
for (const env of [{}, { AGC_BUILD_TARGET: windowsTarget }]) {
const context = resolveReleaseContext(args, env);
assert.equal(context.target, 'aarch64-apple-darwin');
assert.equal(context.channel, 'dev-mac');
assert.equal(context.channel, 'dev');
assert.match(
context.bundleRoot.replaceAll('\\', '/'),
/target\/aarch64-apple-darwin\/release\/bundle$/,
@@ -194,14 +232,14 @@ test('release context resolves explicit targets before environment/default and f
}
assert.throws(
() => resolveReleaseContext(args, { AGC_UPDATE_CHANNEL: 'dev-win' }),
/只能用于 windows/,
/发布渠道无效/,
);
}
assert.equal(resolveReleaseContext([], {}).target, windowsTarget);
assert.equal(
resolveReleaseContext([], { AGC_BUILD_TARGET: 'x86_64-apple-darwin' })
.channel,
'dev-mac',
'dev',
);
for (const args of [
['--target'],
@@ -231,7 +269,10 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
prepareVersion: async (context) => {
seenContexts.push(context);
assert.equal(
await resolveRemoteHighWaterVersion(context.channel),
await resolveRemoteHighWaterVersion(
context.channel,
context.target,
),
'0.1.67',
);
},
@@ -406,7 +447,7 @@ test('manifest writer refuses to create latest when the current Mac DMG is missi
}
});
test('invalid target or mismatched channel fails before any release side effect', async () => {
test('invalid target or platform used as channel fails before any release side effect', async () => {
let touched = false;
const sideEffects = {
prepareVersion: () => {
@@ -426,7 +467,7 @@ test('invalid target or mismatched channel fails before any release side effect'
await withEnv({ AGC_UPDATE_CHANNEL: 'dev-win' }, () =>
assert.rejects(
() => buildRelease(['--target=aarch64-apple-darwin'], sideEffects),
/只能用于 windows/,
/发布渠道无效/,
),
);
assert.equal(touched, false);
@@ -440,7 +481,7 @@ test('Windows remains the default and explicit Windows overrides macOS environme
AGC_BUILD_TARGET: 'aarch64-apple-darwin',
}),
]) {
assert.equal(context.channel, 'dev-win');
assert.equal(context.channel, 'dev');
assert.equal(
selectReleaseArtifact(files, context.target),
'/tmp/windows.exe',
@@ -484,14 +525,14 @@ test('no-bundle smoke skips version writes and manifest generation', async () =>
steps.push('manifest');
},
});
assert.deepEqual(steps, ['dev-mac']);
assert.deepEqual(steps, ['dev']);
});
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',
channel: 'dev',
target: windowsTarget,
publishedAt: '2026-09-17T00:00:00.000Z',
});
@@ -522,7 +563,7 @@ test('missing signature fails the channel manifest closed', () => {
assert.throws(
() =>
createUpdateManifest(artifact, {
channel: 'dev-win',
channel: 'dev',
target: windowsTarget,
}),
/缺少更新包签名/u,
@@ -535,7 +576,7 @@ test('missing signature fails the channel manifest closed', () => {
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',
channel: 'dev',
});
assert.match(legacy.version, /^\d+\.\d+\.\d+$/u);
assert.equal(legacy.sha256.length, 64);
@@ -551,6 +592,86 @@ test('next release version follows the higher local or channel version', () => {
assert.equal(nextPatchVersion('0.1.12', null), '0.1.13');
});
for (const channel of ['release', 'beta-2']) {
for (const target of [windowsTarget, 'aarch64-apple-darwin']) {
test(`${channel} ${target} freezes its endpoint, version source and published objects`, async () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-channel-manifest-'));
try {
const windows = target === windowsTarget;
const partition = `${channel}-${windows ? 'win' : 'mac'}`;
const artifact = path.join(
root,
windows ? '陶泥儿_x64-setup.exe' : '陶泥儿.app.tar.gz',
);
writeFileSync(artifact, 'updater package');
writeFileSync(`${artifact}.sig`, 'updater signature');
if (!windows) createDmgFixture(root, target);
const context = {
...resolveReleaseContext([`--target=${target}`], {
AGC_UPDATE_CHANNEL: channel,
}),
bundleRoot: root,
};
const requests = [];
const result = await withStubbedFetch(
(url) => {
requests.push(url);
assert.ok(url.endsWith(`/agc/${partition}/latest.json`));
return jsonResponse({
version: '2.3.4',
commit: 'abcdef1234567890',
});
},
async () => {
assert.equal(
await resolveRemoteHighWaterVersion(
context.channel,
context.target,
),
'2.3.4',
);
runTauriBuild([`--target=${target}`], context, {
spawn: (_binary, command) => {
const config = JSON.parse(
readFileSync(
command[command.lastIndexOf('--config') + 1],
'utf8',
),
);
assert.ok(
config.plugins.updater.endpoints[0].endsWith(
`/agc/${partition}/latest.json`,
),
);
return { status: 0 };
},
});
return generateUpdateManifest(context);
},
);
assert.equal(result.channel, channel);
assert.equal(result.target, target);
assert.equal(result.manifest.version, packageVersion);
assert.equal(result.legacyManifestPath, null);
assert.equal(result.legacyManifest, null);
assert.equal(requests.length, 2);
for (const entry of [
...Object.values(result.manifest.platforms),
...Object.values(result.manifest.downloads),
]) {
assert.ok(entry.url.includes(`/agc/${partition}/${packageVersion}/`));
}
assert.throws(
() => createLegacyUpdateManifest(artifact, { channel, target }),
/只属于 dev 渠道/u,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
}
}
test('version high water keeps the legacy pointer during the migration window', async () => {
await withStubbedFetch(
(url) =>
@@ -558,7 +679,10 @@ test('version high water keeps the legacy pointer during the migration window',
? jsonResponse({}, 404)
: jsonResponse({ version: '0.1.57' }),
async () => {
assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.57');
assert.equal(
await resolveRemoteHighWaterVersion('dev', windowsTarget),
'0.1.57',
);
// 旧指针 0.1.57 已是高水位,下一次发布必须是 0.1.58,不能退回渠道本地版本。
assert.equal(nextPatchVersion('0.1.47', '0.1.57'), '0.1.58');
},
@@ -572,7 +696,10 @@ test('version high water takes the higher of channel and legacy pointer', async
? jsonResponse({ version: '0.1.60' })
: jsonResponse({ version: '0.1.57' }),
async () => {
assert.equal(await resolveRemoteHighWaterVersion('dev-win'), '0.1.60');
assert.equal(
await resolveRemoteHighWaterVersion('dev', windowsTarget),
'0.1.60',
);
},
);
});
@@ -587,7 +714,16 @@ test('version high water ignores the windows migration pointer for other channel
return jsonResponse({ version: '0.1.12' });
},
async () => {
assert.equal(await resolveRemoteHighWaterVersion('dev-mac'), '0.1.12');
for (const [channel, target] of [
['dev', 'aarch64-apple-darwin'],
['release', windowsTarget],
['beta-2', windowsTarget],
]) {
assert.equal(
await resolveRemoteHighWaterVersion(channel, target),
'0.1.12',
);
}
},
);
});
@@ -597,20 +733,20 @@ test('release notes anchor prefers the explicit commit and falls back to the man
() => jsonResponse({ version: '0.1.61', commit: 'abcdef1234567890' }),
async () => {
assert.equal(
await resolvePreviousReleaseCommit('dev-win', {
await resolvePreviousReleaseCommit('dev', {
override: '6017d46088c04199e99cf89f347b12d67591475e',
}),
'6017d46088c04199e99cf89f347b12d67591475e',
);
// 覆盖值非法时忽略,继续用清单里的 commit。
assert.equal(
await resolvePreviousReleaseCommit('dev-win', {
await resolvePreviousReleaseCommit('dev', {
override: 'not-a-sha',
}),
'abcdef1234567890',
);
assert.equal(
await resolvePreviousReleaseCommit('dev-win', { override: ' ' }),
await resolvePreviousReleaseCommit('dev', { override: ' ' }),
'abcdef1234567890',
);
},
@@ -620,7 +756,7 @@ test('release notes anchor prefers the explicit commit and falls back to the man
() => jsonResponse({ version: '0.1.61' }),
async () => {
assert.equal(
await resolvePreviousReleaseCommit('dev-win', { override: undefined }),
await resolvePreviousReleaseCommit('dev', { override: undefined }),
null,
);
},
@@ -634,7 +770,7 @@ test('release notes anchor degrades to null when the manifest cannot be read', a
};
try {
assert.equal(
await resolvePreviousReleaseCommit('dev-win', { override: undefined }),
await resolvePreviousReleaseCommit('dev', { override: undefined }),
null,
);
} finally {
@@ -1,6 +1,8 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
import { resolveReleasePartition } from './build-release.mjs';
/**
* 发布上传的 OSS 命令行整理:把 ossutil 参数与凭据整理成可执行或可打印的形式,
* 便于在 dry-run 下核对将要执行的上传,同时保证任何输出都不回显凭据明文。
@@ -34,16 +36,28 @@ export function createReleaseUploadPlan(
artifact,
downloadArtifact,
channel,
target,
manifest,
manifestPath,
legacyManifestPath,
},
bucket,
) {
if (!artifact || !downloadArtifact || !manifestPath || !manifest?.version) {
throw new Error('发布结果缺少更新包、首装包或清单');
if (
!artifact ||
!downloadArtifact ||
!manifestPath ||
!manifest?.version ||
!channel ||
!target
) {
throw new Error('发布结果缺少渠道、构建目标、更新包、首装包或清单');
}
const prefix = `oss://${bucket}/agc/${channel}`;
const partition = resolveReleasePartition(channel, target);
if (legacyManifestPath && partition !== 'dev-win') {
throw new Error('旧协议迁移清单只属于 dev 渠道的 Windows 系统');
}
const prefix = `oss://${bucket}/agc/${partition}`;
const artifacts = [
...new Set(
[artifact, `${artifact}.sig`, downloadArtifact].map((file) =>
@@ -40,22 +40,24 @@ test('printed upload command keeps arguments and hides credentials', () => {
);
});
function withReleaseFixture(channel, architecture, run) {
function withReleaseFixture(channel, architecture, run, platform = 'macos') {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-upload-plan-'));
try {
const artifact = path.join(
root,
channel === 'dev-win'
platform === 'windows'
? '陶泥儿_1.2.3_x64-setup.exe'
: '陶泥儿.app.tar.gz',
);
const downloadArtifact =
channel === 'dev-win'
platform === 'windows'
? artifact
: path.join(root, `陶泥儿_1.2.3_${architecture}.dmg`);
const manifestPath = path.join(root, 'latest.json');
const legacyManifestPath =
channel === 'dev-win' ? path.join(root, 'legacy-latest.json') : null;
channel === 'dev' && platform === 'windows'
? path.join(root, 'legacy-latest.json')
: null;
for (const file of [
artifact,
`${artifact}.sig`,
@@ -69,6 +71,10 @@ function withReleaseFixture(channel, architecture, run) {
artifact,
downloadArtifact,
channel,
target:
platform === 'windows'
? 'x86_64-pc-windows-msvc'
: `${architecture === 'aarch64' ? 'aarch64' : 'x86_64'}-apple-darwin`,
manifest: { version: '1.2.3' },
manifestPath,
legacyManifestPath,
@@ -86,7 +92,7 @@ const uploadOptions = {
for (const architecture of ['aarch64', 'x64']) {
test(`uploads every ${architecture} Mac object before the channel pointer`, () => {
withReleaseFixture('dev-mac', architecture, (release) => {
withReleaseFixture('dev', architecture, (release) => {
const calls = [];
uploadReleaseArtifacts(release, {
...uploadOptions,
@@ -120,37 +126,42 @@ for (const architecture of ['aarch64', 'x64']) {
}
test('Windows uploads the shared installer once and publishes migration metadata last', () => {
withReleaseFixture('dev-win', 'x64', (release) => {
const plan = createReleaseUploadPlan(release, 'agc-dev');
assert.deepEqual(
plan.map(({ source }) => source),
[
release.artifact,
`${release.artifact}.sig`,
release.manifestPath,
release.legacyManifestPath,
],
);
assert.equal(plan.at(-1).destination, 'oss://agc-dev/agc/latest.json');
const calls = [];
uploadReleaseArtifacts(release, {
...uploadOptions,
spawn: (_binary, args) => {
assert.deepEqual(args.slice(0, 2), ['cp', '--force']);
calls.push(args[3]);
return { status: 0 };
},
});
assert.deepEqual(
calls,
plan.map(({ destination }) => destination),
);
});
withReleaseFixture(
'dev',
'x64',
(release) => {
const plan = createReleaseUploadPlan(release, 'agc-dev');
assert.deepEqual(
plan.map(({ source }) => source),
[
release.artifact,
`${release.artifact}.sig`,
release.manifestPath,
release.legacyManifestPath,
],
);
assert.equal(plan.at(-1).destination, 'oss://agc-dev/agc/latest.json');
const calls = [];
uploadReleaseArtifacts(release, {
...uploadOptions,
spawn: (_binary, args) => {
assert.deepEqual(args.slice(0, 2), ['cp', '--force']);
calls.push(args[3]);
return { status: 0 };
},
});
assert.deepEqual(
calls,
plan.map(({ destination }) => destination),
);
},
'windows',
);
});
for (const failedArtifactIndex of [0, 1, 2]) {
test(`failed Mac object ${failedArtifactIndex} prevents both later objects and latest publication`, () => {
withReleaseFixture('dev-mac', 'aarch64', (release) => {
withReleaseFixture('dev', 'aarch64', (release) => {
const destinations = [];
assert.throws(
() =>
@@ -176,7 +187,7 @@ for (const failedArtifactIndex of [0, 1, 2]) {
}
test('dry run prints the complete plan without spawning uploads or exposing credentials', () => {
withReleaseFixture('dev-mac', 'aarch64', (release) => {
withReleaseFixture('dev', 'aarch64', (release) => {
const output = [];
uploadReleaseArtifacts(release, {
...uploadOptions,
@@ -195,3 +206,32 @@ test('dry run prints the complete plan without spawning uploads or exposing cred
assert.doesNotMatch(output.join('\n'), /fixture-id|fixture-secret|已上传/u);
});
});
for (const channel of ['release', 'beta-2']) {
for (const platform of ['windows', 'macos']) {
test(`${channel} ${platform} uploads only its own partition and cannot write the dev bridge`, () => {
withReleaseFixture(
channel,
'x64',
(release) => {
const plan = createReleaseUploadPlan(release, 'agc-dev');
const suffix = platform === 'windows' ? 'win' : 'mac';
const prefix = `oss://agc-dev/agc/${channel}-${suffix}/`;
assert.ok(
plan.every(({ destination }) => destination.startsWith(prefix)),
);
assert.equal(plan.at(-1).destination, `${prefix}latest.json`);
assert.throws(
() =>
createReleaseUploadPlan(
{ ...release, legacyManifestPath: release.manifestPath },
'agc-dev',
),
/只属于 dev 渠道/u,
);
},
platform,
);
});
}
}
@@ -2538,6 +2538,7 @@ fn main() {
create_automatic_local_game_project_from_template,
init_local_game_project,
fetch_game_template_library,
get_game_template_library_access,
download_game_template,
import_local_godot_project,
import_local_cocos_project,
@@ -5,6 +5,10 @@
//! 清单、zip 与封面一律先校验再落盘,zip 解压只接受普通文件与目录。
use super::*;
use crate::platform_session::{
current_platform_session, validate_platform_session_identity,
with_validated_platform_session_identity, PlatformSessionIdentity, PlatformSessionSnapshot,
};
use serde::{Deserialize, Serialize};
const TEMPLATE_LIBRARY_SCHEMA_VERSION: &str = "agc-template-library.v1";
@@ -24,6 +28,74 @@ const TEMPLATE_ARCHIVE_MAX_FILES: usize = 4_096;
const TEMPLATE_ARCHIVE_MAX_FILE_BYTES: u64 = 256 * 1024 * 1024;
const TEMPLATE_ID_MAX_CHARS: usize = 64;
const TEMPLATE_VERSION_MAX_CHARS: usize = 32;
const TEMPLATE_ACCESS_ERROR: &str = "template-library-unavailable: 模板库暂未向当前账号开放";
async fn template_library_access_for_session(
session: &PlatformSessionSnapshot,
) -> Result<bool, String> {
let client = reqwest::Client::builder()
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(15))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|_| "template-library-unavailable: 无法检查模板库权限".to_string())?;
let response = client
.get(format!(
"{}/api/runtime/frontend-config",
session.api_base_url.trim_end_matches('/')
))
.bearer_auth(&session.access_token)
.send()
.await
.map_err(|_| "template-library-unavailable: 检查模板库权限失败,请重试".to_string())?;
validate_platform_session_identity(&session.identity())?;
if !response.status().is_success() {
return Err(format!(
"template-library-unavailable: 检查模板库权限返回 HTTP {}",
response.status().as_u16()
));
}
const MAX_BYTES: usize = 64 * 1024;
if response
.content_length()
.is_some_and(|length| length > MAX_BYTES as u64)
{
return Err("template-library-unavailable: 模板库权限响应无效".to_string());
}
let mut bytes = Vec::new();
let mut stream = response.bytes_stream();
while let Some(chunk) = stream.next().await {
let chunk =
chunk.map_err(|_| "template-library-unavailable: 读取模板库权限失败".to_string())?;
if bytes.len() + chunk.len() > MAX_BYTES {
return Err("template-library-unavailable: 模板库权限响应无效".to_string());
}
bytes.extend_from_slice(&chunk);
}
validate_platform_session_identity(&session.identity())?;
let payload: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|_| "template-library-unavailable: 模板库权限响应无效".to_string())?;
Ok(payload
.get("agcTemplateLibraryEnabled")
.and_then(|value| value.as_bool())
== Some(true))
}
async fn require_template_library_access() -> Result<PlatformSessionIdentity, String> {
let session = current_platform_session().ok_or_else(|| TEMPLATE_ACCESS_ERROR.to_string())?;
if !template_library_access_for_session(&session).await? {
return Err(TEMPLATE_ACCESS_ERROR.to_string());
}
Ok(session.identity())
}
#[tauri::command]
pub(crate) async fn get_game_template_library_access() -> Result<bool, String> {
let Some(session) = current_platform_session() else {
return Ok(false);
};
template_library_access_for_session(&session).await
}
/// 远端清单里的单个模板条目(`templates/index.json` 中的 `templates[]`)。
#[derive(Clone, Debug, Deserialize, Serialize, Eq, PartialEq)]
@@ -719,7 +791,9 @@ async fn ensure_template_installed(
cache_root: &Path,
template_id: &str,
template_version: &str,
identity: &PlatformSessionIdentity,
) -> Result<InstalledGameTemplateRecord, String> {
validate_platform_session_identity(identity)?;
let installed_directory = installed_template_dir(cache_root, template_id, template_version)?;
if let Some(record) = read_installed_record(&installed_directory) {
return Ok(record);
@@ -728,13 +802,16 @@ async fn ensure_template_installed(
let client = build_template_library_client();
let url = template_object_url(&summary.zip_key)?;
let bytes = fetch_limited_bytes(&client, &url, TEMPLATE_ARCHIVE_MAX_BYTES).await?;
install_template_archive(cache_root, &summary, &bytes)
with_validated_platform_session_identity(identity, || {
install_template_archive(cache_root, &summary, &bytes)
})
}
#[tauri::command]
pub(crate) async fn fetch_game_template_library(
app: tauri::AppHandle,
) -> Result<GameTemplateLibrarySnapshot, String> {
let identity = require_template_library_access().await?;
let cache_root = template_cache_root(&app)?;
ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?;
let index_url = format!(
@@ -749,7 +826,10 @@ pub(crate) async fn fetch_game_template_library(
let body =
String::from_utf8(bytes).map_err(|_| "模板库清单不是有效 UTF-8".to_string())?;
parse_game_template_library_index(&body)?;
write_cached_index(&cache_root, &body);
with_validated_platform_session_identity(&identity, || {
write_cached_index(&cache_root, &body);
Ok(())
})?;
(body, "network")
}
Err(error) => match read_cached_index(&cache_root) {
@@ -760,6 +840,7 @@ pub(crate) async fn fetch_game_template_library(
None => return Err(error),
},
};
validate_platform_session_identity(&identity)?;
let (header, templates) = parse_game_template_library_index(&body)?;
let installed = collect_installed_records(&cache_root);
let entries = templates
@@ -789,10 +870,17 @@ pub(crate) async fn download_game_template(
template_id: String,
template_version: String,
) -> Result<InstalledGameTemplate, String> {
let identity = require_template_library_access().await?;
let cache_root = template_cache_root(&app)?;
ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?;
let record =
ensure_template_installed(&cache_root, template_id.trim(), template_version.trim()).await?;
let record = ensure_template_installed(
&cache_root,
template_id.trim(),
template_version.trim(),
&identity,
)
.await?;
validate_platform_session_identity(&identity)?;
Ok(InstalledGameTemplate {
template_id: record.template_id,
template_version: record.template_version,
@@ -882,23 +970,137 @@ pub(crate) async fn create_automatic_local_game_project_from_template(
planning: Option<bool>,
projects_root: Option<String>,
) -> Result<InitLocalProjectResult, String> {
let identity = require_template_library_access().await?;
let projects_root = crate::resolve_game_project_creation_root(&app, projects_root.as_deref())?;
let cache_root = template_cache_root(&app)?;
ensure_game_creator_private_directory_tree(&cache_root, "模板库缓存目录")?;
let record =
ensure_template_installed(&cache_root, template_id.trim(), template_version.trim()).await?;
create_project_from_installed_template_at(
&projects_root,
Path::new(&record.project_dir),
name.as_deref(),
planning.unwrap_or(false),
let record = ensure_template_installed(
&cache_root,
template_id.trim(),
template_version.trim(),
&identity,
)
.await?;
with_validated_platform_session_identity(&identity, || {
create_project_from_installed_template_at(
&projects_root,
Path::new(&record.project_dir),
name.as_deref(),
planning.unwrap_or(false),
)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn access_server(
status: u16,
body: &str,
change_identity: bool,
) -> (String, std::thread::JoinHandle<String>) {
use std::io::{Read, Write};
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
let body = body.to_string();
let server = std::thread::spawn(move || {
let (mut socket, _) = listener.accept().unwrap();
socket
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
let mut request = Vec::new();
let mut buffer = [0; 1024];
while !request.windows(4).any(|part| part == b"\r\n\r\n") {
let size = socket.read(&mut buffer).unwrap();
assert!(size > 0);
request.extend_from_slice(&buffer[..size]);
}
if change_identity {
crate::platform_session::clear_platform_session(2, 2);
}
write!(socket, "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap();
String::from_utf8(request).unwrap()
});
(url, server)
}
#[tokio::test]
async fn template_access_requires_current_account_and_explicit_server_grant() {
for (status, body, allowed) in [
(200, r#"{"agcTemplateLibraryEnabled":true}"#, true),
(200, r#"{"agcTemplateLibraryEnabled":false}"#, false),
(200, r#"{"imageEditorAgentSidebarEnabled":true}"#, false),
(200, r#"{"agcTemplateLibraryEnabled":"true"}"#, false),
(503, r#"{"agcTemplateLibraryEnabled":true}"#, false),
(200, "invalid JSON", false),
] {
let (origin, server) = access_server(status, body, false);
let _session = crate::platform_session::install_test_platform_session(
"template-user",
"template-test-token",
&origin,
);
assert_eq!(require_template_library_access().await.is_ok(), allowed);
let request = server.join().unwrap().to_lowercase();
assert!(request.starts_with("get /api/runtime/frontend-config "));
assert!(request.contains("authorization: bearer template-test-token"));
}
let _session = crate::platform_session::clear_test_platform_session();
assert!(!get_game_template_library_access().await.unwrap());
assert!(require_template_library_access().await.is_err());
}
#[tokio::test]
async fn template_access_preserves_identity_during_token_rotation() {
let (origin, server) = access_server(200, r#"{"agcTemplateLibraryEnabled":true}"#, false);
let _session = crate::platform_session::install_test_platform_session(
"template-user",
"old-token",
&origin,
);
let frozen = current_platform_session().unwrap();
crate::platform_session::install_platform_session(
"template-user",
"new-token",
&origin,
1,
2,
)
.unwrap();
assert!(template_library_access_for_session(&frozen).await.unwrap());
server.join().unwrap();
}
#[tokio::test]
async fn template_access_rejects_old_account_response_and_cached_install() {
let (origin, server) = access_server(200, r#"{"agcTemplateLibraryEnabled":true}"#, true);
let _session = crate::platform_session::install_test_platform_session(
"template-user",
"template-test-token",
&origin,
);
let identity = current_platform_session().unwrap().identity();
let error = require_template_library_access().await.unwrap_err();
assert!(error.contains("authentication-required"));
server.join().unwrap();
let error = ensure_template_installed(
Path::new("unused-cache"),
"demo-template",
"0.1.0",
&identity,
)
.await
.unwrap_err();
assert!(error.contains("authentication-required"));
assert!(
with_validated_platform_session_identity::<()>(&identity, || panic!(
"旧会话不得写入项目"
))
.is_err()
);
}
fn sample_index_body() -> String {
serde_json::json!({
"schemaVersion": TEMPLATE_LIBRARY_SCHEMA_VERSION,
@@ -94,10 +94,16 @@ export function WorkspaceLauncherShell({
rememberRecentWorkspace,
});
const templateLibrary = useTemplateLibrary({
onProjectCreated: async (result) => {
await homeProject.enterCreatedTemplateProject(result);
userId: currentUser.id,
onProjectCreated: async (result, isCurrent) => {
await homeProject.enterCreatedTemplateProject(result, isCurrent);
},
});
useEffect(() => {
if (!templateLibrary.enabled && launcherView === 'template-library') {
setLauncherView('home');
}
}, [templateLibrary.enabled, launcherView]);
const {
projectPath,
setProjectPath,
@@ -560,6 +566,7 @@ export function WorkspaceLauncherShell({
>
<Sidebar
activeView={launcherView}
templateLibraryEnabled={templateLibrary.enabled}
currentUser={currentUser}
onLogout={() => {
resetLauncherHomeDraft();
@@ -606,6 +613,7 @@ export function WorkspaceLauncherShell({
}}
onProjectPick={() => void homeProject.pickAndOpenProject()}
templateRecommendations={templateLibrary.templates}
templateLibraryEnabled={templateLibrary.enabled}
templateLibraryLoading={
templateLibrary.status === 'loading' ||
templateLibrary.status === 'idle'
@@ -619,7 +627,7 @@ export function WorkspaceLauncherShell({
homeProject={homeProject}
recentProjects={recentProjects}
/>
) : launcherView === 'template-library' ? (
) : launcherView === 'template-library' && templateLibrary.enabled ? (
<TemplateLibraryView
controller={templateLibrary}
onBack={() => setLauncherView('home')}
@@ -303,7 +303,10 @@ export function useHomeProjectCreation({
}
}
async function enterProjectDevelopment(context: LauncherProjectContext) {
async function enterProjectDevelopment(
context: LauncherProjectContext,
isCurrent: () => boolean = () => true,
) {
const entryToken = (projectEntryTokenRef.current += 1);
/**
* 会话预览只认"内存 registry 里真的还在跑"的那一个(见
@@ -316,7 +319,7 @@ export function useHomeProjectCreation({
projectPath: context.projectPath,
recordedPreview: context.manifest.preview ?? null,
});
if (entryToken !== projectEntryTokenRef.current) {
if (entryToken !== projectEntryTokenRef.current || !isCurrent()) {
// 更晚的一次进项目已经接管工作区:这一次的结果(预览与项目上下文)全部丢弃,
// 否则慢请求后到会把新项目覆盖回旧项目。
return;
@@ -560,29 +563,37 @@ export function useHomeProjectCreation({
* 模板库建出的项目:模板文件与项目脚手架已在 Rust 侧一次落盘,
* 这里只负责登记最近项目并走标准进项目通道(含会话预览核验与代次闸门)。
*/
async function enterCreatedTemplateProject(result: InitLocalProjectResult) {
async function enterCreatedTemplateProject(
result: InitLocalProjectResult,
isCurrent: () => boolean = () => true,
) {
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(),
});
const projectRevision = await readCurrentProjectRevision(
invoke,
result.projectPath,
);
if (!isCurrent()) return;
await enterProjectDevelopment(
{
projectPath: result.projectPath,
projectName:
result.manifest.name || projectNameFromPath(result.projectPath),
projectKind: 'web',
manifest: result.manifest,
projectRevision,
creationType: null,
startMode: null,
initialPrompt: '',
attachments: [],
recentRunStatus: null,
recentRunStopReason: null,
createdAt: Date.now(),
},
isCurrent,
);
}
async function openProject(nextProjectPath: string, mode: 'open' | 'create') {
@@ -10,6 +10,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { resolveTauriInvoke } from '../../app/tauri';
import type { InitLocalProjectResult } from '../../app/types';
import {
currentPlatformSessionGeneration,
subscribePlatformSessionGeneration,
} from '../../services/platformSession';
import { readProjectCreationDirectory } from '../app-shell/model';
import {
collectGameTemplateRuntimes,
@@ -29,8 +33,12 @@ export type TemplateLibraryStatus = 'idle' | 'loading' | 'ready' | 'error';
export type TemplateLibraryBusyKind = 'download' | 'create';
type UseTemplateLibraryOptions = {
userId: string;
/** 项目已建好:由调用方负责进入项目工作区(模板库不碰工作区状态)。 */
onProjectCreated: (result: InitLocalProjectResult) => Promise<void> | void;
onProjectCreated: (
result: InitLocalProjectResult,
isCurrent: () => boolean,
) => Promise<void> | void;
};
function errorMessage(error: unknown): string {
@@ -38,8 +46,25 @@ function errorMessage(error: unknown): string {
}
export function useTemplateLibrary({
userId,
onProjectCreated,
}: UseTemplateLibraryOptions) {
const scopeRef = useRef({
userId,
generation: 0,
authorityGeneration: currentPlatformSessionGeneration(),
allowed: false,
});
if (scopeRef.current.userId !== userId) {
scopeRef.current = {
userId,
generation: 0,
authorityGeneration: currentPlatformSessionGeneration(),
allowed: false,
};
}
const [access, setAccess] = useState({ userId: '', enabled: false });
const enabled = access.userId === userId && access.enabled;
const [snapshot, setSnapshot] = useState<GameTemplateLibrarySnapshot | null>(
null,
);
@@ -53,88 +78,171 @@ export function useTemplateLibrary({
const [busyKind, setBusyKind] = useState<TemplateLibraryBusyKind | null>(
null,
);
const loadingRef = useRef(false);
const refreshSequence = useRef(0);
const revokeAccess = useCallback(() => {
scopeRef.current.generation += 1;
scopeRef.current.allowed = false;
setAccess({ userId: scopeRef.current.userId, enabled: false });
setSnapshot(null);
setStatus('idle');
setError('');
setNotice('');
setFilters(EMPTY_TEMPLATE_LIBRARY_FILTERS);
setBusyTemplateId(null);
setBusyKind(null);
}, []);
const handleOperationError = useCallback(
(nextError: unknown) => {
const message = errorMessage(nextError);
if (
message.includes('template-library-unavailable:') ||
message.includes('authentication-required:')
) {
revokeAccess();
} else {
setError(message);
}
},
[revokeAccess],
);
const refresh = useCallback(async () => {
if (loadingRef.current) {
const scope = scopeRef.current;
if (scope.authorityGeneration !== currentPlatformSessionGeneration()) {
revokeAccess();
return;
}
const generation = scope.generation;
const sequence = ++refreshSequence.current;
const isCurrent = () =>
scopeRef.current === scope &&
scope.authorityGeneration === currentPlatformSessionGeneration() &&
scope.generation === generation &&
refreshSequence.current === sequence;
const invoke = resolveTauriInvoke();
if (!invoke) {
setStatus('error');
setError('需要在陶泥儿客户端内运行');
revokeAccess();
return;
}
loadingRef.current = true;
let allowed: boolean;
try {
allowed = await invoke<boolean>('get_game_template_library_access');
} catch {
if (isCurrent()) revokeAccess();
return;
}
if (!isCurrent()) return;
if (allowed !== true) {
revokeAccess();
return;
}
scope.allowed = true;
setAccess({ userId: scope.userId, enabled: true });
setStatus('loading');
setError('');
try {
const next = await invoke<GameTemplateLibrarySnapshot>(
'fetch_game_template_library',
);
if (!isCurrent()) return;
setSnapshot(next);
setStatus('ready');
setNotice(
next.source === 'cache' ? '远端清单暂时读不到,当前展示本机缓存' : '',
);
} catch (nextError) {
if (!isCurrent()) return;
setStatus('error');
setError(errorMessage(nextError));
} finally {
loadingRef.current = false;
handleOperationError(nextError);
}
}, []);
}, [handleOperationError, revokeAccess]);
useEffect(() => {
revokeAccess();
void refresh();
}, [refresh]);
const onFocus = () => void refresh();
const unsubscribe = subscribePlatformSessionGeneration((generation) => {
if (scopeRef.current.authorityGeneration !== generation) revokeAccess();
});
window.addEventListener('focus', onFocus);
return () => {
scopeRef.current.generation += 1;
scopeRef.current.allowed = false;
window.removeEventListener('focus', onFocus);
unsubscribe();
};
}, [userId, refresh, revokeAccess]);
const downloadTemplate = useCallback(async (template: GameTemplateEntry) => {
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在陶泥儿客户端内运行');
}
setBusyTemplateId(template.id);
setBusyKind('download');
setError('');
try {
const installed = await invoke<InstalledGameTemplate>(
'download_game_template',
{
templateId: template.id,
templateVersion: template.templateVersion,
},
);
setSnapshot((current) =>
current
? {
...current,
templates: current.templates.map((entry) =>
entry.id === template.id
? {
...entry,
installed: true,
installedVersion: installed.templateVersion,
installedAtMillis: installed.installedAtMillis,
}
: entry,
),
}
: current,
);
setNotice(`已下载模板「${template.title}」`);
return installed;
} catch (nextError) {
setError(errorMessage(nextError));
throw nextError;
} finally {
setBusyTemplateId(null);
setBusyKind(null);
}
}, []);
const downloadTemplate = useCallback(
async (template: GameTemplateEntry) => {
const scope = scopeRef.current;
const generation = scope.generation;
const isCurrent = () =>
scopeRef.current === scope &&
scope.authorityGeneration === currentPlatformSessionGeneration() &&
scope.generation === generation &&
scope.allowed;
if (!isCurrent()) throw new Error('模板库暂未向当前账号开放');
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在陶泥儿客户端内运行');
}
setBusyTemplateId(template.id);
setBusyKind('download');
setError('');
try {
const installed = await invoke<InstalledGameTemplate>(
'download_game_template',
{
templateId: template.id,
templateVersion: template.templateVersion,
},
);
if (!isCurrent()) throw new Error('登录态已变化,模板操作已停止');
setSnapshot((current) =>
current
? {
...current,
templates: current.templates.map((entry) =>
entry.id === template.id
? {
...entry,
installed: true,
installedVersion: installed.templateVersion,
installedAtMillis: installed.installedAtMillis,
}
: entry,
),
}
: current,
);
setNotice(`已下载模板「${template.title}」`);
return installed;
} catch (nextError) {
if (isCurrent()) handleOperationError(nextError);
throw nextError;
} finally {
if (isCurrent()) {
setBusyTemplateId(null);
setBusyKind(null);
}
}
},
[handleOperationError],
);
const createProjectFromTemplate = useCallback(
async (template: GameTemplateEntry) => {
const scope = scopeRef.current;
const generation = scope.generation;
const isCurrent = () =>
scopeRef.current === scope &&
scope.authorityGeneration === currentPlatformSessionGeneration() &&
scope.generation === generation &&
scope.allowed;
if (!isCurrent()) throw new Error('模板库暂未向当前账号开放');
const invoke = resolveTauriInvoke();
if (!invoke) {
throw new Error('需要在陶泥儿客户端内运行');
@@ -143,6 +251,7 @@ export function useTemplateLibrary({
if (needsTemplateDownload(template)) {
await downloadTemplate(template);
}
if (!isCurrent()) throw new Error('登录态已变化,模板操作已停止');
setBusyTemplateId(template.id);
setBusyKind('create');
setError('');
@@ -158,23 +267,26 @@ export function useTemplateLibrary({
projectsRoot: readProjectCreationDirectory() || null,
},
);
await onProjectCreated(result);
setNotice(`已用模板「${template.title}」创建项目`);
if (!isCurrent()) throw new Error('登录态已变化,模板操作已停止');
await onProjectCreated(result, isCurrent);
if (isCurrent()) setNotice(`已用模板「${template.title}」创建项目`);
return result;
} catch (nextError) {
setError(errorMessage(nextError));
if (isCurrent()) handleOperationError(nextError);
throw nextError;
} finally {
setBusyTemplateId(null);
setBusyKind(null);
if (isCurrent()) {
setBusyTemplateId(null);
setBusyKind(null);
}
}
},
[downloadTemplate, onProjectCreated],
[downloadTemplate, onProjectCreated, handleOperationError],
);
const templates = useMemo(
() => snapshot?.templates ?? [],
[snapshot?.templates],
() => (enabled ? (snapshot?.templates ?? []) : []),
[enabled, snapshot?.templates],
);
const visibleTemplates = useMemo(
() => filterGameTemplates(templates, filters),
@@ -218,6 +330,7 @@ export function useTemplateLibrary({
}, []);
return {
enabled,
snapshot,
status,
error,
@@ -116,6 +116,7 @@ type HomeViewProps = {
onProjectPick: () => void;
/** 模板库推荐位:清单来自 Rust 侧模板库,首页只负责展示与跳转。 */
templateRecommendations: readonly GameTemplateEntry[];
templateLibraryEnabled: boolean;
templateLibraryLoading: boolean;
templateLibraryError: string;
onTemplateLibraryOpen: () => void;
@@ -133,6 +134,7 @@ export default function HomeView({
onProjectOpen,
onProjectPick,
templateRecommendations,
templateLibraryEnabled,
templateLibraryLoading,
templateLibraryError,
onTemplateLibraryOpen,
@@ -429,37 +431,39 @@ export default function HomeView({
)}
</section>
<section
className="mx-auto mt-5.5 grid w-[min(814px,calc(100vw-122px))] gap-3.5 max-[760px]:w-[min(100%,calc(100vw-76px))]"
aria-label="模板库推荐"
>
<header className="flex items-center justify-start gap-3">
<span className="inline-flex items-baseline gap-1.5">
<h2 className="m-0 text-[15px] text-(--platform-text-strong)">
模板库
</h2>
<Sparkles
className="text-(--platform-warm-text)"
size={15}
aria-hidden="true"
style={{ transform: 'translateY(1px)' }}
/>
</span>
<button
className="ml-auto cursor-pointer border-0 bg-transparent p-0 text-[12px] text-(--platform-warm-text)"
type="button"
onClick={onTemplateLibraryOpen}
>
查看全部模板
</button>
</header>
<TemplateRecommendations
templates={templateRecommendations}
loading={templateLibraryLoading}
error={templateLibraryError}
onOpenLibrary={onTemplateLibraryOpen}
/>
</section>
{templateLibraryEnabled ? (
<section
className="mx-auto mt-5.5 grid w-[min(814px,calc(100vw-122px))] gap-3.5 max-[760px]:w-[min(100%,calc(100vw-76px))]"
aria-label="模板库推荐"
>
<header className="flex items-center justify-start gap-3">
<span className="inline-flex items-baseline gap-1.5">
<h2 className="m-0 text-[15px] text-(--platform-text-strong)">
模板库
</h2>
<Sparkles
className="text-(--platform-warm-text)"
size={15}
aria-hidden="true"
style={{ transform: 'translateY(1px)' }}
/>
</span>
<button
className="ml-auto cursor-pointer border-0 bg-transparent p-0 text-[12px] text-(--platform-warm-text)"
type="button"
onClick={onTemplateLibraryOpen}
>
查看全部模板
</button>
</header>
<TemplateRecommendations
templates={templateRecommendations}
loading={templateLibraryLoading}
error={templateLibraryError}
onOpenLibrary={onTemplateLibraryOpen}
/>
</section>
) : null}
</div>
);
}
+17 -13
View File
@@ -42,6 +42,7 @@ type SidebarUserInfo = {
type LauncherSidebarProps = {
activeView: LauncherView;
templateLibraryEnabled: boolean;
currentUser: SidebarUserInfo;
onViewChange: (view: LauncherView) => void;
onNoticeRequest: (title: string) => void;
@@ -193,6 +194,7 @@ function SidebarAccountMenu({
export function Sidebar({
activeView,
templateLibraryEnabled,
currentUser,
onViewChange,
onNoticeRequest,
@@ -286,19 +288,21 @@ export function Sidebar({
>
<FolderKanban size={17} aria-hidden="true" />
</button>
<button
type="button"
aria-label="模板库"
title="模板库"
className={cx(
sidebarIconButtonClass,
activeView === 'template-library' &&
'border border-(--platform-nav-active-border) bg-(image:--platform-nav-active-fill) text-(--platform-nav-item-text-active) shadow-(--platform-nav-active-shadow)',
)}
onClick={() => changeView('template-library')}
>
<LayoutTemplate size={17} aria-hidden="true" />
</button>
{templateLibraryEnabled ? (
<button
type="button"
aria-label="模板库"
title="模板库"
className={cx(
sidebarIconButtonClass,
activeView === 'template-library' &&
'border border-(--platform-nav-active-border) bg-(image:--platform-nav-active-fill) text-(--platform-nav-item-text-active) shadow-(--platform-nav-active-shadow)',
)}
onClick={() => changeView('template-library')}
>
<LayoutTemplate size={17} aria-hidden="true" />
</button>
) : null}
</nav>
<div className="relative z-[60] mt-auto grid justify-items-center gap-[13px]">
<div className="relative" ref={accountMenuRef}>
@@ -182,6 +182,26 @@ export function registerClientHomeTests() {
);
});
it('hides template navigation and recommendations when the account is outside gray release', async () => {
const invoke = vi.fn(async (command: string) => {
if (command === 'get_game_template_library_access') return false;
if (command === 'read_game_creator_app_config') return { config: {} };
throw new Error(`unexpected invoke ${command}`);
});
window.__TAURI__ = { core: { invoke } };
renderLauncherAt('/?launcher');
await waitFor(() =>
expect(invoke).toHaveBeenCalledWith('get_game_template_library_access'),
);
expect(screen.queryByRole('button', { name: '模板库' })).toBeNull();
expect(screen.queryByLabelText('模板库推荐')).toBeNull();
expect(
invoke.mock.calls.some(
([command]) => command === 'fetch_game_template_library',
),
).toBe(false);
});
it('shows the home template recommendations and opens the library without creating a project', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const templateLibrarySnapshot = {
@@ -244,6 +264,7 @@ export function registerClientHomeTests() {
if (command === 'read_game_creator_app_config') {
return { config: { selectedModelId: 'quality' } };
}
if (command === 'get_game_template_library_access') return true;
if (command === 'fetch_game_template_library') {
return templateLibrarySnapshot;
}
@@ -74,6 +74,7 @@ function controller(
installedOnly: false,
};
return {
enabled: true,
snapshot: null,
status: 'ready',
error: '',
@@ -0,0 +1,306 @@
// @vitest-environment jsdom
import { act, cleanup, renderHook, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createGameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp';
import { useHomeProjectCreation } from '../src/features/app-shell/useHomeProjectCreation';
import type {
GameTemplateEntry,
GameTemplateLibrarySnapshot,
} from '../src/features/template-library/templateLibraryModel';
import { useTemplateLibrary } from '../src/features/template-library/useTemplateLibrary';
import {
beginPlatformSessionClearTransition,
resetPlatformSessionStateForTests,
} from '../src/services/platformSession';
const invoke = vi.hoisted(() => vi.fn());
vi.mock('../src/app/tauri', () => ({ resolveTauriInvoke: () => invoke }));
const template: GameTemplateEntry = {
id: 'demo',
title: '演示',
summary: '',
tags: [],
runtime: 'html',
engine: 'phaser',
engineVersion: '4',
templateVersion: '1',
updatedAt: '',
entry: 'index.html',
zipUrl: 'https://example.invalid/demo.zip',
zipSizeBytes: 1,
zipSha256: 'a'.repeat(64),
coverUrl: '',
coverWidth: 100,
coverHeight: 100,
installed: true,
installedVersion: '1',
installedAtMillis: 1,
};
const snapshot = (id = 'demo'): GameTemplateLibrarySnapshot => ({
schemaVersion: 'agc-template-library.v1',
library: 'official',
libraryVersion: 1,
updatedAt: '',
fetchedAtMillis: 1,
source: 'cache',
templates: [{ ...template, id }],
});
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => {
resolve = done;
});
return { promise, resolve };
}
const onProjectCreated = vi.fn();
const mount = () =>
renderHook(({ userId }) => useTemplateLibrary({ userId, onProjectCreated }), {
initialProps: { userId: 'user-a' },
});
beforeEach(() => {
resetPlatformSessionStateForTests();
invoke.mockReset();
onProjectCreated.mockReset();
});
afterEach(cleanup);
describe('模板库灰度会话', () => {
it('等待权威权限时不读取清单,拒绝后也不能调用下载', async () => {
const access = deferred<boolean>();
invoke.mockImplementation(() => access.promise);
const { result } = mount();
expect(result.current.enabled).toBe(false);
expect(invoke.mock.calls.map(([command]) => command)).toEqual([
'get_game_template_library_access',
]);
await act(async () => {
access.resolve(false);
});
await expect(result.current.downloadTemplate(template)).rejects.toThrow(
'暂未',
);
expect(result.current.snapshot).toBeNull();
expect(invoke).toHaveBeenCalledTimes(1);
});
it('命中后读取清单,窗口重新聚焦失去权限就清空缓存投影', async () => {
let allowed = true;
invoke.mockImplementation(async (command) =>
command === 'get_game_template_library_access' ? allowed : snapshot(),
);
const { result } = mount();
await waitFor(() => expect(result.current.status).toBe('ready'));
expect(result.current.enabled).toBe(true);
expect(result.current.templates).toHaveLength(1);
allowed = false;
await act(async () => {
window.dispatchEvent(new Event('focus'));
});
expect(result.current.enabled).toBe(false);
expect(result.current.templates).toEqual([]);
expect(result.current.snapshot).toBeNull();
expect(
invoke.mock.calls.filter(
([command]) => command === 'fetch_game_template_library',
),
).toHaveLength(1);
});
it('账号切换后丢弃前一账号迟到的允许结果', async () => {
const access = deferred<boolean>();
invoke
.mockImplementationOnce(() => access.promise)
.mockResolvedValue(false);
const { result, rerender } = mount();
rerender({ userId: 'user-b' });
await act(async () => {
access.resolve(true);
});
expect(result.current.enabled).toBe(false);
expect(
invoke.mock.calls.filter(
([command]) => command === 'fetch_game_template_library',
),
).toHaveLength(0);
});
it('账号切换后旧清单不能覆盖当前账号的清单', async () => {
const oldManifest = deferred<GameTemplateLibrarySnapshot>();
let fetches = 0;
invoke.mockImplementation(async (command) => {
if (command === 'get_game_template_library_access') return true;
return ++fetches === 1
? oldManifest.promise
: snapshot('user-b-template');
});
const { result, rerender } = mount();
await waitFor(() => expect(fetches).toBe(1));
rerender({ userId: 'user-b' });
await waitFor(() =>
expect(result.current.templates[0]?.id).toBe('user-b-template'),
);
await act(async () => {
oldManifest.resolve(snapshot('user-a-template'));
});
expect(result.current.templates[0]?.id).toBe('user-b-template');
});
it.each(['download', 'create'] as const)(
'账号切换后迟到的%s结果不能写状态或跳转',
async (operation) => {
const completion = deferred<unknown>();
let allowed = true;
invoke.mockImplementation(async (command) => {
if (command === 'get_game_template_library_access') return allowed;
if (command === 'fetch_game_template_library') return snapshot();
return completion.promise;
});
const { result, rerender } = mount();
await waitFor(() => expect(result.current.status).toBe('ready'));
let pending!: Promise<unknown>;
act(() => {
pending = (
operation === 'create'
? result.current.createProjectFromTemplate(template)
: result.current.downloadTemplate(template)
).catch((error) => error);
});
allowed = false;
rerender({ userId: 'user-b' });
await act(async () => {
completion.resolve({ projectPath: '/old', templateVersion: '1' });
await pending;
});
expect(await pending).toBeInstanceOf(Error);
expect(onProjectCreated).not.toHaveBeenCalled();
expect(result.current.enabled).toBe(false);
expect(result.current.notice).toBe('');
expect(result.current.busyTemplateId).toBeNull();
},
);
it('原生命令拒绝权限后立即关闭入口,已安装模板不能绕过', async () => {
invoke.mockImplementation(async (command) => {
if (command === 'get_game_template_library_access') return true;
if (command === 'fetch_game_template_library') return snapshot();
throw new Error('template-library-unavailable: 模板库暂未开放');
});
const { result } = mount();
await waitFor(() => expect(result.current.status).toBe('ready'));
await act(async () => {
await expect(
result.current.createProjectFromTemplate(template),
).rejects.toThrow('暂未开放');
});
expect(result.current.enabled).toBe(false);
expect(result.current.snapshot).toBeNull();
expect(onProjectCreated).not.toHaveBeenCalled();
});
it('检查权限失败时关闭入口而不使用先前允许结果', async () => {
invoke.mockImplementation(async (command) =>
command === 'get_game_template_library_access' ? true : snapshot(),
);
const { result } = mount();
await waitFor(() => expect(result.current.status).toBe('ready'));
invoke.mockRejectedValueOnce(new Error('offline'));
await act(async () => {
await result.current.refresh();
});
expect(result.current.enabled).toBe(false);
expect(result.current.templates).toEqual([]);
});
it('退出登录开始时即使 userId 未变也清空权限并停止旧建项回调', async () => {
const completion = deferred<unknown>();
invoke.mockImplementation(async (command) => {
if (command === 'get_game_template_library_access') return true;
if (command === 'fetch_game_template_library') return snapshot();
return completion.promise;
});
const { result } = mount();
await waitFor(() => expect(result.current.status).toBe('ready'));
let pending!: Promise<unknown>;
act(() => {
pending = result.current
.createProjectFromTemplate(template)
.catch((error) => error);
});
act(() => {
beginPlatformSessionClearTransition();
});
expect(result.current.enabled).toBe(false);
const count = invoke.mock.calls.length;
await act(async () => {
await result.current.refresh();
});
expect(invoke).toHaveBeenCalledTimes(count);
await act(async () => {
completion.resolve({ projectPath: '/old' });
await pending;
});
expect(onProjectCreated).not.toHaveBeenCalled();
});
it.each(['get_local_game_project_revision', 'get_local_game_preview_status'])(
'退出登录时 %s 的旧结果不能进入项目或登记最近项目',
async (delayedCommand) => {
const delayed = deferred<unknown>();
const manifest = createGameCreationAppManifest(
'template-project',
'模板项目',
);
invoke.mockImplementation(async (command) => {
if (command === 'get_game_template_library_access') return true;
if (command === 'fetch_game_template_library') return snapshot();
if (command === 'create_automatic_local_game_project_from_template')
return { projectPath: 'C:/test/template-project', manifest };
if (command === delayedCommand) return delayed.promise;
if (command === 'get_local_game_project_revision')
return { revision: 1 };
if (command === 'get_local_game_preview_status')
return { status: 'stopped' };
throw new Error(command);
});
const setLauncherView = vi.fn();
const rememberRecentWorkspace = vi.fn();
const { result } = renderHook(() => {
const home = useHomeProjectCreation({
setLauncherView,
rememberRecentWorkspace,
setStatus: vi.fn(),
setAgentChatProjectPath: vi.fn(),
});
const library = useTemplateLibrary({
userId: 'user-a',
onProjectCreated: (created, isCurrent) =>
home.enterCreatedTemplateProject(created, isCurrent),
});
return { home, library };
});
await waitFor(() => expect(result.current.library.status).toBe('ready'));
let pending!: Promise<unknown>;
act(() => {
pending = result.current.library.createProjectFromTemplate(template);
});
await waitFor(() =>
expect(
invoke.mock.calls.some(([command]) => command === delayedCommand),
).toBe(true),
);
act(() => {
beginPlatformSessionClearTransition();
});
await act(async () => {
delayed.resolve({ revision: 1, status: 'stopped' });
await pending;
});
expect(result.current.home.currentProjectContext).toBeNull();
expect(setLauncherView).not.toHaveBeenCalled();
expect(rememberRecentWorkspace).not.toHaveBeenCalled();
},
);
});