完善客户端发布渠道并接入模板库灰度
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
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:
@@ -235,6 +235,10 @@ VITE_DEBUG_MODE=""
|
||||
# This is read by api-server and exposed through /api/runtime/frontend-config.
|
||||
GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR="false"
|
||||
|
||||
# 官网客户端下载检测渠道:dev、release 或自定义渠道;修改后重启 API 服务。
|
||||
# Windows/macOS 是系统维度,不填写 dev-win/dev-mac。
|
||||
GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL="dev"
|
||||
|
||||
# Optional: official VikingDB credentials for regenerating build-tag similarities
|
||||
# with the Python embedding script. The script auto-loads `.env.local` and uses
|
||||
# the fixed `bge-large-zh` embedding model.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -22,7 +22,8 @@
|
||||
|
||||
- AGC 批量追加素材标签由原生在一次项目写锁与 revision CAS 下合并各项原标签,先校验全批再写 manifest;前端不能循环单素材分类命令,不回传展示层推导的分类或旧标签全集,以免部分写入或覆盖未编辑字段。
|
||||
|
||||
- AGC 平台服务固定为 `https://dev.genarrative.world`,会话凭据按 origin 隔离。官网通过同源公开 `/api/client-downloads` 汇总 Windows/Mac 渠道的首装 `downloads`,按真实平台/架构显示;未发布隐藏,单渠道失败不影响其它下载。发布先上传 EXE/DMG 再写本渠道清单,不维护会互相覆盖的共享 OSS 索引。主站 Vite 代理复用实际 `runtimeServerTarget`,浏览器不直接跨域读取 OSS 清单。完整约定见 AGC 客户端更新检查与下载专题。
|
||||
- AGC 平台服务固定为 `https://dev.genarrative.world`,会话凭据按 origin 隔离。发布渠道为 `dev/release/自定义名称`,Windows/Mac 是系统,OSS 的 `<channel>-win/mac` 仅是延续既有地址的分区。官网通过服务端 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`(默认 dev)选择渠道,公开同源 `/api/client-downloads` 汇总其各系统首装包与真实版本;未发布隐藏,单系统失败不影响其它下载,不跨渠道补齐。发布先上传 EXE/DMG 再写对应分区清单,不维护会互相覆盖的共享 OSS 索引。主站 Vite 代理复用实际 `runtimeServerTarget`。完整约定见 AGC 客户端更新检查与下载专题。
|
||||
- AGC 模板库灰度复用 `agc:template-library`:未配置关闭,已配置时遵循现有灰度启停、用户 ID/标签和比例规则;服务端返回权威结论,客户端入口和原生清单/下载/建项均执行门禁,主体切换丢弃旧异步结果。公开 OSS 不是保密边界,已创建项目不受影响。
|
||||
|
||||
- 画布卡片类型与信息角标共用 `CanvasCardCornerActions`;菜单收纳共用 `OverflowActions`,宿主决定展示数量和资源命令。AGC 选中菜单前 5 项直显,Web 默认不折叠;浮层 portal 继续接入现有画布关闭与滚轮归属判据。
|
||||
- 修改范围保持聚焦;优先扩展现有系统、页面、组件、DTO 和脚本,不新建平行入口或业务真相。
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
# AGC 客户端更新检查与下载
|
||||
|
||||
更新时间:`2026-09-19`
|
||||
更新时间:`2026-09-20`
|
||||
|
||||
本文件是 AGC 客户端自动更新的主规范:更新能力由 Tauri 官方插件 `tauri-plugin-updater` 承担,并按下文渠道分发。
|
||||
|
||||
## 目标
|
||||
|
||||
- 客户端自动更新改用 Tauri 官方 `tauri-plugin-updater`:清单请求、版本比较、更新包下载、签名校验、安装与退出全部在原生侧完成;前端只负责触发、展示和渠道选择。
|
||||
- 更新按渠道分发。当前渠道集合为 `dev-win`(Windows x64)与 `dev-mac`(macOS);构建管线按渠道产出并上传清单,客户端只读取自己渠道的清单。
|
||||
- 更新按渠道分发。渠道为 `dev`、`release` 或自定义名称;Windows/macOS 是独立的系统维度,每个渠道分别维护各系统已发布的版本、清单和安装包。客户端只读取构建时确定的渠道及系统对应的清单。
|
||||
|
||||
## 渠道与网站配置合同
|
||||
|
||||
- 构建参数 `AGC_UPDATE_CHANNEL` 默认 `dev`,支持 `release` 和自定义小写名称;名称符合 `[a-z][a-z0-9-]{0,31}`,不能以连字符结尾,不能为 `win/mac/windows/macos/darwin/linux` 或以 `-win/-mac` 结尾。构建目标独立决定系统和架构。
|
||||
- 为延续已发布客户端地址,OSS 继续使用 `agc/<channel>-win/` 和 `agc/<channel>-mac/` 作为物理分区;`dev-win/dev-mac` 是分区键,不是可填写的渠道。每个分区独立维护 `latest.json` 与版本目录,发布 release 不覆盖 dev。旧 `agc/latest.json` 迁移桥与其版本高水位仅属于 dev 的 Windows 分区。
|
||||
- 网站由服务端配置 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL` 选择检测渠道,默认 `dev`;更改后重启 API 服务生效。`GET /api/client-downloads` 返回该渠道 Windows/macOS 的真实已发布版本。请求参数不能覆盖配置或指定 URL;配置非法时失败关闭,不悄悄改读 dev。
|
||||
- 同一渠道中不同系统仍可具有不同 release 版本;未发布的系统隐藏,单系统失败不影响另一系统。不会从其他渠道补齐缺失版本。
|
||||
- 验收必须覆盖 dev/release/自定义渠道各自端点和对象地址、独立版本、非法名称、旧 dev 地址延续、release 不写旧迁移桥、网站配置贯通、跨渠道链接拒绝和部分失败。
|
||||
- 更新链路的信任来源从「清单里的 sha256 + 受信域名」升级为「发布签名 + 受信域名」:清单里的 `signature` 由构建期私钥生成,客户端用内置公钥校验,校验不过就拒绝安装。
|
||||
|
||||
## 非目标
|
||||
@@ -33,11 +41,11 @@
|
||||
### 官网下载与客户端服务地址
|
||||
|
||||
- 官网首页提供无需登录的「下载客户端」入口,桌面和移动视口均可访问;入口打开独立下载面板,复用平台按钮、弹窗和状态组件。
|
||||
- 每次打开下载面板请求同源公开 `GET /api/client-downloads`,后端并行读取固定 `dev-win/latest.json` 与 `dev-mac/latest.json` 并汇总已发布平台,网页和接口均禁用缓存。平台列表与每项版本完全来自清单;不在网页写死版本或猜测文件名。
|
||||
- 每次打开下载面板请求同源公开 `GET /api/client-downloads`,后端并行读取配置渠道的 `<channel>-win/latest.json` 与 `<channel>-mac/latest.json` 并汇总已发布平台,网页和接口均禁用缓存。平台列表与每项版本完全来自清单;不在网页写死版本或猜测文件名。
|
||||
- 接口返回 `{ downloads: [{ platform, architecture, version, downloadUrl }], unavailablePlatforms: [] }`,平台为 `windows` / `macos`,架构为 `x86_64` / `aarch64`;Windows 仅支持 x86_64,Mac 按清单实际提供的架构展示 Apple Silicon / Intel 下载项。DTO 由 `shared-contracts` 与 `packages/shared` 对齐,不接受请求参数指定上游 URL,不触及 SpacetimeDB。
|
||||
- 渠道清单新增可选 `downloads` 字典,键与 updater 平台键一致,值为 `{ url }`,只登记首装包。Windows `.exe` 可同时用于首装和更新;macOS 首装必须为 `.dmg`,不得将 `.app.tar.gz` 当首装包。已发布且没有 `downloads` 字段的 Windows 清单可读取既有 `platforms.windows-x86_64.url`;Mac 没有首装元数据时隐藏,不推导 DMG 地址。
|
||||
- 渠道 404 视为尚未发布并隐藏该平台;两端均未发布时显示空状态。单渠道请求失败、超时或格式非法时保留另一端有效下载项,同时提示部分平台暂不可用并允许重试;没有任何有效项且存在失败时返回可读 502。关闭面板取消请求,迟到响应不能覆盖下一次打开的状态。
|
||||
- 每个上游请求总超时 10 秒、响应体上限 128 KiB、不跟随重定向、不附加用户凭据;返回的链接仅接受固定 OSS 来源、对应 `/agc/<channel>/<version>/` 下的 HTTPS `.exe` / `.dmg` 对象,架构键必须属于该渠道。不提供陈旧、未知来源或版本不匹配的下载地址,不泄露上游正文。
|
||||
- 每个上游请求总超时 10 秒、响应体上限 128 KiB、不跟随重定向、不附加用户凭据;返回的链接仅接受固定 OSS 来源、对应 `/agc/<channel>-win|mac/<version>/` 分区下的 HTTPS `.exe` / `.dmg` 对象,架构键必须属于对应系统。不提供陈旧、未知来源或版本不匹配的下载地址,不泄露上游正文。
|
||||
- AGC 开发态和正式包的平台服务地址统一固定为 `https://dev.genarrative.world`;登录页不提供服务器选择或自定义地址。旧的服务器偏好不能覆盖固定地址;已有会话仍按 origin 隔离,不能将其他服务的凭据迁往 dev。自定义 LLM 配置不属于平台服务器选择。
|
||||
- access token 与 origin 一起保存;已有 dev origin 的 token 保留。没有 origin 的旧 token 一律清除,因为旧版可以单独修改服务器偏好,偏好不能证明 token 来源。随后仅使用 dev 自己的 refresh cookie 恢复或重新登录;原生会话回写同样绑定 dev origin。
|
||||
- 验收覆盖固定 dev 的登录/会话与请求行为、旧服务器偏好、首页入口挂载、动态最新版本链接、清单失败与重试、关闭取消、桌面和移动布局,以及公开清单和安装包的真实可读性。
|
||||
@@ -47,7 +55,7 @@
|
||||
- 正式包启动时检查一次渠道清单;仅当清单版本高于当前版本时显示更新提示,提示包含目标版本与发布说明。
|
||||
- 用户确认后下载更新包:下载期间显示进度与已下载字节数;下载完成后按平台安装。
|
||||
- Windows 使用静默安装模式(NSIS `quiet`),安装启动成功后客户端退出并由安装程序重启新版本;macOS 由客户端在安装完成后重启进程接管新版本。
|
||||
- 渠道在构建期确定并烘焙进产物:`dev-win` 产物只读 `dev-win` 清单,`dev-mac` 产物只读 `dev-mac` 清单,同一份二进制不会在运行期跨渠道切换。
|
||||
- 渠道与系统在构建期确定并烘焙进产物:如 dev 的 Windows 产物只读 `dev-win` 分区,release 的 Mac 产物只读 `release-mac` 分区,同一份二进制不会在运行期跨渠道切换。
|
||||
- 开发态(`npm run agc` / `agc:serve` 由 Vite dev server 提供前端)不检查更新、不显示更新入口,也不下载任何更新包。
|
||||
|
||||
### 失败、重试与幂等
|
||||
@@ -85,19 +93,19 @@
|
||||
}
|
||||
```
|
||||
|
||||
- 渠道与平台映射:
|
||||
- 渠道与平台分区映射(`<channel>` 为 dev、release 或自定义名称):
|
||||
|
||||
| 渠道 | 构建目标 | 清单平台键 | 更新包 | 清单地址 |
|
||||
| 系统 | 构建目标 | 清单平台键 | 更新包 | 清单地址 |
|
||||
| --------- | ------------------------ | ---------------------------------------------- | ------------------------ | ------------------------------------ |
|
||||
| `dev-win` | `x86_64-pc-windows-msvc` | `windows-x86_64` | NSIS `.exe` + `.exe.sig` | `<OSS base>/agc/dev-win/latest.json` |
|
||||
| `dev-mac` | `aarch64-apple-darwin` 或 `x86_64-apple-darwin` | 对应 `darwin-aarch64` 或 `darwin-x86_64` | `*.app.tar.gz` + `.sig` | `<OSS base>/agc/dev-mac/latest.json` |
|
||||
| Windows | `x86_64-pc-windows-msvc` | `windows-x86_64` | NSIS `.exe` + `.exe.sig` | `<OSS base>/agc/<channel>-win/latest.json` |
|
||||
| macOS | `aarch64-apple-darwin` 或 `x86_64-apple-darwin` | 对应 `darwin-aarch64` 或 `darwin-x86_64` | `*.app.tar.gz` + `.sig` | `<OSS base>/agc/<channel>-mac/latest.json` |
|
||||
|
||||
- 对象布局:清单固定写成 `agc/<channel>/latest.json`;安装包与签名写成 `agc/<channel>/<version>/<file>` 与 `<file>.sig`。
|
||||
- 对象布局:清单固定写成 `agc/<channel>-win|mac/latest.json`;安装包与签名写成同一分区的 `<version>/<file>` 与 `<file>.sig`。
|
||||
- macOS 当前采用单架构包:Apple Silicon 使用 `aarch64-apple-darwin`,Intel 使用 `x86_64-apple-darwin`;每次生成的清单只登记本次实际构建的架构,不把单架构原生 Codex 资源挂到另一架构。`universal-apple-darwin` 在版本读取/写入、构建和清单生成之前拒绝。
|
||||
- 渠道清单以实际运行架构为键。两种单架构构建不可轮流覆盖同一个 `latest.json` 并宣称双架构均可更新;当前不实现跨构建合并,Intel 发布需先完成其构建验证与多架构清单发布方案。
|
||||
- 构建期要求:打开 `bundle.createUpdaterArtifacts` 以生成 `.sig`;构建环境提供签名私钥与密码(私钥内容不得入库);公钥写入客户端配置。公钥在首个带更新能力的版本发布后不可更换,更换等于放弃自动更新(只能手动重装)。
|
||||
- 版本递增按渠道独立进行:发布脚本读取该渠道远端 `latest.json` 的 `version`,与本地版本取较高者递增 patch;两个渠道的版本号互不影响。
|
||||
- 版本高水位:发布脚本取「渠道清单版本」与「旧协议迁移指针版本」(迁移窗口内)中的较大值再递增。只看渠道清单会在渠道启用初期把版本链改小 —— 2026-09-17 首次渠道发布即把旧指针的 0.1.57 退回 0.1.48,随后以显式 0.1.60 纠偏;迁移窗口结束(旧指针 404)后自动只剩渠道清单,`dev-mac` 不参与旧指针比较。
|
||||
- 版本递增按渠道及系统分区独立进行:发布脚本读取该分区远端 `latest.json` 的 `version`,与本地版本取较高者递增 patch;不同分区的远端版本互不影响。
|
||||
- 版本高水位:仅 dev 的 Windows 分区在迁移窗口内取「分区清单版本」与「旧协议迁移指针版本」较大值再递增,避免已发布旧客户端版本倒退。迁移窗口结束(旧指针 404)后只读分区清单;release、自定义渠道与所有 Mac 分区均不参与旧指针比较。
|
||||
- 迁移(旧协议 → 渠道清单):
|
||||
- 迁移起点:已发布客户端(含当前线上版本)内置自研清单地址 `agc/latest.json`(sha256 格式),下载与安装由自研 Rust 命令完成。
|
||||
- 迁移策略见「未决问题与决策」。迁移完成后,自研清单解析、下载命令、下载进度事件以及为此放行的 CSP / HTTP 白名单条目按「四不写」整条删除,不留兼容分支与墓碑说明。
|
||||
@@ -106,17 +114,19 @@
|
||||
|
||||
- 发布入口:`npm run ai-game-creator-shell:release:upload`(构建 + 按渠道上传);仅构建不发布的 smoke 使用 `--no-bundle` 分支,不读远端版本、不改版本、不生成清单。
|
||||
- 发布入口只解析一次目标,优先级为 CLI `--target value` / `--target=value` / `-t value`、`AGC_BUILD_TARGET`、Windows 默认值;重复/空目标与不支持目标失败关闭。版本高水位、构建 feature/渠道端点、bundle 路径、产物后缀、清单平台键及摘要必须消费同一个发布上下文,不能分别回读默认目标。
|
||||
- 渠道由构建参数显式指定,并按目标平台校验:Windows 目标只允许 `dev-win`,macOS 目标只允许 `dev-mac`;未显式指定时按目标平台取默认渠道。
|
||||
- 渠道由 `AGC_UPDATE_CHANNEL` 显式指定,默认 dev;Windows 与 macOS 目标均支持 dev、release 和自定义渠道,目标校验独立进行。
|
||||
- 定时调度只在本轮到达的提交包含 AGC 相关路径(客户端、共享包、`server-rs/crates`、AGC 插件、桌面壳图标、根依赖清单)时才触发渠道发布;纯文档或流水线自身的提交只跑 Full Build,不推高客户端版本号。判定失败或勾选强制触发时按"需要发布"处理。
|
||||
- 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA)`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt`。`AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`(CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。锚点仍不可得(清单读取失败或没有 CI 锚点)时降级为「最近客户端改动」列表并注明可能与上一版重复 —— 摘要属于附注,任何情况下都不允许因为它让发布失败。
|
||||
- 清单里的 `commit` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。
|
||||
- 上传:安装包与 `.sig` 上传到 `agc/<channel>/<version>/`,清单以 `--force` 覆盖上传到 `agc/<channel>/latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。
|
||||
- 上传:安装包与 `.sig` 上传到 `agc/<channel>-win|mac/<version>/`,清单以 `--force` 覆盖上传到对应分区的 `latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。
|
||||
- 首装发布:发布脚本生成 `downloads`,Windows 复用已选 NSIS `.exe`,Mac 选择本次版本和目标架构匹配的非空 `.dmg`;缺失、歧义或版本/架构不匹配时失败,不发布带悬空地址的清单。上传顺序为更新包、签名及首装包全部成功后再更新渠道清单,Windows 相同对象只上传一次。`dry-run` 不写 OSS。各渠道独立写自己的清单,由 BFF 汇总,Windows 与 Mac 发布不会覆盖彼此的下载项;Mac 跨架构合并仍遵循现有单架构发布约束。
|
||||
- Jenkins 流水线需要新增渠道参数与签名凭据;签名私钥与密码只以受保护凭据注入当前进程,不写入 workspace、日志或归档产物。
|
||||
- 归档证据:安装包、`.sig`、渠道清单与源码 commit。
|
||||
|
||||
## 验收标准与证据
|
||||
|
||||
渠道与系统分离的定向验证覆盖发布脚本、上传计划、网站配置贯通及跨渠道链接拒绝:`build-release.test.mjs`、`release-oss.test.mjs`、`platform-oss client_downloads` 和 `api-server client_download`。本地隔离数据库的 API smoke 验证 `/healthz` 成功、配置 release 时仅读取 release 分区、查询参数不能覆盖渠道、未发布版本返回空列表且 `no-store`;这不代表已构建或上传 release 安装包。真实 Windows/macOS 安装、签名和更新仍由发布验收单独执行。
|
||||
|
||||
官网下载与固定服务地址已于 `2026-09-19` 完成源码验收:
|
||||
|
||||
| 条款 | 验收方式 | 结果 |
|
||||
@@ -161,8 +171,8 @@
|
||||
- macOS 采用单架构包,只登记实际构建架构;Intel 真机构建与跨架构清单合并未验收,不公开宣称双架构分发就绪。
|
||||
- 旧客户端迁移桥:保留一个版本周期。渠道清单上线后,发布管线同时把旧的 `agc/latest.json`(sha256 格式)指向 `dev-win` 最新安装包,让已发布客户端自动升级到新协议;下个周期整条删除。
|
||||
- 签名密钥:由本仓库维护者生成并保管,私钥保存在仓库外(`%USERPROFILE%\.tauri\genarrative-agc-updater.key`),只有公钥进入客户端配置;Jenkins 用受保护凭据 `AgcUpdaterSigningKey` 与 `AgcUpdaterSigningKeyPassword` 注入为 Tauri 打包器读取的 `TAURI_SIGNING_PRIVATE_KEY` 与 `TAURI_SIGNING_PRIVATE_KEY_PASSWORD`,本机可用 `TAURI_SIGNING_PRIVATE_KEY_PATH` 指向同一私钥。当前密钥不带密码;首次发布前仍可重新生成,首次发布后不可更换。
|
||||
- macOS 发布方式:`dev-mac` 产物在本机 mac 上执行发布入口上传,Jenkins 暂不新增 macOS 节点;macOS 代码签名与公证凭据未确认前,相关闭环记为未验证项,不静默通过。
|
||||
- macOS 发布方式:对应渠道的 Mac 产物在本机 mac 上执行发布入口上传,Jenkins 暂不新增 macOS 节点;macOS 代码签名与公证凭据未确认前,相关闭环记为未验证项,不静默通过。
|
||||
|
||||
待办:
|
||||
|
||||
- macOS `dev-mac` 渠道落地(macOS 构建机、签名与公证、安装后重启验证、是否接入 Jenkins macOS 节点)暂缓,由后续独立变更单独完成;在此之前 `dev-mac` 渠道只有构建与清单能力,不发布。
|
||||
- macOS 实际发布(macOS 构建机、签名与公证、安装后重启验证、是否接入 Jenkins macOS 节点)仍需独立验证;代码中的渠道与分区支持不等于已有 Mac 安装包发布。
|
||||
|
||||
@@ -8,6 +8,17 @@ AGC 客户端接入公共 OSS 上的**游戏模板库**(真·游戏模板,
|
||||
- 客户端侧:Rust `template_library` 模块(读清单、下载、安装、建项目)+ 模板库全屏页 + 首页模板推荐 + 左侧导航入口。
|
||||
- 不在本次范围:模板制作工具、模板审核、模板计费、增量更新、已建项目的模板回填。
|
||||
|
||||
## 模板库灰度访问
|
||||
|
||||
- 后台现有灰度发布页登记 `agc:template-library`,控制整个模板库;复用用户 ID 白名单、用户标签、拒绝名单和稳定用户分桶比例,不新增数据库表或字段。未配置此 Gate 时默认关闭;已配置且停用灰度时遵循现有语义全量开放,启用灰度时拒绝名单优先于白名单及比例。
|
||||
- `/api/runtime/frontend-config` 增加 `agcTemplateLibraryEnabled`,按认证主体返回服务端权威结论。客户端尚未得到结果、匿名或请求失败时关闭入口;权限结果不作为离线缓存。
|
||||
- 登录、主体变化、窗口重新获得焦点和每次模板操作时刷新权限,不承诺后台修改的实时推送。未获准时首页模板推荐和左侧模板库导航隐藏,不读取 OSS 清单;已在模板页检测到失去权限时回到首页。原生命令明确拒绝或权限检查失败后同样清空并关闭入口。账号切换或退出后立即清空前一主体的清单、操作状态和可见性,旧异步响应不能恢复权限或导航。
|
||||
- 原生清单读取、下载和模板建项命令均独立核对当前平台会话及服务端权限,不能依靠前端隐藏;远端等待后的会话变化必须拒绝,安装和建项写入使用当前会话身份保护。已缓存清单和已安装模板不能绕过权限。
|
||||
- 此灰度控制当前客户端的产品功能,不承诺公开 OSS 模板内容的保密性,也不限制已创建项目的正常打开和编辑。旧客户端升级后才接入此控制。
|
||||
- 验收覆盖缺省关闭、明确全量、允许/拒绝名单、标签和比例、请求失败、缓存绕过、原生命令阻断、首页/导航/模板页以及账号切换竞态。
|
||||
- 验证入口:后台灰度页面测试、`useTemplateLibrary.test.tsx`、AppSurface 实际挂载的 template 用例、原生 `template_library` 测试和后端 `frontend_runtime_config` 测试。退出开始使用既有平台会话代次立即撤销,建项返回、revision 读取和预览核验后的旧回调均不得导航或登记最近项目;同主体 token 轮换不误撤销原生身份。
|
||||
- 本地隔离数据库已验证 Gate 经后台 API 保存后可重新读回,匿名运行时配置为 false;后台受控浏览器 smoke 验证桌面和 320px 布局及保存确认交互。线上 OSS 下载和正式安装包登录后的端到端操作不由这些测试替代。
|
||||
|
||||
## OSS 契约
|
||||
|
||||
```text
|
||||
|
||||
@@ -75,6 +75,7 @@ npm run check:server-rs-ddd
|
||||
- 健康检查:`GET /healthz`、`GET /readyz`。
|
||||
- 后台管理:`/admin/api/*`,现役路由包括登录与账号管理、Dashboard / 概览、HTTP debug、埋点、表查询、通用 feature gate、编辑器定价与素材 / 精选管理,以及账号侧兑换码、邀请码、任务、钱包、充值与退款管理;不再挂载旧创作入口配置、旧作品互动或旧玩法运营路由。环境变量管理员固定作为 owner,持久化 member 每次请求按当前 `enabled`、`token_version` 和一级 Tab 权限实时校验;账号管理仅 owner 可访问,未登记权限映射的新后台路由对 member 默认拒绝。完整权限矩阵见 [`docs/technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md`](./technical/【后台管理】多账号与Tab访问权限方案-2026-07-14.md),Dashboard 指标口径见 [`docs/technical/【后台管理】Dashboard运营看板方案-2026-06-23.md`](./technical/【后台管理】Dashboard运营看板方案-2026-06-23.md)。
|
||||
- 通用灰度控制面固定为后台 `#gray-release` 与 `GET/PUT /admin/api/feature-gates`;页面固定目标只登记现役功能,不读取旧 `/admin/api/creation-entry/config`,也不恢复 `creation-entry:*` 动态目标。
|
||||
- AGC 模板库使用 `agc:template-library`,不新增 schema;`GET /api/runtime/frontend-config` 追加账号相关的 `agcTemplateLibraryEnabled`,匿名和 Gate 缺失为 false,存在配置时复用通用灰度规则。响应设置 `Cache-Control: no-store` 与 `Vary: Authorization`,客户端原生操作独立检查权限。官网公开 `GET /api/client-downloads` 则读取服务端 `GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL`(默认 dev)对应的 Windows/macOS 分区,渠道与系统独立,不从请求 query 接受 URL 或渠道覆盖。
|
||||
- 认证与账号:`/api/auth/*`、`/api/profile/me`,包括短信、密码、微信、refresh session、多端会话和登出。
|
||||
- 个人中心:`/api/profile/*`,包括钱包流水、任务、领奖、充值、反馈、邀请和兑换等账号侧能力。
|
||||
- 平台基础能力:`/api/llm/*`、`/api/speech/volcengine/*`,只保留通用 LLM 和语音代理。
|
||||
|
||||
@@ -22,7 +22,7 @@ pipeline {
|
||||
string(name: 'SOURCE_BRANCH', defaultValue: 'master', description: '源码分支')
|
||||
string(name: 'COMMIT_HASH', defaultValue: '', description: '可选,指定属于 SOURCE_BRANCH 的 Git commit')
|
||||
string(name: 'AGC_RELEASE_VERSION', defaultValue: '', description: '可选,指定三段版本号;留空则按该渠道 OSS 与本地版本自动递增 patch')
|
||||
choice(name: 'AGC_UPDATE_CHANNEL', choices: ['dev-win', 'dev-mac'], description: 'AGC 发布渠道;dev-win 在 Windows 节点执行,dev-mac 需在 macOS 构建机本地执行')
|
||||
string(name: 'AGC_UPDATE_CHANNEL', defaultValue: 'dev', description: 'AGC 发布渠道:dev、release 或自定义小写名称;此 Job 构建 Windows,macOS 在对应构建机执行')
|
||||
booleanParam(name: 'AGC_RELEASE_DRY_RUN', defaultValue: false, description: '勾选后只构建并打印将要执行的上传命令,不写入 OSS')
|
||||
text(name: 'AGC_UPDATE_RELEASE_NOTES', defaultValue: '', description: '可选,支持多行文本;留空则由本次发布的客户端相关提交自动生成更新摘要')
|
||||
string(name: 'OSSUTIL_BIN', defaultValue: 'ossutil', description: 'ossutil 或 ossutil.exe 的绝对路径/命令名')
|
||||
@@ -134,7 +134,10 @@ pipeline {
|
||||
def anchor = ''
|
||||
try {
|
||||
def previousBuild = currentBuild.previousSuccessfulBuild
|
||||
anchor = (previousBuild?.buildVariables?.COMMIT_HASH ?: '').toString().trim()
|
||||
def previousChannel = (previousBuild?.buildVariables?.AGC_UPDATE_CHANNEL ?: '').toString().trim()
|
||||
if (previousChannel == params.AGC_UPDATE_CHANNEL.trim()) {
|
||||
anchor = (previousBuild?.buildVariables?.COMMIT_HASH ?: '').toString().trim()
|
||||
}
|
||||
} catch (error) {
|
||||
echo "读取上一次成功构建的 commit 失败,跳过摘要锚点兜底:${error}"
|
||||
}
|
||||
@@ -154,6 +157,7 @@ pipeline {
|
||||
"OSSUTIL_BIN=${params.OSSUTIL_BIN}",
|
||||
"AGC_RELEASE_VERSION=${params.AGC_RELEASE_VERSION}",
|
||||
"AGC_UPDATE_CHANNEL=${params.AGC_UPDATE_CHANNEL}",
|
||||
'AGC_BUILD_TARGET=x86_64-pc-windows-msvc',
|
||||
"AGC_RELEASE_DRY_RUN=${params.AGC_RELEASE_DRY_RUN ? '1' : '0'}",
|
||||
"AGC_UPDATE_PREVIOUS_COMMIT=${env.AGC_UPDATE_PREVIOUS_COMMIT ?: ''}",
|
||||
"AGC_UPDATE_RELEASE_NOTES=${params.AGC_UPDATE_RELEASE_NOTES}",
|
||||
|
||||
@@ -1006,6 +1006,51 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn frontend_runtime_config_template_library_is_scoped_to_authenticated_gate() {
|
||||
let state = AppState::new(AppConfig::default()).expect("state should build");
|
||||
let user = seed_phone_user_with_password(&state, "13800138194", TEST_PASSWORD).await;
|
||||
let token = sign_test_user_token(&state, &user, "sess_template_library_gate");
|
||||
let mut gate = test_feature_gate(module_runtime::AGC_TEMPLATE_LIBRARY_GATE_KEY);
|
||||
let mut cases = vec![(vec![], false)];
|
||||
cases.push((vec![gate.clone()], false));
|
||||
gate.allow_user_ids = vec![user.id.clone()];
|
||||
cases.push((vec![gate.clone()], true));
|
||||
gate.deny_user_ids = vec![user.id.clone()];
|
||||
cases.push((vec![gate.clone()], false));
|
||||
gate.enabled = false;
|
||||
cases.push((vec![gate.clone()], true));
|
||||
gate.enabled = true;
|
||||
gate.allow_user_ids.clear();
|
||||
gate.deny_user_ids.clear();
|
||||
gate.rollout_percent = 100;
|
||||
cases.push((vec![gate], true));
|
||||
|
||||
for (gates, expected) in cases {
|
||||
state.set_test_feature_gate_config(gates);
|
||||
let app = build_router(state.clone());
|
||||
for authenticated in [false, true] {
|
||||
let mut request = Request::builder().uri("/api/runtime/frontend-config");
|
||||
if authenticated {
|
||||
request = request.header("authorization", format!("Bearer {token}"));
|
||||
}
|
||||
let response = app
|
||||
.clone()
|
||||
.oneshot(request.body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers().get("cache-control").unwrap(), "no-store");
|
||||
assert_eq!(response.headers().get("vary").unwrap(), "Authorization");
|
||||
let payload = read_json_response(response).await;
|
||||
assert_eq!(
|
||||
payload["agcTemplateLibraryEnabled"],
|
||||
Value::Bool(authenticated && expected)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn frontend_runtime_config_returns_agent_sidebar_env_flag() {
|
||||
let config = AppConfig {
|
||||
|
||||
@@ -91,6 +91,7 @@ pub struct AppConfig {
|
||||
pub editor_bgfilter_circuit_failure_threshold: u32,
|
||||
pub editor_bgfilter_circuit_cooldown: Duration,
|
||||
pub image_editor_agent_sidebar_enabled: bool,
|
||||
pub client_download_channel: String,
|
||||
pub log_filter: String,
|
||||
pub otel_enabled: bool,
|
||||
pub admin_username: Option<String>,
|
||||
@@ -397,6 +398,7 @@ impl Default for AppConfig {
|
||||
DEFAULT_EDITOR_BGFILTER_CIRCUIT_COOLDOWN_SECONDS,
|
||||
),
|
||||
image_editor_agent_sidebar_enabled: false,
|
||||
client_download_channel: "dev".to_string(),
|
||||
log_filter: "info,tower_http=info".to_string(),
|
||||
otel_enabled: false,
|
||||
admin_username: None,
|
||||
@@ -713,6 +715,10 @@ impl AppConfig {
|
||||
]) {
|
||||
config.editor_bgfilter_circuit_cooldown = Duration::from_secs(cooldown_seconds.max(1));
|
||||
}
|
||||
if let Ok(channel) = std::env::var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL") {
|
||||
// 显式空值或非法值也保留,由下载入口失败关闭,不能悄悄改读 dev。
|
||||
config.client_download_channel = channel.trim().to_string();
|
||||
}
|
||||
if let Some(enabled) =
|
||||
read_first_bool_env(&["GENARRATIVE_ENABLE_IMAGE_EDITOR_AGENT_SIDEBAR"])
|
||||
{
|
||||
@@ -3046,6 +3052,36 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn client_download_channel_defaults_to_dev_and_preserves_explicit_configuration() {
|
||||
let _guard = ENV_LOCK
|
||||
.get_or_init(|| Mutex::new(()))
|
||||
.lock()
|
||||
.expect("env lock");
|
||||
let previous = std::env::var_os("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL");
|
||||
unsafe {
|
||||
std::env::remove_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL");
|
||||
}
|
||||
assert_eq!(AppConfig::from_env().client_download_channel, "dev");
|
||||
for (value, expected) in [
|
||||
("release", "release"),
|
||||
(" qa-2026 ", "qa-2026"),
|
||||
("", ""),
|
||||
("dev-win", "dev-win"),
|
||||
] {
|
||||
unsafe {
|
||||
std::env::set_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL", value);
|
||||
}
|
||||
assert_eq!(AppConfig::from_env().client_download_channel, expected);
|
||||
}
|
||||
unsafe {
|
||||
match previous {
|
||||
Some(value) => std::env::set_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL", value),
|
||||
None => std::env::remove_var("GENARRATIVE_CLIENT_DOWNLOAD_CHANNEL"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn from_env_reads_prefixed_character_animation_ffmpeg_paths() {
|
||||
let _guard = ENV_LOCK
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Extension, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
http::{HeaderMap, StatusCode, header},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
api_response::json_success_body, auth::optional_access_token_from_headers,
|
||||
@@ -15,13 +15,14 @@ use crate::{
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct FrontendRuntimeConfigResponse {
|
||||
pub image_editor_agent_sidebar_enabled: bool,
|
||||
pub agc_template_library_enabled: bool,
|
||||
}
|
||||
|
||||
pub async fn get_frontend_runtime_config(
|
||||
State(state): State<AppState>,
|
||||
Extension(request_context): Extension<RequestContext>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, AppError> {
|
||||
) -> Result<Response, AppError> {
|
||||
let authenticated = optional_access_token_from_headers(
|
||||
&state,
|
||||
"/api/runtime/frontend-config".to_string(),
|
||||
@@ -44,10 +45,30 @@ pub async fn get_frontend_runtime_config(
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok(json_success_body(
|
||||
Some(&request_context),
|
||||
FrontendRuntimeConfigResponse {
|
||||
image_editor_agent_sidebar_enabled,
|
||||
},
|
||||
))
|
||||
let agc_template_library_enabled = state
|
||||
.is_agc_template_library_enabled_for_user(user_id)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
AppError::from_status(StatusCode::BAD_GATEWAY)
|
||||
.with_message("读取前端运行时配置失败")
|
||||
.with_details(json!({
|
||||
"provider": "spacetimedb",
|
||||
"message": error.to_string(),
|
||||
}))
|
||||
})?;
|
||||
|
||||
Ok((
|
||||
[
|
||||
(header::CACHE_CONTROL, "no-store"),
|
||||
(header::VARY, "Authorization"),
|
||||
],
|
||||
json_success_body(
|
||||
Some(&request_context),
|
||||
FrontendRuntimeConfigResponse {
|
||||
image_editor_agent_sidebar_enabled,
|
||||
agc_template_library_enabled,
|
||||
},
|
||||
),
|
||||
)
|
||||
.into_response())
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user