Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 623e007fae | |||
| fc0ce4ee5f | |||
| c1d26f11df |
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2611,8 +2611,8 @@ async fn bridge_web_search_at(root: &Path, arguments: &Value, search_url: &str)
|
||||
#[cfg(all(windows, target_arch = "x86_64", feature = "unity-editor-execute"))]
|
||||
async fn bridge_unity_execute(state: &DirectToolBridgeState, arguments: &Value) -> Value {
|
||||
let prepared = (|| {
|
||||
if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(&state.root) {
|
||||
return Err("当前项目不是 Unity 项目或 Unity 插件不可用".to_string());
|
||||
if !crate::builtin_plugins::unity_editor_agent_tool_available() {
|
||||
return Err("当前 Unity 插件不可用".to_string());
|
||||
}
|
||||
enforce_project_permission_policy(&state.root, "unity.editor.execute")?;
|
||||
bridge_reject_unknown_fields(arguments, &["code"])?;
|
||||
@@ -2637,7 +2637,7 @@ async fn bridge_unity_execute(state: &DirectToolBridgeState, arguments: &Value)
|
||||
};
|
||||
let root = state.root.clone();
|
||||
let result = tokio::task::spawn_blocking(move || {
|
||||
if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(&root) {
|
||||
if !crate::builtin_plugins::unity_editor_agent_tool_available() {
|
||||
return Err("当前 Unity 插件不可用".to_string());
|
||||
}
|
||||
crate::editor_adapters::execute_unity_editor_code(&root, &code)
|
||||
@@ -2667,10 +2667,9 @@ async fn bridge_cocos_call(
|
||||
if !crate::builtin_plugins::is_enabled(crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID) {
|
||||
return bridge_tool_result("Cocos 编辑器插件已禁用".to_string(), Vec::new(), true);
|
||||
}
|
||||
if !crate::builtin_plugins::cocos_editor_agent_tool_available_for_project(&state.root) {
|
||||
if !crate::builtin_plugins::cocos_editor_agent_tool_available() {
|
||||
return bridge_tool_result(
|
||||
"当前项目不是 Cocos Creator 项目或 Cocos 插件不可用,agc_cocos_execute 不可用"
|
||||
.to_string(),
|
||||
"当前 Cocos 插件不可用,agc_cocos_execute 不可用".to_string(),
|
||||
Vec::new(),
|
||||
true,
|
||||
);
|
||||
@@ -2735,9 +2734,9 @@ async fn bridge_cocos_call(
|
||||
// validated Inspector/pipe bridge. It does not mutate AGC's project
|
||||
// files or manifest, so it must not wait on `.agent/project.lock`.
|
||||
// File-writing tools keep their own project lock separately.
|
||||
if !crate::builtin_plugins::cocos_editor_agent_tool_available_for_project(&root) {
|
||||
if !crate::builtin_plugins::cocos_editor_agent_tool_available() {
|
||||
return Err(cocos_editor_bridge::BridgeError::InvalidInput(
|
||||
"当前项目不是 Cocos Creator 项目或 Cocos 插件不可用".to_string(),
|
||||
"当前 Cocos 插件不可用".to_string(),
|
||||
));
|
||||
}
|
||||
cocos_editor_bridge::execute_cocos_editor_code_for_project(
|
||||
@@ -2840,7 +2839,7 @@ async fn handle_direct_tool_bridge(
|
||||
let result = match request.tool.as_str() {
|
||||
// 隔离 MCP 只取工具名,不接触真实 AppData 或读取权限。
|
||||
"builtin.plugins.tools" => bridge_tool_result(
|
||||
json!({"tools": crate::builtin_plugins::available_agent_tools_for_project(&state.root)}).to_string(),
|
||||
json!({"tools": crate::builtin_plugins::available_agent_tools()}).to_string(),
|
||||
Vec::new(),
|
||||
false,
|
||||
),
|
||||
|
||||
@@ -2057,6 +2057,110 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn builtin_editor_tools_follow_independent_switches_for_non_engine_projects() {
|
||||
let _guard = crate::builtin_plugins::test_lock();
|
||||
let config = tempfile::tempdir().unwrap();
|
||||
crate::builtin_plugins::initialize(config.path()).unwrap();
|
||||
let project = crate::tests::canonical_test_tempdir("builtin-editor-mcp-");
|
||||
std::fs::create_dir_all(project.path().join(".agent")).unwrap();
|
||||
std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap();
|
||||
let bridge =
|
||||
super::super::direct_tool_bridge::start_direct_tool_bridge(project.path(), false)
|
||||
.await
|
||||
.unwrap();
|
||||
for (cocos_enabled, unity_enabled) in
|
||||
[(false, false), (true, false), (false, true), (true, true)]
|
||||
{
|
||||
crate::builtin_plugins::set_enabled(
|
||||
crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID,
|
||||
cocos_enabled,
|
||||
)
|
||||
.unwrap();
|
||||
crate::builtin_plugins::set_enabled(
|
||||
crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID,
|
||||
unity_enabled,
|
||||
)
|
||||
.unwrap();
|
||||
let response = EXTERNAL_MCP_BRIDGE_URL
|
||||
.scope(
|
||||
bridge.url().to_string(),
|
||||
call_client_tool_bridge("builtin.plugins.tools", &json!({})),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(response["isError"], false);
|
||||
let available: Value =
|
||||
serde_json::from_str(response["content"][0]["text"].as_str().unwrap()).unwrap();
|
||||
let specs = EXTERNAL_MCP_BRIDGE_URL
|
||||
.scope(bridge.url().to_string(), direct_tools_mcp_specs())
|
||||
.await;
|
||||
let cocos_expected =
|
||||
cocos_enabled && cfg!(all(windows, feature = "cocos-editor-execute"));
|
||||
for (runtime_tool, mcp_tool, expected) in [
|
||||
(
|
||||
crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME,
|
||||
"agc_cocos_execute",
|
||||
cocos_expected,
|
||||
),
|
||||
(
|
||||
crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME,
|
||||
"agc_unity_execute",
|
||||
unity_enabled
|
||||
&& cfg!(all(
|
||||
windows,
|
||||
target_arch = "x86_64",
|
||||
feature = "unity-editor-execute"
|
||||
)),
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
available["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|tool| tool == runtime_tool),
|
||||
expected,
|
||||
"{runtime_tool}"
|
||||
);
|
||||
assert_eq!(
|
||||
specs["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|tool| tool["name"] == mcp_tool),
|
||||
expected,
|
||||
"{mcp_tool}"
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
specs["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|tool| tool["name"]
|
||||
.as_str()
|
||||
.is_some_and(cocos_editor_bridge::is_cocos_operation))
|
||||
.count(),
|
||||
if cocos_expected {
|
||||
cocos_editor_bridge::cocos_operation_catalog().len()
|
||||
} else {
|
||||
0
|
||||
},
|
||||
);
|
||||
}
|
||||
std::fs::write(config.path().join("extensions/builtin-plugins.json"), "{").unwrap();
|
||||
let specs = EXTERNAL_MCP_BRIDGE_URL
|
||||
.scope(bridge.url().to_string(), direct_tools_mcp_specs())
|
||||
.await;
|
||||
for tool in ["agc_cocos_execute", "agc_unity_execute"] {
|
||||
assert!(!specs["tools"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|entry| entry["name"] == tool));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(windows, feature = "cocos-editor-execute"))]
|
||||
#[test]
|
||||
fn builtin_mcp_process_probe() {
|
||||
@@ -2097,13 +2201,6 @@ mod tests {
|
||||
let config = tempfile::tempdir().unwrap();
|
||||
crate::builtin_plugins::initialize(config.path()).unwrap();
|
||||
let project = crate::tests::canonical_test_tempdir("builtin-mcp-project-");
|
||||
// 工具目录现在按当前项目类型过滤,fixture 必须具备最小 Cocos Creator 结构。
|
||||
std::fs::write(
|
||||
project.path().join("package.json"),
|
||||
r#"{"creator":{"version":"3.8.8"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::create_dir(project.path().join("assets")).unwrap();
|
||||
std::fs::create_dir_all(project.path().join(".agent")).unwrap();
|
||||
std::fs::write(project.path().join(".agent/manifest.json"), "{}").unwrap();
|
||||
let bridge =
|
||||
|
||||
+3
-5
@@ -248,8 +248,8 @@ fn build_game_creator_agent_background_tool_plan_request_at(
|
||||
"你正在执行一个自主游戏构建任务。请按自己的判断规划并直接调用当前广告的原生工具完成目标;任务可以与其它 Agent 并行,依赖只作为参考,不要等待或索要平台资产/验收回执。已有观察只代表已发生的事实,完成后直接调用 respond_to_user。\n\n运行上下文:\n{context}\n\n任务:\n{effective_task}\n\n已有观察:\n{observations_json}"
|
||||
);
|
||||
let mut function_tools =
|
||||
crate::agent_native_tools::build_agent_runtime_native_function_tools_for_project(
|
||||
root, agent_id,
|
||||
crate::agent_native_tools::build_agent_runtime_native_function_tools_for_agent(
|
||||
agent_id,
|
||||
)?;
|
||||
remove_relaxed_autonomous_platform_validation_tools(&mut function_tools)?;
|
||||
// Platform-backed generation remains an optional capability. A
|
||||
@@ -487,9 +487,7 @@ fn build_game_creator_agent_background_tool_plan_request_at(
|
||||
.with_max_output_tokens(AGENT_RUNTIME_TOOL_PLAN_MAX_OUTPUT_TOKENS)
|
||||
.with_response_text_verbosity(platform_llm::LlmResponseTextVerbosity::Low)
|
||||
.with_function_tools(
|
||||
crate::agent_native_tools::build_agent_runtime_native_function_tools_for_project(
|
||||
root, agent_id,
|
||||
)?,
|
||||
crate::agent_native_tools::build_agent_runtime_native_function_tools_for_agent(agent_id)?,
|
||||
)
|
||||
.with_tool_choice(platform_llm::LlmToolChoice::Required);
|
||||
if runtime_owner_artifact_validation_available {
|
||||
|
||||
+1
-1
@@ -969,7 +969,7 @@ pub(in crate::agent) async fn request_game_creator_agent_background_tool_plan_at
|
||||
|| force_autonomous_pre_mutation
|
||||
{
|
||||
request.function_tools =
|
||||
crate::agent_native_tools::build_agent_runtime_native_function_tools_for_project(root, agent_id)?;
|
||||
crate::agent_native_tools::build_agent_runtime_native_function_tools_for_agent(agent_id)?;
|
||||
if runtime_owner_artifact_validation_available {
|
||||
remove_autonomous_owner_manual_verification_tools(
|
||||
&mut request.function_tools,
|
||||
|
||||
+61
-9
@@ -162,11 +162,6 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at(
|
||||
let mut confirm_tools = Vec::new();
|
||||
let mut denied_tools = Vec::new();
|
||||
for tool in agent_runtime_executable_tools() {
|
||||
if tool == crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME
|
||||
&& !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if isolated && ISOLATED_AGENT_UNSCOPED_DENIED_TOOLS.contains(&tool) {
|
||||
denied_tools.push(tool.to_string());
|
||||
continue;
|
||||
@@ -209,10 +204,6 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at(
|
||||
run_profile_binding_fingerprint: String::new(),
|
||||
allowed_tools: agent_runtime_executable_tools()
|
||||
.into_iter()
|
||||
.filter(|tool| {
|
||||
*tool != crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME
|
||||
|| crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root)
|
||||
})
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
auto_tools,
|
||||
@@ -222,6 +213,67 @@ pub(in crate::agent) fn agent_runtime_tool_policy_snapshot_at(
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod builtin_editor_policy_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn builtin_editor_tools_follow_switches_for_non_engine_projects() {
|
||||
let _guard = crate::builtin_plugins::test_lock();
|
||||
let config = tempfile::tempdir().unwrap();
|
||||
crate::builtin_plugins::initialize(config.path()).unwrap();
|
||||
let project = crate::tests::canonical_test_tempdir("builtin-editor-policy-");
|
||||
for (cocos_enabled, unity_enabled) in
|
||||
[(false, false), (true, false), (false, true), (true, true)]
|
||||
{
|
||||
crate::builtin_plugins::set_enabled(
|
||||
crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID,
|
||||
cocos_enabled,
|
||||
)
|
||||
.unwrap();
|
||||
crate::builtin_plugins::set_enabled(
|
||||
crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID,
|
||||
unity_enabled,
|
||||
)
|
||||
.unwrap();
|
||||
let snapshot =
|
||||
agent_runtime_tool_policy_snapshot_at(project.path(), "project-supervisor")
|
||||
.unwrap();
|
||||
for (tool, expected) in [
|
||||
(
|
||||
crate::builtin_plugins::AGC_COCOS_EDITOR_TOOL_NAME,
|
||||
cocos_enabled && cfg!(all(windows, feature = "cocos-editor-execute")),
|
||||
),
|
||||
(
|
||||
crate::builtin_plugins::AGC_UNITY_EDITOR_TOOL_NAME,
|
||||
unity_enabled
|
||||
&& cfg!(all(
|
||||
windows,
|
||||
target_arch = "x86_64",
|
||||
feature = "unity-editor-execute"
|
||||
)),
|
||||
),
|
||||
] {
|
||||
assert_eq!(
|
||||
snapshot.allowed_tools.iter().any(|entry| entry == tool),
|
||||
expected,
|
||||
"{tool}"
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.auto_tools
|
||||
.iter()
|
||||
.chain(&snapshot.confirm_tools)
|
||||
.chain(&snapshot.denied_tools)
|
||||
.any(|entry| entry == tool),
|
||||
expected,
|
||||
"{tool}",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn agent_runtime_tool_policy_snapshot_for_run_at(
|
||||
root: &Path,
|
||||
agent_id: &str,
|
||||
|
||||
@@ -33,11 +33,11 @@ pub(in crate::agent) fn observe_agent_runtime_cocos_editor_execute(
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
if !crate::builtin_plugins::cocos_editor_agent_tool_available_for_project(root) {
|
||||
if !crate::builtin_plugins::cocos_editor_agent_tool_available() {
|
||||
return AgentRuntimeToolObservation {
|
||||
tool: "cocos.editor.execute".to_string(),
|
||||
status: "failed".to_string(),
|
||||
summary: "当前项目不是 Cocos Creator 项目或 Cocos 插件不可用".to_string(),
|
||||
summary: "当前 Cocos 插件不可用".to_string(),
|
||||
detail: None,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@ pub(in crate::agent) fn observe_agent_runtime_unity_editor_execute(
|
||||
if pending_action.is_none() {
|
||||
return Err("unity.editor.execute 必须绑定 durable pending action".to_string());
|
||||
}
|
||||
if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root) {
|
||||
return Err("当前项目不是 Unity 项目或 Unity 插件不可用".to_string());
|
||||
if !crate::builtin_plugins::unity_editor_agent_tool_available() {
|
||||
return Err("当前 Unity 插件不可用".to_string());
|
||||
}
|
||||
crate::editor_adapters::execute_unity_editor_code(root, &input.code)
|
||||
})();
|
||||
|
||||
@@ -306,18 +306,6 @@ pub(crate) fn build_agent_runtime_native_function_tools_for_agent(
|
||||
Ok(functions)
|
||||
}
|
||||
|
||||
pub(crate) fn build_agent_runtime_native_function_tools_for_project(
|
||||
root: &std::path::Path,
|
||||
agent_id: &str,
|
||||
) -> Result<Vec<LlmFunctionTool>, String> {
|
||||
let mut tools = build_agent_runtime_native_function_tools_for_agent(agent_id)?;
|
||||
if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(root) {
|
||||
let name = native_runtime_function_name_for_tool("unity.editor.execute");
|
||||
tools.retain(|tool| tool.name != name);
|
||||
}
|
||||
Ok(tools)
|
||||
}
|
||||
|
||||
pub(crate) fn agent_runtime_native_tool_allowed_for_agent(tool: &str) -> bool {
|
||||
agent_runtime_native_capability_registry()
|
||||
.ok()
|
||||
|
||||
@@ -259,17 +259,6 @@ pub(crate) fn cocos_editor_agent_tool_available() -> bool {
|
||||
agent_tool_available(BuiltinPlugin::CocosEditor)
|
||||
}
|
||||
|
||||
/// Cocos 编辑器插件只对当前确认为 Cocos Creator 的项目可用。
|
||||
///
|
||||
/// 项目类型以项目根的真实结构为准,不能仅凭插件开关或编译 feature 推断。
|
||||
pub(crate) fn cocos_editor_agent_tool_available_for_project(root: &Path) -> bool {
|
||||
cocos_editor_agent_tool_available()
|
||||
&& crate::project::discover_local_cocos_project_root(root)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub(crate) fn available_agent_tools() -> Vec<&'static str> {
|
||||
let mut available = Vec::new();
|
||||
if cocos_editor_agent_tool_available() {
|
||||
@@ -287,33 +276,10 @@ pub(crate) fn available_agent_tools() -> Vec<&'static str> {
|
||||
available
|
||||
}
|
||||
|
||||
/// Project-scoped variant used by the isolated DirectProject MCP bridge.
|
||||
/// Without a project root the safe result is an empty Cocos tool set.
|
||||
pub(crate) fn available_agent_tools_for_project(root: &Path) -> Vec<&'static str> {
|
||||
available_agent_tools()
|
||||
.into_iter()
|
||||
.filter(|tool| {
|
||||
if *tool == AGC_UNITY_EDITOR_TOOL_NAME {
|
||||
unity_editor_agent_tool_available_for_project(root)
|
||||
} else {
|
||||
cocos_editor_agent_tool_available_for_project(root)
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn unity_editor_agent_tool_available() -> bool {
|
||||
agent_tool_available(BuiltinPlugin::UnityEditor)
|
||||
}
|
||||
|
||||
pub(crate) fn unity_editor_agent_tool_available_for_project(root: &Path) -> bool {
|
||||
unity_editor_agent_tool_available()
|
||||
&& crate::project::discover_local_unity_project_root(root)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) use tests::test_lock;
|
||||
|
||||
@@ -330,44 +296,25 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unity_tool_visibility_requires_project_platform_and_independent_toggle() {
|
||||
fn unity_tool_visibility_requires_platform_and_independent_toggle() {
|
||||
let _guard = test_lock();
|
||||
let config = tempdir().unwrap();
|
||||
initialize(config.path()).unwrap();
|
||||
let project = tempdir().unwrap();
|
||||
for directory in ["Assets", "Packages", "ProjectSettings"] {
|
||||
fs::create_dir(project.path().join(directory)).unwrap();
|
||||
}
|
||||
fs::write(
|
||||
project.path().join("ProjectSettings/ProjectVersion.txt"),
|
||||
"m_EditorVersion: 6000.0.1f1",
|
||||
)
|
||||
.unwrap();
|
||||
let supported = cfg!(all(
|
||||
windows,
|
||||
target_arch = "x86_64",
|
||||
feature = "unity-editor-execute"
|
||||
));
|
||||
assert_eq!(
|
||||
available_agent_tools_for_project(project.path()).contains(&AGC_UNITY_EDITOR_TOOL_NAME),
|
||||
available_agent_tools().contains(&AGC_UNITY_EDITOR_TOOL_NAME),
|
||||
supported
|
||||
);
|
||||
assert!(
|
||||
!available_agent_tools_for_project(config.path()).contains(&AGC_UNITY_EDITOR_TOOL_NAME)
|
||||
);
|
||||
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, false).unwrap();
|
||||
assert_eq!(
|
||||
unity_editor_agent_tool_available_for_project(project.path()),
|
||||
supported
|
||||
);
|
||||
assert_eq!(unity_editor_agent_tool_available(), supported);
|
||||
set_enabled(AGC_UNITY_EDITOR_PLUGIN_ID, false).unwrap();
|
||||
assert!(!unity_editor_agent_tool_available_for_project(
|
||||
project.path()
|
||||
));
|
||||
assert!(!unity_editor_agent_tool_available());
|
||||
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).unwrap();
|
||||
assert!(!unity_editor_agent_tool_available_for_project(
|
||||
project.path()
|
||||
));
|
||||
assert!(!unity_editor_agent_tool_available());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -531,32 +478,4 @@ mod tests {
|
||||
tool_visible_when_enabled
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_scoped_availability_requires_a_cocos_creator_root() {
|
||||
let _guard = test_lock();
|
||||
let directory = tempdir().expect("temp config");
|
||||
initialize(directory.path()).expect("initialize");
|
||||
set_enabled(AGC_COCOS_EDITOR_PLUGIN_ID, true).expect("enable");
|
||||
let non_cocos = tempdir().expect("non-cocos project");
|
||||
assert!(!cocos_editor_agent_tool_available_for_project(
|
||||
non_cocos.path()
|
||||
));
|
||||
|
||||
let cocos = tempdir().expect("cocos project");
|
||||
fs::write(
|
||||
cocos.path().join("package.json"),
|
||||
r#"{"creator":{"version":"3.8.8"}}"#,
|
||||
)
|
||||
.expect("cocos package");
|
||||
fs::create_dir(cocos.path().join("assets")).expect("cocos assets");
|
||||
assert_eq!(
|
||||
cocos_editor_agent_tool_available_for_project(cocos.path()),
|
||||
cfg!(feature = "cocos-editor-execute")
|
||||
);
|
||||
assert_eq!(
|
||||
available_agent_tools_for_project(non_cocos.path()),
|
||||
Vec::<&'static str>::new()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,12 +152,12 @@ pub(crate) fn unity_editor_rpc_owned(
|
||||
if method == "connect" {
|
||||
unity_editor_bridge::disconnect_unity_editor();
|
||||
}
|
||||
let project = params
|
||||
params
|
||||
.get("projectPath")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or("缺少 projectPath")?;
|
||||
if !crate::builtin_plugins::unity_editor_agent_tool_available_for_project(Path::new(project)) {
|
||||
return Err("当前项目不是 Unity 项目或 Unity 插件不可用".to_string());
|
||||
if !crate::builtin_plugins::unity_editor_agent_tool_available() {
|
||||
return Err("Unity 插件不可用".to_string());
|
||||
}
|
||||
let mut delivery = if method == "execute" {
|
||||
let mut pending = match unity_pending_delivery().try_lock() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -791,37 +791,6 @@ fn require_plugin_adapter(id: &str, editors: &EditorRegistry) -> Result<(), Stri
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn plugin_matches_project(id: &str, project: Option<&Path>) -> bool {
|
||||
match id {
|
||||
crate::builtin_plugins::AGC_COCOS_EDITOR_PLUGIN_ID => project.is_some_and(|path| {
|
||||
crate::project::discover_local_cocos_project_root(path)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
}),
|
||||
crate::builtin_plugins::AGC_UNITY_EDITOR_PLUGIN_ID => project.is_some_and(|path| {
|
||||
crate::project::discover_local_unity_project_root(path)
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
}),
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn require_plugin_project(id: &str, project: &ProjectContext) -> Result<(), String> {
|
||||
if !plugin_matches_project(
|
||||
id,
|
||||
project
|
||||
.lock()
|
||||
.map_err(|_| "项目上下文锁已损坏".to_string())?
|
||||
.as_deref(),
|
||||
) {
|
||||
return Err("编辑器插件与当前项目类型不匹配".to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn controlled_editor_params(project: &Path, mut params: Value) -> Result<Value, String> {
|
||||
if params.is_null() {
|
||||
params = json!({});
|
||||
@@ -1020,18 +989,10 @@ impl PluginHost {
|
||||
.clone()
|
||||
.ok_or_else(|| "插件宿主尚未初始化".to_string())?;
|
||||
self.scan_locked(&mut state, &root)?;
|
||||
let project = state
|
||||
.active_project
|
||||
.lock()
|
||||
.map_err(|_| "项目上下文锁已损坏".to_string())?
|
||||
.clone();
|
||||
state
|
||||
.plugins
|
||||
.values()
|
||||
.filter(|record| {
|
||||
plugin_matches_project(&record.id, project.as_deref())
|
||||
&& require_plugin_adapter(&record.id, &state.editors).is_ok()
|
||||
})
|
||||
.filter(|record| require_plugin_adapter(&record.id, &state.editors).is_ok())
|
||||
.map(|record| self.summary_locked(record))
|
||||
.collect()
|
||||
}
|
||||
@@ -1096,7 +1057,6 @@ impl PluginHost {
|
||||
.ok_or_else(|| "插件宿主尚未初始化".to_string())?;
|
||||
let active_project = state.active_project.clone();
|
||||
require_plugin_adapter(id, &state.editors)?;
|
||||
require_plugin_project(id, &active_project)?;
|
||||
let editors = state.editors.clone();
|
||||
let record = state
|
||||
.plugins
|
||||
@@ -1198,7 +1158,6 @@ impl PluginHost {
|
||||
.plugins
|
||||
.get(id)
|
||||
.ok_or_else(|| "插件不存在".to_string())?;
|
||||
require_plugin_project(id, &state.active_project)?;
|
||||
if record.running.is_none() || !record.manifest.permissions.contains("ui.register") {
|
||||
return Err("插件面板未激活".to_string());
|
||||
}
|
||||
@@ -1244,7 +1203,6 @@ impl PluginHost {
|
||||
.root
|
||||
.clone()
|
||||
.ok_or_else(|| "插件宿主尚未初始化".to_string())?;
|
||||
require_plugin_project(id, &state.active_project)?;
|
||||
let record = state
|
||||
.plugins
|
||||
.get_mut(id)
|
||||
@@ -1619,9 +1577,6 @@ impl PluginHost {
|
||||
let project = active_project
|
||||
.lock()
|
||||
.map_err(|_| "项目上下文锁已损坏".to_string())?;
|
||||
if !plugin_matches_project(&manifest.id, project.as_deref()) {
|
||||
return Err("编辑器插件与当前项目类型不匹配".to_string());
|
||||
}
|
||||
let project = project
|
||||
.as_deref()
|
||||
.ok_or_else(|| "尚未设置当前项目".to_string())?;
|
||||
@@ -1672,7 +1627,7 @@ impl PluginHost {
|
||||
}
|
||||
|
||||
pub(crate) fn set_active_project(&self, project_path: Option<String>) -> Result<(), String> {
|
||||
let mut state = self
|
||||
let state = self
|
||||
.state
|
||||
.lock()
|
||||
.map_err(|_| "插件宿主锁已损坏".to_string())?;
|
||||
@@ -1692,23 +1647,19 @@ impl PluginHost {
|
||||
.map_err(|_| "项目上下文锁已损坏".to_string())?
|
||||
.clone();
|
||||
if previous != project {
|
||||
let mut editors = state
|
||||
.editors
|
||||
.try_lock()
|
||||
.map_err(|_| "编辑器适配器忙,请等待当前操作完成".to_string())?;
|
||||
if let Some(editor) = editors.get_mut("cocos-editor") {
|
||||
editor.disconnect();
|
||||
}
|
||||
crate::editor_adapters::disconnect_unity_editor_connection();
|
||||
}
|
||||
*state
|
||||
.active_project
|
||||
.lock()
|
||||
.map_err(|_| "项目上下文锁已损坏".to_string())? = project.clone();
|
||||
for record in state.plugins.values_mut() {
|
||||
if !plugin_matches_project(&record.id, project.as_deref()) {
|
||||
if let Some(mut running) = record.running.take() {
|
||||
let _ = running.child.kill();
|
||||
let _ = running.child.wait();
|
||||
}
|
||||
if record.manifest.enabled {
|
||||
record.status = "stopped".to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
for record in state.plugins.values() {
|
||||
if let Some(running) = record.running.as_ref() {
|
||||
let subscribed = running
|
||||
@@ -1930,6 +1881,7 @@ pub(crate) async fn set_agc_plugin_project_path(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::atomic::Ordering;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn manifest() -> PluginManifest {
|
||||
@@ -2152,7 +2104,7 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p
|
||||
let project = Arc::new(Mutex::new(Some(root.path().to_path_buf())));
|
||||
let editors: EditorRegistry = Arc::new(Mutex::new(BTreeMap::from([(
|
||||
"cocos-editor".to_string(),
|
||||
Box::new(StubCocosAdapter) as Box<dyn EditorAdapter>,
|
||||
Box::new(StubCocosAdapter::default()) as Box<dyn EditorAdapter>,
|
||||
)])));
|
||||
let registrations = Arc::new(Mutex::new(PluginRegistrations::default()));
|
||||
let mut manifest = manifest();
|
||||
@@ -2187,7 +2139,10 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p
|
||||
assert!(start.elapsed() < Duration::from_millis(100));
|
||||
}
|
||||
|
||||
struct StubCocosAdapter;
|
||||
#[derive(Default)]
|
||||
struct StubCocosAdapter {
|
||||
disconnects: Arc<std::sync::atomic::AtomicUsize>,
|
||||
}
|
||||
|
||||
struct StubUnityAdapter;
|
||||
|
||||
@@ -2218,7 +2173,7 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_unity_plugin_round_trips_and_stops_when_leaving_project() {
|
||||
fn workspace_unity_plugin_round_trips_across_project_contexts() {
|
||||
let _guard = crate::builtin_plugins::test_lock();
|
||||
let config = tempdir().unwrap();
|
||||
let project = tempdir().unwrap();
|
||||
@@ -2237,13 +2192,11 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p
|
||||
.unwrap();
|
||||
host.set_plugin_workspace(Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins"))
|
||||
.unwrap();
|
||||
assert!(!host
|
||||
assert!(host
|
||||
.list()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|plugin| plugin.id == "agc-unity-editor"));
|
||||
host.set_active_project(Some(project.path().to_string_lossy().into_owned()))
|
||||
.unwrap();
|
||||
host.start("agc-unity-editor").unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
@@ -2257,6 +2210,14 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p
|
||||
assert!(Instant::now() < deadline);
|
||||
thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
let plugin_pid = host.state.lock().unwrap().plugins["agc-unity-editor"]
|
||||
.running
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.child
|
||||
.id();
|
||||
host.set_active_project(Some(project.path().to_string_lossy().into_owned()))
|
||||
.unwrap();
|
||||
let response = host
|
||||
.call(
|
||||
"agc-unity-editor",
|
||||
@@ -2274,15 +2235,51 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p
|
||||
.to_string_lossy()
|
||||
.as_ref()
|
||||
);
|
||||
let other = tempdir().unwrap();
|
||||
host.set_active_project(Some(other.path().to_string_lossy().into_owned()))
|
||||
.unwrap();
|
||||
let response = host
|
||||
.call(
|
||||
"agc-unity-editor",
|
||||
"unity.editor.execute".to_string(),
|
||||
json!({"code":"return 3;"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(response["status"], "completed");
|
||||
assert_eq!(
|
||||
response["result"]["projectPath"],
|
||||
other
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.as_ref()
|
||||
);
|
||||
host.set_active_project(None).unwrap();
|
||||
assert!(!host
|
||||
assert!(host
|
||||
.list()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|plugin| plugin.id == "agc-unity-editor"));
|
||||
assert!(host.state.lock().unwrap().plugins["agc-unity-editor"]
|
||||
.running
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
host.state.lock().unwrap().plugins["agc-unity-editor"]
|
||||
.running
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.child
|
||||
.id(),
|
||||
plugin_pid
|
||||
);
|
||||
let response = host
|
||||
.call(
|
||||
"agc-unity-editor",
|
||||
"unity.editor.execute".to_string(),
|
||||
json!({"code":"return 4;"}),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(response["status"], "failed");
|
||||
assert_eq!(response["dispatched"], false);
|
||||
host.stop("agc-unity-editor").unwrap();
|
||||
}
|
||||
|
||||
impl EditorAdapter for StubCocosAdapter {
|
||||
@@ -2303,7 +2300,9 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p
|
||||
Err("stub adapter 不建立连接".to_string())
|
||||
}
|
||||
|
||||
fn disconnect(&mut self) {}
|
||||
fn disconnect(&mut self) {
|
||||
self.disconnects.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
fn translate_rpc(&self, _method: &str, params: Value) -> Result<Value, String> {
|
||||
Ok(params)
|
||||
@@ -2329,7 +2328,7 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p
|
||||
let host = PluginHost::default();
|
||||
crate::builtin_plugins::initialize(directory.path()).expect("builtin plugin state");
|
||||
host.initialize(directory.path()).expect("initialize");
|
||||
host.register_editor_adapter(Box::new(StubCocosAdapter))
|
||||
host.register_editor_adapter(Box::new(StubCocosAdapter::default()))
|
||||
.expect("register adapter");
|
||||
host.set_plugin_workspace(workspace)
|
||||
.expect("set plugins workspace");
|
||||
@@ -2381,7 +2380,7 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p
|
||||
host.initialize(directory.path()).expect("initialize");
|
||||
host.set_plugin_workspace(workspace)
|
||||
.expect("set plugins workspace");
|
||||
host.register_editor_adapter(Box::new(StubCocosAdapter))
|
||||
host.register_editor_adapter(Box::new(StubCocosAdapter::default()))
|
||||
.expect("register adapter");
|
||||
let project = fs::canonicalize(directory.path())
|
||||
.expect("canonical project")
|
||||
@@ -2425,26 +2424,98 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cocos_plugin_is_hidden_and_cannot_start_for_non_cocos_project() {
|
||||
fn cocos_plugin_stays_available_across_project_contexts() {
|
||||
let _guard = crate::builtin_plugins::test_lock();
|
||||
let directory = tempdir().expect("temp config");
|
||||
crate::builtin_plugins::initialize(directory.path()).expect("builtin state");
|
||||
let workspace = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../../plugins");
|
||||
let host = PluginHost::default();
|
||||
host.initialize(directory.path()).expect("initialize");
|
||||
host.register_editor_adapter(Box::new(StubCocosAdapter))
|
||||
let adapter = StubCocosAdapter::default();
|
||||
let disconnects = Arc::clone(&adapter.disconnects);
|
||||
host.register_editor_adapter(Box::new(adapter))
|
||||
.expect("register adapter");
|
||||
host.set_plugin_workspace(workspace).expect("set workspace");
|
||||
|
||||
let project = tempdir().expect("web project");
|
||||
host.set_active_project(Some(project.path().to_string_lossy().into_owned()))
|
||||
.expect("set active project");
|
||||
assert!(host
|
||||
.list()
|
||||
.expect("list plugins")
|
||||
.into_iter()
|
||||
.all(|plugin| plugin.id != "agc-cocos-editor"));
|
||||
assert!(host.start("agc-cocos-editor").is_err());
|
||||
.any(|plugin| plugin.id == "agc-cocos-editor"));
|
||||
host.start("agc-cocos-editor")
|
||||
.expect("start without a project");
|
||||
let deadline = Instant::now() + Duration::from_secs(15);
|
||||
while host.read_panel("agc-cocos-editor", "cocos-editor").is_err()
|
||||
|| !host.state.lock().unwrap().plugins["agc-cocos-editor"]
|
||||
.running
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.registrations
|
||||
.lock()
|
||||
.unwrap()
|
||||
.subscriptions
|
||||
.values()
|
||||
.any(|event| event == "project.changed")
|
||||
{
|
||||
assert!(Instant::now() < deadline, "Cocos panel was not registered");
|
||||
thread::sleep(Duration::from_millis(25));
|
||||
}
|
||||
let plugin_pid = host.state.lock().unwrap().plugins["agc-cocos-editor"]
|
||||
.running
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.child
|
||||
.id();
|
||||
let project = tempdir().expect("web project");
|
||||
host.set_active_project(Some(project.path().to_string_lossy().into_owned()))
|
||||
.expect("set active project");
|
||||
assert_eq!(disconnects.load(Ordering::SeqCst), 1);
|
||||
host.set_active_project(Some(project.path().to_string_lossy().into_owned()))
|
||||
.expect("keep the same active project");
|
||||
assert_eq!(disconnects.load(Ordering::SeqCst), 1);
|
||||
let response = host
|
||||
.call(
|
||||
"agc-cocos-editor",
|
||||
"cocos.editor.execute".to_string(),
|
||||
json!({"code":"return 1;"}),
|
||||
)
|
||||
.expect("RPC reaches the adapter without a project type gate");
|
||||
assert_eq!(response["status"], "completed");
|
||||
assert_eq!(
|
||||
response["response"]["params"]["projectPath"],
|
||||
project
|
||||
.path()
|
||||
.canonicalize()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.as_ref()
|
||||
);
|
||||
host.set_active_project(None).unwrap();
|
||||
assert_eq!(disconnects.load(Ordering::SeqCst), 2);
|
||||
assert!(host
|
||||
.list_extensions()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|plugin| plugin.id == "agc-cocos-editor"));
|
||||
host.read_panel("agc-cocos-editor", "cocos-editor")
|
||||
.expect("panel remains available");
|
||||
assert_eq!(
|
||||
host.state.lock().unwrap().plugins["agc-cocos-editor"]
|
||||
.running
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.child
|
||||
.id(),
|
||||
plugin_pid
|
||||
);
|
||||
assert!(host
|
||||
.call(
|
||||
"agc-cocos-editor",
|
||||
"cocos.editor.execute".to_string(),
|
||||
json!({"code":"return 1;"}),
|
||||
)
|
||||
.is_err());
|
||||
host.stop("agc-cocos-editor").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2487,7 +2558,7 @@ setTimeout(() => send({ jsonrpc: '2.0', id: 1, method: 'host.registerCommand', p
|
||||
assert!(host.state.lock().unwrap().plugins["agc-cocos-editor"]
|
||||
.running
|
||||
.is_none());
|
||||
host.register_editor_adapter(Box::new(StubCocosAdapter))
|
||||
host.register_editor_adapter(Box::new(StubCocosAdapter::default()))
|
||||
.unwrap();
|
||||
assert!(host
|
||||
.list()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -307,7 +307,7 @@ import {
|
||||
} from './services/platformSession';
|
||||
import {
|
||||
setAgcPluginProjectPath,
|
||||
startAvailableAgcPlugin,
|
||||
startAvailableAgcEditorPlugins,
|
||||
} from './services/pluginHost';
|
||||
import {
|
||||
canSubscribeTauriEvents,
|
||||
@@ -617,24 +617,18 @@ export function App({
|
||||
// 未绑定项目时无需触发插件宿主;这也避免启动空首页时产生无意义的 Tauri 调用。
|
||||
if (!nextProjectPath && !previousProjectPath) return;
|
||||
let active = true;
|
||||
const editorPlugin =
|
||||
workspaceProjectKind === 'cocos'
|
||||
? { id: 'agc-cocos-editor', title: 'Cocos Creator' }
|
||||
: workspaceProjectKind === 'unity'
|
||||
? { id: 'agc-unity-editor', title: 'Unity' }
|
||||
: null;
|
||||
void setAgcPluginProjectPath(nextProjectPath)
|
||||
.then(async () => {
|
||||
if (active && editorPlugin && nextProjectPath) {
|
||||
await startAvailableAgcPlugin(editorPlugin.id);
|
||||
if (active && nextProjectPath) {
|
||||
await startAvailableAgcEditorPlugins(() => active);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!active || !editorPlugin || !nextProjectPath) {
|
||||
if (!active || !nextProjectPath) {
|
||||
return;
|
||||
}
|
||||
setWorkspaceStatus(
|
||||
`${editorPlugin.title} 插件未就绪:${
|
||||
`编辑器插件未就绪:${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
@@ -646,7 +640,7 @@ export function App({
|
||||
}
|
||||
localProjectPathRef.current = null;
|
||||
};
|
||||
}, [localProject?.projectPath, supervisorChatOnly, workspaceProjectKind]);
|
||||
}, [localProject?.projectPath, supervisorChatOnly]);
|
||||
|
||||
const manifestRefreshMountedRef = useRef(true);
|
||||
const manifestRefreshStatesRef = useRef(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -33,14 +33,38 @@ export async function startAgcPlugin(id: string) {
|
||||
}) as Promise<AgcPluginSummary>;
|
||||
}
|
||||
|
||||
/** 只消费宿主的能力投影,不因项目类型自行推断原生适配器是否存在。 */
|
||||
export async function startAvailableAgcPlugin(id: string) {
|
||||
/** 只消费宿主的能力投影;项目类型与平台支持均不在前端再次判断。 */
|
||||
export async function startAvailableAgcEditorPlugins(
|
||||
isActive: () => boolean = () => true,
|
||||
) {
|
||||
const plugins = await listAgcPlugins();
|
||||
const plugin = plugins.find((candidate) => candidate.id === id);
|
||||
if (!plugin?.enabled || !plugin.hasRuntime || plugin.status === 'invalid') {
|
||||
return;
|
||||
const available = plugins.filter(
|
||||
(plugin) =>
|
||||
plugin.builtin &&
|
||||
(plugin.id === 'agc-cocos-editor' || plugin.id === 'agc-unity-editor') &&
|
||||
plugin.enabled &&
|
||||
plugin.hasRuntime &&
|
||||
(plugin.status === 'stopped' || plugin.status === 'discovered'),
|
||||
);
|
||||
const results = await Promise.allSettled(
|
||||
available.map((plugin) =>
|
||||
isActive() ? startAgcPlugin(plugin.id) : Promise.resolve(),
|
||||
),
|
||||
);
|
||||
const errors = results.flatMap((result, index) =>
|
||||
result.status === 'rejected'
|
||||
? [
|
||||
`${available[index]!.name}:${
|
||||
result.reason instanceof Error
|
||||
? result.reason.message
|
||||
: String(result.reason)
|
||||
}`,
|
||||
]
|
||||
: [],
|
||||
);
|
||||
if (errors.length) {
|
||||
throw new Error(errors.join(';'));
|
||||
}
|
||||
return startAgcPlugin(id);
|
||||
}
|
||||
|
||||
export async function stopAgcPlugin(id: string) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user