Compare commits

...

1 Commits

Author SHA1 Message Date
kdletters e3682fd06f 固定客户端dev服务并支持官网多平台下载
Project CI / AI game creator shell Rust shard 4/4 (push) Successful in 7m31s
Project CI / AI game creator shell Rust shard 1/4 (push) Successful in 7m32s
Project CI / AI game creator shell Rust shard 3/4 (push) Successful in 7m33s
Project CI / AI game creator shell Rust shard 2/4 (push) Successful in 7m40s
Project CI / AI game creator shell Rust smoke (push) Successful in 2m21s
Project CI / AI game creator shell Rust crates (push) Successful in 3m11s
Project CI / Native shell tests (push) Successful in 16m15s
Project CI / Frontend tests (push) Successful in 14m57s
Project CI / Repository checks (push) Successful in 14m48s
Project CI / Backend tests (push) Successful in 20m7s
Project CI / AI game creator shell web tests (push) Successful in 6m20s
移除服务器选择并按来源隔离登录凭据
新增官网客户端下载入口和匿名平台聚合接口
根据发布清单自动展示Windows与macOS首装包
补齐Mac首装元数据和上传顺序校验
同步定向测试与下载发布规范
2026-09-19 16:19:28 +08:00
34 changed files with 2245 additions and 653 deletions
@@ -501,6 +501,41 @@ export function selectReleaseArtifact(files, target = defaultTarget()) {
); );
} }
export function selectFirstInstallArtifact(
files,
{ target, version, artifact },
) {
validateReleaseTarget(target);
let selected;
if (target.includes('windows')) {
selected = artifact;
if (!selected?.endsWith('.exe')) {
throw new Error('Windows 首装包必须复用本次 NSIS .exe 更新包');
}
} else {
// Tauri DMG 文件名使用 aarch64 / x64,而 updater 的 Intel 平台键是 x86_64。
const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64';
const suffix = `_${version}_${architecture}.dmg`;
const candidates = files.filter((file) =>
path.basename(file).endsWith(suffix),
);
if (candidates.length !== 1) {
throw new Error(
`首装 DMG 必须唯一匹配本次版本 ${version} 和架构 ${architecture},找到 ${candidates.length}`,
);
}
selected = candidates[0];
}
if (
!fs.existsSync(selected) ||
!fs.statSync(selected).isFile() ||
fs.statSync(selected).size === 0
) {
throw new Error(`首装包不存在或为空:${selected}`);
}
return selected;
}
function readUpdaterSignature(artifactPath) { function readUpdaterSignature(artifactPath) {
const signaturePath = `${artifactPath}.sig`; const signaturePath = `${artifactPath}.sig`;
if (!fs.existsSync(signaturePath)) { if (!fs.existsSync(signaturePath)) {
@@ -521,23 +556,32 @@ export function createUpdateManifest(
publishedAt = new Date().toISOString(), publishedAt = new Date().toISOString(),
notes = readReleaseNotes(), notes = readReleaseNotes(),
commit = readHeadCommit(), commit = readHeadCommit(),
downloadArtifact,
} = {}, } = {},
) { ) {
validateReleaseTarget(target); validateReleaseTarget(target);
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }, target); resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }, target);
const signature = readUpdaterSignature(artifactPath); const signature = readUpdaterSignature(artifactPath);
const version = readPackageJson().version; const version = readPackageJson().version;
const firstInstallArtifact = selectFirstInstallArtifact(
downloadArtifact ? [downloadArtifact] : [],
{ target, version, artifact: artifactPath },
);
const fileName = path.basename(artifactPath); const fileName = path.basename(artifactPath);
const url = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`; const url = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`;
const downloadUrl = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(path.basename(firstInstallArtifact))}`;
const platforms = {}; const platforms = {};
const downloads = {};
for (const key of resolveManifestPlatformKeys(target)) { for (const key of resolveManifestPlatformKeys(target)) {
platforms[key] = { signature, url }; platforms[key] = { signature, url };
downloads[key] = { url: downloadUrl };
} }
return { return {
version, version,
...(notes ? { notes } : {}), ...(notes ? { notes } : {}),
pub_date: publishedAt, pub_date: publishedAt,
platforms, platforms,
downloads,
// 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。 // 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。
...(commit ? { commit } : {}), ...(commit ? { commit } : {}),
}; };
@@ -676,10 +720,16 @@ export async function generateUpdateManifest(
context = resolveReleaseContext(), context = resolveReleaseContext(),
) { ) {
const { channel, target, bundleRoot } = context; const { channel, target, bundleRoot } = context;
const artifact = selectReleaseArtifact(listFiles(bundleRoot), target); const files = listFiles(bundleRoot);
const artifact = selectReleaseArtifact(files, target);
if (!artifact) { if (!artifact) {
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`); throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
} }
const downloadArtifact = selectFirstInstallArtifact(files, {
target,
version: readPackageJson().version,
artifact,
});
const manualNotes = readReleaseNotes(); const manualNotes = readReleaseNotes();
const previousCommit = await resolvePreviousReleaseCommit(channel); const previousCommit = await resolvePreviousReleaseCommit(channel);
const commits = collectReleaseCommits(previousCommit); const commits = collectReleaseCommits(previousCommit);
@@ -693,7 +743,12 @@ export async function generateUpdateManifest(
`[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'}`, `[ai-game-creator-shell] 未生成自动更新摘要(上一发布 commit=${previousCommit ?? '未知'},客户端相关提交=${commits ? commits.length : '不可判定'},最近提交=${recentCommits ? recentCommits.length : '不可判定'}`,
); );
} }
const manifest = createUpdateManifest(artifact, { channel, target, notes }); const manifest = createUpdateManifest(artifact, {
channel,
target,
notes,
downloadArtifact,
});
const manifestPath = path.join(bundleRoot, 'latest.json'); const manifestPath = path.join(bundleRoot, 'latest.json');
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
const notesPath = path.join(bundleRoot, 'release-notes.txt'); const notesPath = path.join(bundleRoot, 'release-notes.txt');
@@ -718,6 +773,7 @@ export async function generateUpdateManifest(
`[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`, `[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`,
); );
console.log(`[ai-game-creator-shell] 安装包:${artifact}`); console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
console.log(`[ai-game-creator-shell] 首装包:${downloadArtifact}`);
console.log( console.log(
manualNotes manualNotes
? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案' ? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案'
@@ -734,6 +790,7 @@ export async function generateUpdateManifest(
return { return {
channel, channel,
artifact, artifact,
downloadArtifact,
manifest, manifest,
manifestPath, manifestPath,
notes, notes,
@@ -32,12 +32,23 @@ import {
resolveReleaseContext, resolveReleaseContext,
resolveRemoteHighWaterVersion, resolveRemoteHighWaterVersion,
runTauriBuild, runTauriBuild,
selectFirstInstallArtifact,
selectReleaseArtifact, selectReleaseArtifact,
updateManifestUrl, updateManifestUrl,
} from './build-release.mjs'; } from './build-release.mjs';
const windowsTarget = 'x86_64-pc-windows-msvc'; const windowsTarget = 'x86_64-pc-windows-msvc';
const universalTarget = 'universal-apple-darwin'; const universalTarget = 'universal-apple-darwin';
const packageVersion = JSON.parse(
readFileSync(new URL('../package.json', import.meta.url), 'utf8'),
).version;
function createDmgFixture(root, target, version = packageVersion) {
const architecture = target.startsWith('aarch64') ? 'aarch64' : 'x64';
const dmg = path.join(root, `陶泥儿_${version}_${architecture}.dmg`);
writeFileSync(dmg, 'first installation disk image');
return dmg;
}
test('native sidecar builds reject universal targets and accept each macOS architecture', () => { test('native sidecar builds reject universal targets and accept each macOS architecture', () => {
assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/); assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/);
@@ -254,7 +265,13 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
), ),
artifact, artifact,
); );
const manifest = createUpdateManifest(artifact, context); const manifest = createUpdateManifest(artifact, {
...context,
downloadArtifact: createDmgFixture(
path.dirname(artifact),
context.target,
),
});
assert.deepEqual(Object.keys(manifest.platforms), [ assert.deepEqual(Object.keys(manifest.platforms), [
'darwin-aarch64', 'darwin-aarch64',
]); ]);
@@ -272,29 +289,118 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
assert.ok(seenContexts.every((context) => context === seenContexts[0])); assert.ok(seenContexts.every((context) => context === seenContexts[0]));
}); });
test('real manifest writer uses the resolved bundle root and does not emit Windows artifacts', async () => { for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-')); test(`real manifest writer publishes the ${target} updater and first installer separately`, async () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-'));
try {
const artifact = path.join(root, '陶泥儿.app.tar.gz');
writeFileSync(artifact, 'mac package');
writeFileSync(`${artifact}.sig`, 'mac signature');
writeFileSync(path.join(root, 'windows.exe'), 'wrong platform');
const downloadArtifact = createDmgFixture(root, target);
const context = {
...resolveReleaseContext([`--target=${target}`], {}),
bundleRoot: root,
};
const result = await withStubbedFetch(
(url) => {
assert.match(url, /\/dev-mac\/latest\.json$/);
return jsonResponse({}, 404);
},
() => generateUpdateManifest(context),
);
assert.equal(result.artifact, artifact);
assert.equal(result.downloadArtifact, downloadArtifact);
assert.equal(result.manifestPath, path.join(root, 'latest.json'));
assert.equal(result.legacyManifestPath, null);
const key = target.startsWith('aarch64')
? 'darwin-aarch64'
: 'darwin-x86_64';
assert.deepEqual(Object.keys(result.manifest.platforms), [key]);
assert.deepEqual(Object.keys(result.manifest.downloads), [key]);
assert.match(
result.manifest.platforms[key].url,
/\/dev-mac\/.*\.app\.tar\.gz$/,
);
assert.equal(
decodeURIComponent(
new URL(result.manifest.downloads[key].url).pathname,
),
`/agc/dev-mac/${packageVersion}/${path.basename(downloadArtifact)}`,
);
assert.deepEqual(
JSON.parse(readFileSync(result.manifestPath, 'utf8')),
result.manifest,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
}
test('DMG selection ignores other versions and architectures but rejects missing, empty and ambiguous current packages', () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-dmg-selection-'));
try {
const target = 'aarch64-apple-darwin';
const options = {
target,
version: '2.3.4',
artifact: path.join(root, '陶泥儿.app.tar.gz'),
};
const oldVersion = createDmgFixture(root, target, '2.3.3');
const wrongArchitecture = createDmgFixture(
root,
'x86_64-apple-darwin',
'2.3.4',
);
assert.throws(() => selectFirstInstallArtifact([], options), /找到 0 个/u);
assert.throws(
() =>
selectFirstInstallArtifact([oldVersion, wrongArchitecture], options),
/找到 0 个/u,
);
const current = createDmgFixture(root, target, '2.3.4');
assert.equal(
selectFirstInstallArtifact(
[oldVersion, wrongArchitecture, current],
options,
),
current,
);
writeFileSync(current, '');
assert.throws(
() => selectFirstInstallArtifact([current], options),
/不存在或为空/u,
);
writeFileSync(current, 'valid dmg');
const second = path.join(root, '另一包_2.3.4_aarch64.dmg');
writeFileSync(second, 'ambiguous dmg');
assert.throws(
() => selectFirstInstallArtifact([current, second], options),
/找到 2 个/u,
);
} finally {
rmSync(root, { recursive: true, force: true });
}
});
test('manifest writer refuses to create latest when the current Mac DMG is missing', async () => {
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-missing-dmg-'));
try { try {
const artifact = path.join(root, '陶泥儿.app.tar.gz'); const artifact = path.join(root, '陶泥儿.app.tar.gz');
writeFileSync(artifact, 'mac package'); writeFileSync(artifact, 'updater archive');
writeFileSync(`${artifact}.sig`, 'mac signature'); writeFileSync(`${artifact}.sig`, 'signature');
writeFileSync(path.join(root, 'windows.exe'), 'wrong platform');
const context = { const context = {
...resolveReleaseContext(['--target=x86_64-apple-darwin'], {}), ...resolveReleaseContext(['--target=aarch64-apple-darwin'], {}),
bundleRoot: root, bundleRoot: root,
}; };
const result = await withStubbedFetch( await assert.rejects(
(url) => {
assert.match(url, /\/dev-mac\/latest\.json$/);
return jsonResponse({}, 404);
},
() => generateUpdateManifest(context), () => generateUpdateManifest(context),
/首装 DMG 必须唯一匹配/u,
); );
assert.equal(result.artifact, artifact); assert.throws(() => readFileSync(path.join(root, 'latest.json')), {
assert.equal(result.manifestPath, path.join(root, 'latest.json')); code: 'ENOENT',
assert.equal(result.legacyManifestPath, null); });
assert.deepEqual(Object.keys(result.manifest.platforms), ['darwin-x86_64']);
assert.match(result.manifest.platforms['darwin-x86_64'].url, /\/dev-mac\//);
} finally { } finally {
rmSync(root, { recursive: true, force: true }); rmSync(root, { recursive: true, force: true });
} }
@@ -393,6 +499,9 @@ test('channel manifest carries version, platform keys and signature', () => {
assert.equal(manifest.notes, '修复与改进'); assert.equal(manifest.notes, '修复与改进');
assert.equal(manifest.pub_date, '2026-09-17T00:00:00.000Z'); assert.equal(manifest.pub_date, '2026-09-17T00:00:00.000Z');
assert.deepEqual(Object.keys(manifest.platforms), ['windows-x86_64']); assert.deepEqual(Object.keys(manifest.platforms), ['windows-x86_64']);
assert.deepEqual(manifest.downloads, {
'windows-x86_64': { url: manifest.platforms['windows-x86_64'].url },
});
assert.equal( assert.equal(
manifest.platforms['windows-x86_64'].signature, manifest.platforms['windows-x86_64'].signature,
'signature-content', 'signature-content',
@@ -573,18 +682,18 @@ test('recent commit fallback marks that entries may repeat the previous release'
} }
}); });
test('release upload forces overwrite for artifact, signature and channel pointers', () => { test('release entry forwards the built artifacts and dry-run mode to the uploader', () => {
const source = readFileSync( const source = readFileSync(
new URL('./release-upload.mjs', import.meta.url), new URL('./release-upload.mjs', import.meta.url),
'utf8', 'utf8',
); );
assert.equal( assert.match(
(source.match(/runOssutil\(\[\s*'cp',\s*'--force'/gu) ?? []).length, source,
4, /const release = await buildRelease\(process\.argv\.slice\(2\)\)/u,
); );
assert.match(source, /agc\/\$\{channel\}\/latest\.json/u); assert.match(source, /uploadReleaseArtifacts\(release, \{/u);
assert.match(source, /agc\/latest\.json/u); assert.match(source, /const dryRun = readReleaseDryRun\(\);/u);
assert.match(source, /await buildRelease\(process\.argv\.slice\(2\)\)/u); assert.ok(source.includes('\n dryRun,\n'));
}); });
test('release notes list client commits with short sha and bound their size', () => { test('release notes list client commits with short sha and bound their size', () => {
@@ -1,3 +1,6 @@
import { spawnSync } from 'node:child_process';
import path from 'node:path';
/** /**
* 发布上传的 OSS 命令行整理:把 ossutil 参数与凭据整理成可执行或可打印的形式, * 发布上传的 OSS 命令行整理:把 ossutil 参数与凭据整理成可执行或可打印的形式,
* 便于在 dry-run 下核对将要执行的上传,同时保证任何输出都不回显凭据明文。 * 便于在 dry-run 下核对将要执行的上传,同时保证任何输出都不回显凭据明文。
@@ -25,3 +28,89 @@ export function formatOssutilCommand({ binary, args, endpoint, credentials }) {
} }
return parts.map(quoteArgument).join(' '); return parts.map(quoteArgument).join(' ');
} }
export function createReleaseUploadPlan(
{
artifact,
downloadArtifact,
channel,
manifest,
manifestPath,
legacyManifestPath,
},
bucket,
) {
if (!artifact || !downloadArtifact || !manifestPath || !manifest?.version) {
throw new Error('发布结果缺少更新包、首装包或清单');
}
const prefix = `oss://${bucket}/agc/${channel}`;
const artifacts = [
...new Set(
[artifact, `${artifact}.sig`, downloadArtifact].map((file) =>
path.resolve(file),
),
),
];
const plan = artifacts.map((source) => ({
source,
destination: `${prefix}/${manifest.version}/${path.basename(source)}`,
}));
plan.push({ source: manifestPath, destination: `${prefix}/latest.json` });
if (legacyManifestPath) {
plan.push({
source: legacyManifestPath,
destination: `oss://${bucket}/agc/latest.json`,
});
}
return plan;
}
export function uploadReleaseArtifacts(
release,
{
bucket,
endpoint,
binary = 'ossutil',
accessKeyId,
accessKeySecret,
dryRun = false,
spawn = spawnSync,
log = console.log,
},
) {
if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) {
throw new Error('OSS AccessKey ID 和 Secret 必须同时提供');
}
const plan = createReleaseUploadPlan(release, bucket);
for (const { source, destination } of plan) {
// 全部安装对象成功后才执行 latest 指针;失败立即终止,不发布悬空链接。
const args = ['cp', '--force', source, destination];
if (dryRun) {
log(
`[dry-run] ${formatOssutilCommand({ binary, args, endpoint, credentials: Boolean(accessKeyId) })}`,
);
continue;
}
const credentials = accessKeyId
? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret]
: [];
const result = spawn(
binary,
[...args, '--endpoint', endpoint, ...credentials],
{
stdio: 'inherit',
shell: false,
},
);
if (result.error)
throw new Error(`无法执行 ${binary},请先安装并配置 ossutil`);
if (result.status !== 0) {
throw new Error(
`OSS 上传失败(退出码 ${result.status ?? 1}):${destination}`,
);
}
log(`[ai-game-creator-shell] 已上传 ${destination}`);
}
if (dryRun) log('[ai-game-creator-shell] dry-run:未写入任何 OSS 对象');
return plan;
}
@@ -1,8 +1,15 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { test } from 'node:test'; import { test } from 'node:test';
import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs'; import {
createReleaseUploadPlan,
formatOssutilCommand,
readReleaseDryRun,
uploadReleaseArtifacts,
} from './release-oss.mjs';
test('dry run only accepts explicit truthy values', () => { test('dry run only accepts explicit truthy values', () => {
assert.equal(readReleaseDryRun({}), false); assert.equal(readReleaseDryRun({}), false);
@@ -33,12 +40,158 @@ test('printed upload command keeps arguments and hides credentials', () => {
); );
}); });
test('uploader gates every ossutil call behind the dry run switch', () => { function withReleaseFixture(channel, architecture, run) {
const source = readFileSync( const root = mkdtempSync(path.join(os.tmpdir(), 'agc-upload-plan-'));
new URL('./release-upload.mjs', import.meta.url), try {
'utf8', const artifact = path.join(
); root,
assert.match(source, /const dryRun = readReleaseDryRun\(\);/u); channel === 'dev-win'
assert.match(source, /if \(dryRun\) \{/u); ? '陶泥儿_1.2.3_x64-setup.exe'
assert.match(source, /dry-run:未写入任何 OSS 对象/u); : '陶泥儿.app.tar.gz',
);
const downloadArtifact =
channel === 'dev-win'
? 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;
for (const file of [
artifact,
`${artifact}.sig`,
downloadArtifact,
manifestPath,
legacyManifestPath,
].filter(Boolean)) {
writeFileSync(file, 'fixture');
}
return run({
artifact,
downloadArtifact,
channel,
manifest: { version: '1.2.3' },
manifestPath,
legacyManifestPath,
});
} finally {
rmSync(root, { recursive: true, force: true });
}
}
const uploadOptions = {
bucket: 'agc-dev',
endpoint: 'oss-rg-china-mainland.aliyuncs.com',
log: () => {},
};
for (const architecture of ['aarch64', 'x64']) {
test(`uploads every ${architecture} Mac object before the channel pointer`, () => {
withReleaseFixture('dev-mac', architecture, (release) => {
const calls = [];
uploadReleaseArtifacts(release, {
...uploadOptions,
spawn: (binary, args, options) => {
assert.equal(binary, 'ossutil');
assert.equal(options.shell, false);
assert.deepEqual(args.slice(0, 2), ['cp', '--force']);
calls.push({ source: args[2], destination: args[3] });
return { status: 0 };
},
});
assert.deepEqual(
calls.map(({ source }) => source),
[
release.artifact,
`${release.artifact}.sig`,
release.downloadArtifact,
release.manifestPath,
],
);
assert.equal(
calls[2].destination,
`oss://agc-dev/agc/dev-mac/1.2.3/陶泥儿_1.2.3_${architecture}.dmg`,
);
assert.equal(
calls[3].destination,
'oss://agc-dev/agc/dev-mac/latest.json',
);
});
});
}
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),
);
});
});
for (const failedArtifactIndex of [0, 1, 2]) {
test(`failed Mac object ${failedArtifactIndex} prevents both later objects and latest publication`, () => {
withReleaseFixture('dev-mac', 'aarch64', (release) => {
const destinations = [];
assert.throws(
() =>
uploadReleaseArtifacts(release, {
...uploadOptions,
spawn: (_binary, args) => {
destinations.push(args[3]);
return {
status: destinations.length - 1 === failedArtifactIndex ? 1 : 0,
};
},
}),
/OSS 上传失败/u,
);
assert.equal(destinations.length, failedArtifactIndex + 1);
assert.ok(
destinations.every(
(destination) => !destination.endsWith('/latest.json'),
),
);
});
});
}
test('dry run prints the complete plan without spawning uploads or exposing credentials', () => {
withReleaseFixture('dev-mac', 'aarch64', (release) => {
const output = [];
uploadReleaseArtifacts(release, {
...uploadOptions,
dryRun: true,
accessKeyId: 'fixture-id',
accessKeySecret: 'fixture-secret',
spawn: () => assert.fail('dry run must never execute ossutil'),
log: (line) => output.push(line),
});
assert.equal(
output.filter((line) => line.startsWith('[dry-run]')).length,
4,
);
assert.match(output.join('\n'), /\.dmg/u);
assert.match(output.at(-1), /未写入任何 OSS 对象/u);
assert.doesNotMatch(output.join('\n'), /fixture-id|fixture-secret|已上传/u);
});
}); });
@@ -1,7 +1,4 @@
import { spawnSync } from 'node:child_process'; import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs';
import path from 'node:path';
import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs';
const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev'; const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev';
const endpoint = const endpoint =
@@ -14,76 +11,12 @@ const dryRun = readReleaseDryRun();
const { buildRelease } = await import('./build-release.mjs'); const { buildRelease } = await import('./build-release.mjs');
function runOssutil(args) { const release = await buildRelease(process.argv.slice(2));
const binary = process.env.OSSUTIL_BIN?.trim() || 'ossutil'; uploadReleaseArtifacts(release, {
const accessKeyId = process.env.AGC_OSS_ACCESS_KEY_ID?.trim(); bucket,
const accessKeySecret = process.env.AGC_OSS_ACCESS_KEY_SECRET; endpoint,
if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) { binary: process.env.OSSUTIL_BIN?.trim() || 'ossutil',
throw new Error('OSS AccessKey ID 和 Secret 必须同时提供'); accessKeyId: process.env.AGC_OSS_ACCESS_KEY_ID?.trim(),
} accessKeySecret: process.env.AGC_OSS_ACCESS_KEY_SECRET,
if (dryRun) { dryRun,
// 演练:只打印将要执行的上传,凭据以占位符呈现,不写入 OSS。 });
console.log(
`[dry-run] ${formatOssutilCommand({
binary,
args,
endpoint,
credentials: Boolean(accessKeyId),
})}`,
);
return;
}
const credentialArgs = accessKeyId
? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret]
: [];
const result = spawnSync(
binary,
[...args, '--endpoint', endpoint, ...credentialArgs],
{
stdio: 'inherit',
shell: false,
},
);
if (result.error) {
throw new Error(`无法执行 ${binary},请先安装并配置 ossutil`);
}
if (result.status !== 0) process.exit(result.status ?? 1);
}
const { artifact, channel, legacyManifestPath, manifest, manifestPath } =
await buildRelease(process.argv.slice(2));
const artifactKey = `agc/${channel}/${manifest.version}/${path.basename(artifact)}`;
// Jenkins/ossutil 默认会在目标对象已存在时交互询问并按默认值跳过;
// 发布清单是固定的 latest 指针,必须显式覆盖,否则流水线会误报成功但远端仍保留旧版本。
runOssutil(['cp', '--force', artifact, `oss://${bucket}/${artifactKey}`]);
runOssutil([
'cp',
'--force',
`${artifact}.sig`,
`oss://${bucket}/${artifactKey}.sig`,
]);
runOssutil([
'cp',
'--force',
manifestPath,
`oss://${bucket}/agc/${channel}/latest.json`,
]);
console.log(`[ai-game-creator-shell] 已上传 oss://${bucket}/${artifactKey}`);
console.log(
`[ai-game-creator-shell] 已上传 oss://${bucket}/agc/${channel}/latest.json`,
);
if (legacyManifestPath) {
// 迁移桥:让仍走旧 sha256 清单的已发布客户端升级到新协议,一个版本周期后删除。
runOssutil([
'cp',
'--force',
legacyManifestPath,
`oss://${bucket}/agc/latest.json`,
]);
console.log(
`[ai-game-creator-shell] 已上传迁移指针 oss://${bucket}/agc/latest.json`,
);
}
if (dryRun) {
console.log('[ai-game-creator-shell] dry-run:未写入任何 OSS 对象');
}
@@ -23,14 +23,7 @@ import {
normalizeAuthPhoneInput, normalizeAuthPhoneInput,
sendClientPhoneLoginCode, sendClientPhoneLoginCode,
} from '../services/clientAuth'; } from '../services/clientAuth';
import { import { getClientServerBaseUrl } from '../services/clientHttp';
type ClientServerPreset,
type ClientServerSelection,
getClientServerBaseUrl,
getClientServerSelection,
normalizeClientServerBaseUrl,
setClientServerSelection,
} from '../services/clientHttp';
import { import {
captureClientError, captureClientError,
installWebviewLogBridge, installWebviewLogBridge,
@@ -158,13 +151,6 @@ export function AuthenticatedClient({
const [loginBusy, setLoginBusy] = useState(false); const [loginBusy, setLoginBusy] = useState(false);
const [codeBusy, setCodeBusy] = useState(false); const [codeBusy, setCodeBusy] = useState(false);
const [codeCooldownSeconds, setCodeCooldownSeconds] = useState(0); const [codeCooldownSeconds, setCodeCooldownSeconds] = useState(0);
const initialServerSelection = getClientServerSelection();
const [serverSelection, setServerSelection] = useState<ClientServerSelection>(
initialServerSelection,
);
const [customServerUrl, setCustomServerUrl] = useState(
initialServerSelection.customBaseUrl,
);
useEffect(() => { useEffect(() => {
const uninstallWebviewLogBridge = installWebviewLogBridge(); const uninstallWebviewLogBridge = installWebviewLogBridge();
const handleError = (event: ErrorEvent) => { const handleError = (event: ErrorEvent) => {
@@ -184,37 +170,6 @@ export function AuthenticatedClient({
}; };
}, []); }, []);
function persistServerSelection() {
try {
const next = setClientServerSelection({
preset: serverSelection.preset,
customBaseUrl: customServerUrl,
});
setServerSelection(next);
return next;
} catch (error) {
void captureClientError(error, {
source: 'auth-hydrate',
action: 'restore-session',
});
setLoginStatus(error instanceof Error ? error.message : String(error));
return null;
}
}
function handleServerPresetChange(preset: ClientServerPreset) {
if (preset === 'custom') {
setServerSelection((current) => ({ ...current, preset }));
return;
}
const next = setClientServerSelection({
preset,
customBaseUrl: customServerUrl,
});
setServerSelection(next);
setLoginStatus(`已选择 ${preset} 服务器`);
}
useEffect(() => { useEffect(() => {
let disposed = false; let disposed = false;
async function hydrateAuth() { async function hydrateAuth() {
@@ -430,11 +385,7 @@ export function AuthenticatedClient({
if (codeBusy || codeCooldownSeconds > 0) { if (codeBusy || codeCooldownSeconds > 0) {
return; return;
} }
const persistedSelection = persistServerSelection(); const apiBaseUrl = getClientServerBaseUrl();
if (!persistedSelection) {
return;
}
const apiBaseUrl = getClientServerBaseUrl(persistedSelection);
const normalizedPhone = normalizeAuthPhoneInput(phone); const normalizedPhone = normalizeAuthPhoneInput(phone);
if (!normalizedPhone) { if (!normalizedPhone) {
setLoginStatus('请输入手机号'); setLoginStatus('请输入手机号');
@@ -479,11 +430,7 @@ export function AuthenticatedClient({
setLoginStatus('请输入密码'); setLoginStatus('请输入密码');
return; return;
} }
const persistedSelection = persistServerSelection(); const loginApiBaseUrl = getClientServerBaseUrl();
if (!persistedSelection) {
return;
}
const loginApiBaseUrl = getClientServerBaseUrl(persistedSelection);
const loginAttempt = (loginAttemptRef.current += 1); const loginAttempt = (loginAttemptRef.current += 1);
setLoginBusy(true); setLoginBusy(true);
setLoginStatus('正在登录'); setLoginStatus('正在登录');
@@ -635,50 +582,6 @@ export function AuthenticatedClient({
</button> </button>
</div> </div>
) : null} ) : null}
<label>
<select
aria-label="服务器"
disabled={loginBusy || codeBusy}
value={serverSelection.preset}
onChange={(event) =>
handleServerPresetChange(
event.currentTarget.value as ClientServerPreset,
)
}
>
<option value="release">release</option>
<option value="dev">dev</option>
<option value="custom">custom</option>
</select>
</label>
{serverSelection.preset === 'custom' ? (
<label>
<input
aria-label="自定义服务器地址"
disabled={loginBusy || codeBusy}
inputMode="url"
placeholder="https://example.com"
value={customServerUrl}
onChange={(event) =>
setCustomServerUrl(event.currentTarget.value)
}
onBlur={() => {
if (customServerUrl.trim()) {
try {
normalizeClientServerBaseUrl(customServerUrl);
persistServerSelection();
} catch (error) {
setLoginStatus(
error instanceof Error ? error.message : String(error),
);
}
}
}}
/>
</label>
) : null}
<div className="client-auth-tabs" role="group" aria-label="登录方式"> <div className="client-auth-tabs" role="group" aria-label="登录方式">
<button <button
type="button" type="button"
@@ -9,6 +9,7 @@ import {
RedeemProfileRewardCodeResponse, RedeemProfileRewardCodeResponse,
unwrapApiResponse, unwrapApiResponse,
} from '../../../../packages/shared/src'; } from '../../../../packages/shared/src';
import { getStoredAuthAccessToken } from './clientAuth';
import { fetchClientHttp, readClientHttpResponseText } from './clientHttp'; import { fetchClientHttp, readClientHttpResponseText } from './clientHttp';
import { captureClientError } from './errorReporting'; import { captureClientError } from './errorReporting';
import { import {
@@ -16,7 +17,11 @@ import {
requestPlatformSessionRefresh, requestPlatformSessionRefresh,
} from './platformSession'; } from './platformSession';
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; export {
clearStoredAuthAccessToken,
getStoredAuthAccessToken,
setStoredAuthAccessToken,
} from './clientAuth';
export class ClientAuthRequestError extends Error { export class ClientAuthRequestError extends Error {
readonly status: number | null; readonly status: number | null;
@@ -32,23 +37,6 @@ export class ClientAuthRequestError extends Error {
} }
} }
export function getStoredAuthAccessToken() {
return window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)?.trim() || '';
}
export function setStoredAuthAccessToken(token: string) {
const nextToken = token.trim();
if (nextToken) {
window.localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, nextToken);
return;
}
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
}
export function clearStoredAuthAccessToken() {
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
}
async function readApiErrorMessage( async function readApiErrorMessage(
response: Response, response: Response,
fallback: string, fallback: string,
@@ -29,6 +29,10 @@ import {
} from './clientOperation'; } from './clientOperation';
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
const ACCESS_TOKEN_ORIGIN_STORAGE_KEY =
'genarrative.auth.access-token-origin.v1';
const LEGACY_SERVER_SELECTION_STORAGE_KEY =
'genarrative.client.server-selection.v1';
export function normalizeAuthPhoneInput(phone: string) { export function normalizeAuthPhoneInput(phone: string) {
const compactPhone = phone.replace(/[^\d+]/gu, '').trim(); const compactPhone = phone.replace(/[^\d+]/gu, '').trim();
@@ -44,21 +48,44 @@ function buildClientAuthPhoneInput(phone: string): AuthPhoneNumberInput {
}; };
} }
export function getStoredAuthAccessToken() { export function getStoredAuthAccessToken(
return window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)?.trim() || ''; apiBaseUrl = getClientServerBaseUrl(),
) {
if (apiBaseUrl !== getClientServerBaseUrl()) return '';
const token =
window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)?.trim() || '';
if (!token) return '';
const storedOrigin = window.localStorage.getItem(
ACCESS_TOKEN_ORIGIN_STORAGE_KEY,
);
if (storedOrigin === apiBaseUrl) return token;
// Old preferences were editable independently of the token, so they cannot
// establish its origin. Recover an unmarked session through the dev cookie.
clearStoredAuthAccessToken();
window.localStorage.removeItem(LEGACY_SERVER_SELECTION_STORAGE_KEY);
return '';
} }
function setStoredAuthAccessToken(token: string) { export function setStoredAuthAccessToken(
token: string,
apiBaseUrl = getClientServerBaseUrl(),
) {
if (apiBaseUrl !== getClientServerBaseUrl()) {
throw new Error('登录凭据不属于客户端固定的 dev 服务');
}
const nextToken = token.trim(); const nextToken = token.trim();
if (nextToken) { if (nextToken) {
window.localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, nextToken); window.localStorage.setItem(ACCESS_TOKEN_STORAGE_KEY, nextToken);
window.localStorage.setItem(ACCESS_TOKEN_ORIGIN_STORAGE_KEY, apiBaseUrl);
window.localStorage.removeItem(LEGACY_SERVER_SELECTION_STORAGE_KEY);
return; return;
} }
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY); clearStoredAuthAccessToken();
} }
export function clearStoredAuthAccessToken() { export function clearStoredAuthAccessToken() {
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY); window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
window.localStorage.removeItem(ACCESS_TOKEN_ORIGIN_STORAGE_KEY);
} }
const clientAuthRefreshPromises = new Map<string, Promise<string>>(); const clientAuthRefreshPromises = new Map<string, Promise<string>>();
@@ -102,7 +129,7 @@ function getClientAuthNetworkErrorMessage(error: unknown) {
return '无法连接登录服务:服务器拒绝连接,请确认服务已启动并检查端口'; return '无法连接登录服务:服务器拒绝连接,请确认服务已启动并检查端口';
} }
if (/dns|resolve|name or service not known|/iu.test(detail)) { if (/dns|resolve|name or service not known|/iu.test(detail)) {
return '无法连接登录服务:服务器地址无法解析,请检查服务器选择'; return '无法连接登录服务:服务器地址无法解析,请检查网络后重试';
} }
if (/certificate|tls|ssl|/iu.test(detail)) { if (/certificate|tls|ssl|/iu.test(detail)) {
return '无法连接登录服务:安全连接失败,请检查服务器地址和证书'; return '无法连接登录服务:安全连接失败,请检查服务器地址和证书';
@@ -202,7 +229,7 @@ async function requestAuthJson<T>(
const headers = new Headers(init.headers); const headers = new Headers(init.headers);
headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION); headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION);
if (!options.skipAuth) { if (!options.skipAuth) {
const token = getStoredAuthAccessToken(); const token = getStoredAuthAccessToken(options.apiBaseUrl);
if (token) { if (token) {
headers.set('Authorization', `Bearer ${token}`); headers.set('Authorization', `Bearer ${token}`);
} }
@@ -284,7 +311,7 @@ export async function refreshClientAuthAccessToken(
apiBaseUrl, apiBaseUrl,
transitionClientOperation(operation, 'success'), transitionClientOperation(operation, 'success'),
); );
setStoredAuthAccessToken(response.token); setStoredAuthAccessToken(response.token, apiBaseUrl);
return response.token; return response.token;
}) })
.catch((error) => { .catch((error) => {
@@ -322,7 +349,7 @@ export async function loginClientWithPassword(
'登录失败', '登录失败',
{ skipAuth: true, apiBaseUrl }, { skipAuth: true, apiBaseUrl },
); );
setStoredAuthAccessToken(response.token); setStoredAuthAccessToken(response.token, apiBaseUrl);
return response.user; return response.user;
} }
@@ -365,7 +392,7 @@ export async function loginClientWithPhoneCode(
'登录失败', '登录失败',
{ skipAuth: true, apiBaseUrl }, { skipAuth: true, apiBaseUrl },
); );
setStoredAuthAccessToken(response.token); setStoredAuthAccessToken(response.token, apiBaseUrl);
return response.user; return response.user;
} }
@@ -1,14 +1,13 @@
import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http'; import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http';
export const AGC_DEVELOPMENT_API_BASE_URL = 'https://dev.genarrative.world'; export const AGC_DEVELOPMENT_API_BASE_URL = 'https://dev.genarrative.world';
export const AGC_RELEASE_API_BASE_URL = 'https://www.genarrative.world';
export const AGC_CLIENT_MARKER_HEADER = 'X-Genarrative-Client'; export const AGC_CLIENT_MARKER_HEADER = 'X-Genarrative-Client';
export const AGC_CLIENT_MARKER_VALUE = 'agc'; export const AGC_CLIENT_MARKER_VALUE = 'agc';
/** /**
* Upper bound for the initial network transaction (DNS/connect/response * Upper bound for the initial network transaction (DNS/connect/response
* headers). Callers may override this for a request that legitimately needs * headers). Callers may override this for a request that legitimately needs
* more time; the default prevents auth/bootstrap requests from hanging * more time; the default prevents auth/bootstrap requests from hanging
* forever when the selected server or proxy is unavailable. * forever when the platform service is unavailable.
*/ */
export const CLIENT_HTTP_DEFAULT_TIMEOUT_MS = 15_000; export const CLIENT_HTTP_DEFAULT_TIMEOUT_MS = 15_000;
@@ -85,119 +84,12 @@ export async function readClientHttpResponseText(
} }
} }
export type ClientServerPreset = 'release' | 'dev' | 'custom'; export function getClientServerBaseUrl() {
return AGC_DEVELOPMENT_API_BASE_URL;
export type ClientServerSelection = {
preset: ClientServerPreset;
customBaseUrl: string;
};
const CLIENT_SERVER_SELECTION_STORAGE_KEY =
'genarrative.client.server-selection.v1';
function defaultClientServerPreset(): Exclude<ClientServerPreset, 'custom'> {
return import.meta.env.DEV ? 'dev' : 'release';
}
function isClientServerPreset(value: unknown): value is ClientServerPreset {
return value === 'release' || value === 'dev' || value === 'custom';
}
export function normalizeClientServerBaseUrl(value: string) {
const normalized = value.trim().replace(/\/+$/u, '');
let parsed: URL;
try {
parsed = new URL(normalized);
} catch {
throw new Error('服务器地址无效');
}
if (
!['http:', 'https:'].includes(parsed.protocol) ||
parsed.username ||
parsed.password ||
parsed.pathname !== '/' ||
parsed.search ||
parsed.hash
) {
throw new Error('服务器地址必须是纯 HTTP(S) 地址');
}
const isLoopback = ['localhost', '127.0.0.1', '[::1]'].includes(
parsed.hostname,
);
if (parsed.protocol === 'http:' && !isLoopback) {
throw new Error('非本机服务器必须使用 HTTPS');
}
return normalized;
}
function readStoredClientServerSelection(): ClientServerSelection {
const fallback: ClientServerSelection = {
preset: defaultClientServerPreset(),
customBaseUrl: '',
};
if (typeof window === 'undefined') return fallback;
try {
const raw = window.localStorage.getItem(
CLIENT_SERVER_SELECTION_STORAGE_KEY,
);
if (!raw) return fallback;
const parsed = JSON.parse(raw) as {
preset?: unknown;
customBaseUrl?: unknown;
};
if (!isClientServerPreset(parsed.preset)) return fallback;
const customBaseUrl =
typeof parsed.customBaseUrl === 'string' ? parsed.customBaseUrl : '';
if (parsed.preset === 'custom') {
normalizeClientServerBaseUrl(customBaseUrl);
}
return { preset: parsed.preset, customBaseUrl };
} catch {
return fallback;
}
}
export function getClientServerSelection() {
return readStoredClientServerSelection();
}
export function setClientServerSelection(
selection: ClientServerSelection,
): ClientServerSelection {
const next: ClientServerSelection = {
preset: selection.preset,
customBaseUrl:
selection.preset === 'custom'
? normalizeClientServerBaseUrl(selection.customBaseUrl)
: selection.customBaseUrl.trim(),
};
if (typeof window !== 'undefined') {
window.localStorage.setItem(
CLIENT_SERVER_SELECTION_STORAGE_KEY,
JSON.stringify(next),
);
}
return next;
}
export function resetClientServerSelectionForTests() {
if (typeof window !== 'undefined') {
window.localStorage.removeItem(CLIENT_SERVER_SELECTION_STORAGE_KEY);
}
}
export function getClientServerBaseUrl(
selection: ClientServerSelection = getClientServerSelection(),
) {
if (selection.preset === 'release') return AGC_RELEASE_API_BASE_URL;
if (selection.preset === 'dev') return AGC_DEVELOPMENT_API_BASE_URL;
return normalizeClientServerBaseUrl(selection.customBaseUrl);
} }
type ClientHttpContext = { type ClientHttpContext = {
isDevelopment: boolean;
isTauri: boolean; isTauri: boolean;
pageProtocol: string;
mode?: string; mode?: string;
serverBaseUrl?: string; serverBaseUrl?: string;
}; };
@@ -215,9 +107,7 @@ function withAgcClientMarker(init: RequestInit): RequestInit {
function currentClientHttpContext(): ClientHttpContext { function currentClientHttpContext(): ClientHttpContext {
return { return {
isDevelopment: import.meta.env.DEV,
isTauri: typeof window !== 'undefined' && Boolean(window.__TAURI__), isTauri: typeof window !== 'undefined' && Boolean(window.__TAURI__),
pageProtocol: typeof window === 'undefined' ? '' : window.location.protocol,
mode: import.meta.env.MODE, mode: import.meta.env.MODE,
}; };
} }
@@ -226,20 +116,20 @@ export function resolveClientHttpTarget(
url: string, url: string,
context: ClientHttpContext = currentClientHttpContext(), context: ClientHttpContext = currentClientHttpContext(),
): ClientHttpTarget { ): ClientHttpTarget {
// Existing unit fixtures omit mode; retain the Vite-relative transport for const serverBaseUrl = getClientServerBaseUrl();
// them while real development/release clients use the selected server. const target = new URL(url, `${serverBaseUrl}/`);
if ( if (
!context.serverBaseUrl && (context.serverBaseUrl && context.serverBaseUrl !== serverBaseUrl) ||
(context.mode === 'test' || (!context.mode && context.isDevelopment)) target.origin !== serverBaseUrl ||
target.username ||
target.password
) { ) {
return { transport: 'web', url }; throw new Error('请求目标不在客户端固定的 dev 服务范围内');
} }
const serverBaseUrl = // Unit fixtures use relative requests after the same origin validation.
context.serverBaseUrl ?? getClientServerBaseUrl(getClientServerSelection()); if (context.mode === 'test') {
const target = new URL(url, `${serverBaseUrl}/`); return { transport: 'web', url };
if (target.origin !== serverBaseUrl) {
throw new Error('请求目标不在当前选择的服务器范围内');
} }
if (!context.isTauri) { if (!context.isTauri) {
@@ -258,17 +148,10 @@ export async function fetchClientHttp(
} = {}, } = {},
): Promise<Response> { ): Promise<Response> {
const currentContext = currentClientHttpContext(); const currentContext = currentClientHttpContext();
const serverBaseUrl = options.serverBaseUrl const target = resolveClientHttpTarget(url, {
? normalizeClientServerBaseUrl(options.serverBaseUrl) ...currentContext,
: undefined; serverBaseUrl: options.serverBaseUrl,
// Unit fixtures intentionally use the relative Vite transport. Real clients bind every });
// auth transaction to the explicit origin captured before its first request.
const target = resolveClientHttpTarget(
url,
currentContext.mode === 'test'
? currentContext
: { ...currentContext, serverBaseUrl },
);
const markedInit = withAgcClientMarker(init); const markedInit = withAgcClientMarker(init);
// Always use a private controller so an internal timeout cannot mutate a // Always use a private controller so an internal timeout cannot mutate a
@@ -1,10 +1,12 @@
import type { AuthUser } from '../../../../packages/shared/src/contracts/auth'; import type { AuthUser } from '../../../../packages/shared/src/contracts/auth';
import { resolveTauriInvoke } from '../app/tauri'; import { resolveTauriInvoke } from '../app/tauri';
import { import {
clearStoredAuthAccessToken,
getCurrentClientAuthUser, getCurrentClientAuthUser,
getStoredAuthAccessToken, getStoredAuthAccessToken,
isClientAuthAuthorityFailure, isClientAuthAuthorityFailure,
refreshClientAuthAccessToken, refreshClientAuthAccessToken,
setStoredAuthAccessToken,
} from './clientAuth'; } from './clientAuth';
import { getClientServerBaseUrl } from './clientHttp'; import { getClientServerBaseUrl } from './clientHttp';
import { import {
@@ -13,10 +15,8 @@ import {
transitionClientOperation, transitionClientOperation,
} from './clientOperation'; } from './clientOperation';
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1'; function readStoredAccessTokenOrThrow(apiBaseUrl: string) {
const accessToken = getStoredAuthAccessToken(apiBaseUrl);
function readStoredAccessTokenOrThrow() {
const accessToken = getStoredAuthAccessToken();
if (!accessToken) { if (!accessToken) {
throw new Error('陶泥儿登录凭据缺失,请重新登录'); throw new Error('陶泥儿登录凭据缺失,请重新登录');
} }
@@ -94,18 +94,18 @@ export function getPlatformSessionOperation() {
function restoreCommittedAccessToken() { function restoreCommittedAccessToken() {
if (committedPlatformSession?.accessToken) { if (committedPlatformSession?.accessToken) {
window.localStorage.setItem( setStoredAuthAccessToken(
ACCESS_TOKEN_STORAGE_KEY,
committedPlatformSession.accessToken, committedPlatformSession.accessToken,
committedPlatformSession.apiBaseUrl,
); );
return; return;
} }
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY); clearStoredAuthAccessToken();
} }
function restoreCurrentRendererAccessToken() { function restoreCurrentRendererAccessToken() {
if (!desiredPlatformSession) { if (!desiredPlatformSession) {
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY); clearStoredAuthAccessToken();
return; return;
} }
restoreCommittedAccessToken(); restoreCommittedAccessToken();
@@ -424,7 +424,7 @@ export async function commitAuthenticatedPlatformSession(
expectedGeneration: number, expectedGeneration: number,
apiBaseUrl = resolvePlatformApiBaseUrl(), apiBaseUrl = resolvePlatformApiBaseUrl(),
) { ) {
const accessToken = readStoredAccessTokenOrThrow(); const accessToken = readStoredAccessTokenOrThrow(apiBaseUrl);
const operation = createClientOperation( const operation = createClientOperation(
'auth-transition', 'auth-transition',
{ userId: user.id }, { userId: user.id },
@@ -508,7 +508,7 @@ export function requestPlatformSessionRefresh(expectedUserId?: string) {
const committed = await enqueuePlatformSessionNativeMutation(() => const committed = await enqueuePlatformSessionNativeMutation(() =>
commitPlatformCredentialRefresh( commitPlatformCredentialRefresh(
user, user,
readStoredAccessTokenOrThrow(), readStoredAccessTokenOrThrow(apiBaseUrl),
apiBaseUrl, apiBaseUrl,
expectedGeneration, expectedGeneration,
), ),
@@ -1,11 +1,7 @@
import { afterEach } from 'vitest'; import { afterEach } from 'vitest';
import { import { setStoredAuthAccessToken } from '../../src/services/clientAuth';
AGC_DEVELOPMENT_API_BASE_URL, import { AGC_DEVELOPMENT_API_BASE_URL } from '../../src/services/clientHttp';
AGC_RELEASE_API_BASE_URL,
resetClientServerSelectionForTests,
setClientServerSelection,
} from '../../src/services/clientHttp';
import { import {
beginPlatformSessionClearTransition, beginPlatformSessionClearTransition,
beginPlatformSessionTransition, beginPlatformSessionTransition,
@@ -33,7 +29,6 @@ import {
export function registerAuthTests() { export function registerAuthTests() {
afterEach(() => { afterEach(() => {
resetPlatformSessionStateForTests(); resetPlatformSessionStateForTests();
resetClientServerSelectionForTests();
delete window.__TAURI__; delete window.__TAURI__;
}); });
@@ -151,7 +146,6 @@ export function registerAuthTests() {
}); });
it('keeps login HTTP and native commit bound to the origin frozen before the request', async () => { it('keeps login HTTP and native commit bound to the origin frozen before the request', async () => {
setClientServerSelection({ preset: 'dev', customBaseUrl: '' });
const invoke = vi.fn(async () => null); const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
let resolveLogin: ((response: Response) => void) | null = null; let resolveLogin: ((response: Response) => void) | null = null;
@@ -184,11 +178,12 @@ export function registerAuthTests() {
}); });
fireEvent.click(screen.getByRole('button', { name: '登录' })); fireEvent.click(screen.getByRole('button', { name: '登录' }));
await waitFor(() => expect(resolveLogin).not.toBeNull()); await waitFor(() => expect(resolveLogin).not.toBeNull());
expect( expect(screen.queryByLabelText('服务器')).toBeNull();
(screen.getByLabelText('服务器') as HTMLSelectElement).disabled,
).toBe(true);
setClientServerSelection({ preset: 'release', customBaseUrl: '' }); window.localStorage.setItem(
'genarrative.client.server-selection.v1',
JSON.stringify({ preset: 'release', customBaseUrl: '' }),
);
resolveLogin?.( resolveLogin?.(
new Response( new Response(
JSON.stringify({ JSON.stringify({
@@ -211,7 +206,7 @@ export function registerAuthTests() {
); );
expect(invoke).not.toHaveBeenCalledWith( expect(invoke).not.toHaveBeenCalledWith(
'install_platform_account_session', 'install_platform_account_session',
expect.objectContaining({ apiBaseUrl: AGC_RELEASE_API_BASE_URL }), expect.objectContaining({ apiBaseUrl: 'https://www.genarrative.world' }),
); );
}); });
@@ -257,10 +252,7 @@ export function registerAuthTests() {
const installFloor = nativeFloor.revision; const installFloor = nativeFloor.revision;
const installIdentityFloor = nativeFloor.identityGeneration; const installIdentityFloor = nativeFloor.identityGeneration;
const loginGeneration = beginPlatformSessionTransition(); const loginGeneration = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('renderer-reload-token');
'genarrative.auth.access-token.v1',
'renderer-reload-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, loginGeneration); await commitAuthenticatedPlatformSession(testAuthUser, loginGeneration);
expect(mutations[0]?.command).toBe('install_platform_account_session'); expect(mutations[0]?.command).toBe('install_platform_account_session');
expect(mutations[0]?.identityGeneration).toBeGreaterThan( expect(mutations[0]?.identityGeneration).toBeGreaterThan(
@@ -310,10 +302,7 @@ export function registerAuthTests() {
}); });
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const firstGeneration = beginPlatformSessionTransition(); const firstGeneration = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('retry-floor-token');
'genarrative.auth.access-token.v1',
'retry-floor-token',
);
await expect( await expect(
commitAuthenticatedPlatformSession(testAuthUser, firstGeneration), commitAuthenticatedPlatformSession(testAuthUser, firstGeneration),
@@ -321,10 +310,7 @@ export function registerAuthTests() {
// 瞬时读取失败不能被缓存成永久失败:第二次登录必须重新读取并成功。 // 瞬时读取失败不能被缓存成永久失败:第二次登录必须重新读取并成功。
const secondGeneration = beginPlatformSessionTransition(); const secondGeneration = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('retry-floor-token');
'genarrative.auth.access-token.v1',
'retry-floor-token',
);
await expect( await expect(
commitAuthenticatedPlatformSession(testAuthUser, secondGeneration), commitAuthenticatedPlatformSession(testAuthUser, secondGeneration),
).resolves.toEqual(expect.any(Number)); ).resolves.toEqual(expect.any(Number));
@@ -358,10 +344,7 @@ export function registerAuthTests() {
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const stalledGeneration = beginPlatformSessionTransition(); const stalledGeneration = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('stalled-token');
'genarrative.auth.access-token.v1',
'stalled-token',
);
const stalled = commitAuthenticatedPlatformSession( const stalled = commitAuthenticatedPlatformSession(
testAuthUser, testAuthUser,
stalledGeneration, stalledGeneration,
@@ -370,10 +353,7 @@ export function registerAuthTests() {
expect(installedTokens).toEqual(['stalled-token']); expect(installedTokens).toEqual(['stalled-token']);
const retryGeneration = beginPlatformSessionTransition(); const retryGeneration = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('retry-token');
'genarrative.auth.access-token.v1',
'retry-token',
);
const retry = commitAuthenticatedPlatformSession( const retry = commitAuthenticatedPlatformSession(
testAuthUser, testAuthUser,
retryGeneration, retryGeneration,
@@ -477,17 +457,11 @@ export function registerAuthTests() {
}); });
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const accountAGeneration = beginPlatformSessionTransition(); const accountAGeneration = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('account-a-token');
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition(); const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('account-b-token');
'genarrative.auth.access-token.v1',
'account-b-token',
);
await expect( await expect(
commitAuthenticatedPlatformSession(accountB, accountBGeneration), commitAuthenticatedPlatformSession(accountB, accountBGeneration),
@@ -513,17 +487,11 @@ export function registerAuthTests() {
}); });
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const accountAGeneration = beginPlatformSessionTransition(); const accountAGeneration = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('account-a-token');
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition(); const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('account-b-token');
'genarrative.auth.access-token.v1',
'account-b-token',
);
const accountBCommit = commitAuthenticatedPlatformSession( const accountBCommit = commitAuthenticatedPlatformSession(
accountB, accountB,
accountBGeneration, accountBGeneration,
@@ -562,17 +530,11 @@ export function registerAuthTests() {
}); });
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const accountAGeneration = beginPlatformSessionTransition(); const accountAGeneration = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('account-a-token');
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition(); const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('account-b-token');
'genarrative.auth.access-token.v1',
'account-b-token',
);
const accountBCommit = commitAuthenticatedPlatformSession( const accountBCommit = commitAuthenticatedPlatformSession(
accountB, accountB,
accountBGeneration, accountBGeneration,
@@ -613,18 +575,12 @@ export function registerAuthTests() {
}); });
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const accountAGeneration = beginPlatformSessionTransition(); const accountAGeneration = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('account-a-token');
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition(); const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('account-b-token');
'genarrative.auth.access-token.v1',
'account-b-token',
);
const accountBCommit = commitAuthenticatedPlatformSession( const accountBCommit = commitAuthenticatedPlatformSession(
accountB, accountB,
accountBGeneration, accountBGeneration,
@@ -633,10 +589,7 @@ export function registerAuthTests() {
const accountC = { ...testAuthUser, id: 'user-c', displayName: '用户 C' }; const accountC = { ...testAuthUser, id: 'user-c', displayName: '用户 C' };
const accountCGeneration = beginPlatformSessionTransition(); const accountCGeneration = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('account-c-token');
'genarrative.auth.access-token.v1',
'account-c-token',
);
const accountCCommit = commitAuthenticatedPlatformSession( const accountCCommit = commitAuthenticatedPlatformSession(
accountC, accountC,
accountCGeneration, accountCGeneration,
@@ -661,18 +614,12 @@ export function registerAuthTests() {
const invoke = vi.fn(async () => null); const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const accountAGeneration = beginPlatformSessionTransition(); const accountAGeneration = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('account-a-token');
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration); await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition(); const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('account-b-token');
'genarrative.auth.access-token.v1',
'account-b-token',
);
const accountBCommit = commitAuthenticatedPlatformSession( const accountBCommit = commitAuthenticatedPlatformSession(
accountB, accountB,
accountBGeneration, accountBGeneration,
@@ -694,10 +641,7 @@ export function registerAuthTests() {
const invoke = vi.fn(async () => null); const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const initialGeneration = beginPlatformSessionTransition(); const initialGeneration = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('account-a-token');
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration); await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration);
let resolveRefresh: ((response: Response) => void) | null = null; let resolveRefresh: ((response: Response) => void) | null = null;
@@ -731,10 +675,7 @@ export function registerAuthTests() {
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition(); const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('account-b-token');
'genarrative.auth.access-token.v1',
'account-b-token',
);
await commitAuthenticatedPlatformSession(accountB, accountBGeneration); await commitAuthenticatedPlatformSession(accountB, accountBGeneration);
resolveRefresh?.( resolveRefresh?.(
new Response(JSON.stringify({ token: 'late-account-a-token' }), { new Response(JSON.stringify({ token: 'late-account-a-token' }), {
@@ -768,10 +709,7 @@ export function registerAuthTests() {
const invoke = vi.fn(async () => null); const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const initialGeneration = beginPlatformSessionTransition(); const initialGeneration = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('account-a-token');
'genarrative.auth.access-token.v1',
'account-a-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration); await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration);
let rejectRefresh: ((error: Error) => void) | null = null; let rejectRefresh: ((error: Error) => void) | null = null;
@@ -788,10 +726,7 @@ export function registerAuthTests() {
const staleRefresh = requestPlatformSessionRefresh(testAuthUser.id); const staleRefresh = requestPlatformSessionRefresh(testAuthUser.id);
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' }; const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
const accountBGeneration = beginPlatformSessionTransition(); const accountBGeneration = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('account-b-token');
'genarrative.auth.access-token.v1',
'account-b-token',
);
await commitAuthenticatedPlatformSession(accountB, accountBGeneration); await commitAuthenticatedPlatformSession(accountB, accountBGeneration);
rejectRefresh?.(new Error('late account A refresh failed')); rejectRefresh?.(new Error('late account A refresh failed'));
@@ -805,10 +740,7 @@ export function registerAuthTests() {
const invoke = vi.fn(async () => null); const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const generation = beginPlatformSessionTransition(); const generation = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('expired-token');
'genarrative.auth.access-token.v1',
'expired-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, generation); await commitAuthenticatedPlatformSession(testAuthUser, generation);
let refreshCalls = 0; let refreshCalls = 0;
vi.spyOn(globalThis, 'fetch').mockImplementation( vi.spyOn(globalThis, 'fetch').mockImplementation(
@@ -862,10 +794,7 @@ export function registerAuthTests() {
}); });
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const generation = beginPlatformSessionTransition(); const generation = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('expired-token');
'genarrative.auth.access-token.v1',
'expired-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, generation); await commitAuthenticatedPlatformSession(testAuthUser, generation);
const identityGenerationAfterLogin = const identityGenerationAfterLogin =
currentPlatformNativeIdentityGenerationForTests(); currentPlatformNativeIdentityGenerationForTests();
@@ -915,10 +844,7 @@ export function registerAuthTests() {
const invoke = vi.fn(async () => null); const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const generation = beginPlatformSessionTransition(); const generation = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('still-valid-token');
'genarrative.auth.access-token.v1',
'still-valid-token',
);
await commitAuthenticatedPlatformSession(testAuthUser, generation); await commitAuthenticatedPlatformSession(testAuthUser, generation);
const sessionGeneration = currentPlatformSessionGeneration(); const sessionGeneration = currentPlatformSessionGeneration();
@@ -947,14 +873,10 @@ export function registerAuthTests() {
}); });
it('keeps refresh, current-user lookup, and native commit on the frozen origin', async () => { it('keeps refresh, current-user lookup, and native commit on the frozen origin', async () => {
setClientServerSelection({ preset: 'dev', customBaseUrl: '' });
const invoke = vi.fn(async () => null); const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } }; window.__TAURI__ = { core: { invoke } };
const generation = beginPlatformSessionTransition(); const generation = beginPlatformSessionTransition();
window.localStorage.setItem( setStoredAuthAccessToken('expired-token');
'genarrative.auth.access-token.v1',
'expired-token',
);
await commitAuthenticatedPlatformSession( await commitAuthenticatedPlatformSession(
testAuthUser, testAuthUser,
generation, generation,
@@ -983,7 +905,10 @@ export function registerAuthTests() {
}, },
); );
const refresh = requestPlatformSessionRefresh(testAuthUser.id); const refresh = requestPlatformSessionRefresh(testAuthUser.id);
setClientServerSelection({ preset: 'release', customBaseUrl: '' }); window.localStorage.setItem(
'genarrative.client.server-selection.v1',
JSON.stringify({ preset: 'release', customBaseUrl: '' }),
);
resolveRefresh?.( resolveRefresh?.(
new Response(JSON.stringify({ token: 'replacement-token' }), { new Response(JSON.stringify({ token: 'replacement-token' }), {
status: 200, status: 200,
@@ -1074,7 +999,7 @@ export function registerAuthTests() {
expect(screen.queryByLabelText('已登录')).toBeNull(); expect(screen.queryByLabelText('已登录')).toBeNull();
}); });
it('shows release, dev, and custom server choices on the login screen', async () => { it('shows login without server selection or custom platform address', async () => {
vi.spyOn(globalThis, 'fetch').mockImplementation( vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => { async (input: RequestInfo | URL) => {
if (String(input) === '/api/auth/refresh') { if (String(input) === '/api/auth/refresh') {
@@ -1091,22 +1016,74 @@ export function registerAuthTests() {
); );
await screen.findByRole('main', { name: '登录' }); await screen.findByRole('main', { name: '登录' });
const server = screen.getByRole('combobox', { name: '服务器' }); expect(screen.queryByRole('combobox', { name: '服务器' })).toBeNull();
expect(server).not.toBeNull(); expect(screen.queryByLabelText('自定义服务器地址')).toBeNull();
expect(screen.getByRole('option', { name: 'release' })).not.toBeNull();
expect(screen.getByRole('option', { name: 'dev' })).not.toBeNull();
expect(screen.getByRole('option', { name: 'custom' })).not.toBeNull();
fireEvent.change(server, { target: { value: 'custom' } });
expect(screen.getByLabelText('自定义服务器地址')).not.toBeNull();
fireEvent.change(screen.getByLabelText('自定义服务器地址'), {
target: { value: 'https://staging.example.com' },
});
expect(
(screen.getByLabelText('自定义服务器地址') as HTMLInputElement).value,
).toBe('https://staging.example.com');
}); });
it.each(['release', 'dev'])(
'restores dev without forwarding a bare credential despite the %s preference',
async (preset) => {
window.localStorage.setItem(
'genarrative.auth.access-token.v1',
'release-token',
);
window.localStorage.setItem(
'genarrative.client.server-selection.v1',
JSON.stringify({ preset, customBaseUrl: '' }),
);
const invoke = vi.fn(async () => null);
window.__TAURI__ = { core: { invoke } };
const fetchSpy = vi
.spyOn(globalThis, 'fetch')
.mockImplementation(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
expect(new Headers(init?.headers).get('Authorization')).not.toBe(
'Bearer release-token',
);
if (url === '/api/auth/refresh') {
expect(
new Headers(init?.headers).get('Authorization'),
).toBeNull();
return new Response(JSON.stringify({ token: 'dev-token' }), {
status: 200,
});
}
if (url === '/api/auth/me') {
expect(new Headers(init?.headers).get('Authorization')).toBe(
'Bearer dev-token',
);
return new Response(JSON.stringify({ user: testAuthUser }), {
status: 200,
});
}
throw new Error(`unexpected fetch ${url}`);
},
);
render(
React.createElement(AuthenticatedClient, null, () =>
React.createElement('main', { 'aria-label': '已登录' }),
),
);
expect(
await screen.findByRole('main', { name: '已登录' }),
).not.toBeNull();
expect(fetchSpy.mock.calls.map(([url]) => String(url))).toEqual([
'/api/auth/refresh',
'/api/auth/me',
]);
expect(invoke).toHaveBeenLastCalledWith(
'install_platform_account_session',
expect.objectContaining({
accessToken: 'dev-token',
apiBaseUrl: AGC_DEVELOPMENT_API_BASE_URL,
}),
);
},
);
it('logs in with a phone code and stores the returned token', async () => { it('logs in with a phone code and stores the returned token', async () => {
const fetchSpy = vi const fetchSpy = vi
.spyOn(globalThis, 'fetch') .spyOn(globalThis, 'fetch')
@@ -1445,10 +1422,7 @@ export function registerAuthTests() {
}); });
it('keeps the stored token when startup auth check cannot reach the service', async () => { it('keeps the stored token when startup auth check cannot reach the service', async () => {
window.localStorage.setItem( setStoredAuthAccessToken('existing-token');
'genarrative.auth.access-token.v1',
'existing-token',
);
const fetchSpy = vi const fetchSpy = vi
.spyOn(globalThis, 'fetch') .spyOn(globalThis, 'fetch')
.mockImplementation(async (input: RequestInfo | URL) => { .mockImplementation(async (input: RequestInfo | URL) => {
@@ -1485,10 +1459,7 @@ export function registerAuthTests() {
}); });
it('shows the HTTP maintenance error when startup auth receives a 503', async () => { it('shows the HTTP maintenance error when startup auth receives a 503', async () => {
window.localStorage.setItem( setStoredAuthAccessToken('existing-token');
'genarrative.auth.access-token.v1',
'existing-token',
);
vi.spyOn(globalThis, 'fetch').mockImplementation( vi.spyOn(globalThis, 'fetch').mockImplementation(
async (input: RequestInfo | URL) => { async (input: RequestInfo | URL) => {
if (String(input) === '/api/auth/me') { if (String(input) === '/api/auth/me') {
@@ -1516,10 +1487,7 @@ export function registerAuthTests() {
}); });
it('still calls logout when token refresh fails during logout retry', async () => { it('still calls logout when token refresh fails during logout retry', async () => {
window.localStorage.setItem( setStoredAuthAccessToken('existing-token');
'genarrative.auth.access-token.v1',
'existing-token',
);
let logoutCalls = 0; let logoutCalls = 0;
const fetchSpy = vi const fetchSpy = vi
.spyOn(globalThis, 'fetch') .spyOn(globalThis, 'fetch')
@@ -1581,10 +1549,7 @@ export function registerAuthTests() {
}); });
it('fails the renderer closed when native session clear is rejected during logout', async () => { it('fails the renderer closed when native session clear is rejected during logout', async () => {
window.localStorage.setItem( setStoredAuthAccessToken('existing-token');
'genarrative.auth.access-token.v1',
'existing-token',
);
window.__TAURI__ = { window.__TAURI__ = {
core: { core: {
invoke: vi.fn(async (command: string) => { invoke: vi.fn(async (command: string) => {
@@ -11,7 +11,10 @@ import {
getStoredAuthAccessToken, getStoredAuthAccessToken,
refreshClientAuthAccessToken, refreshClientAuthAccessToken,
} from '../src/services/clientAuth'; } from '../src/services/clientAuth';
import { CLIENT_HTTP_DEFAULT_TIMEOUT_MS } from '../src/services/clientHttp'; import {
AGC_DEVELOPMENT_API_BASE_URL,
CLIENT_HTTP_DEFAULT_TIMEOUT_MS,
} from '../src/services/clientHttp';
import { import {
cachedLlmModelCatalog, cachedLlmModelCatalog,
refreshLlmModelCatalog, refreshLlmModelCatalog,
@@ -266,16 +269,18 @@ it('响应体卡住超时后,下一次续期会重新发起请求', async () =
); );
}); });
const first = refreshClientAuthAccessToken('http://localhost:3000'); const first = refreshClientAuthAccessToken(AGC_DEVELOPMENT_API_BASE_URL);
const firstAssertion = expect(first).rejects.toThrow(); const firstAssertion = expect(first).rejects.toThrow();
await vi.advanceTimersByTimeAsync(15_000); await vi.advanceTimersByTimeAsync(15_000);
await firstAssertion; await firstAssertion;
expect(getClientAuthRefreshOperation('http://localhost:3000')).toMatchObject({ expect(
getClientAuthRefreshOperation(AGC_DEVELOPMENT_API_BASE_URL),
).toMatchObject({
kind: 'auth-refresh', kind: 'auth-refresh',
phase: 'retryable-failure', phase: 'retryable-failure',
}); });
const second = refreshClientAuthAccessToken('http://localhost:3000'); const second = refreshClientAuthAccessToken(AGC_DEVELOPMENT_API_BASE_URL);
const secondAssertion = expect(second).rejects.toThrow(); const secondAssertion = expect(second).rejects.toThrow();
expect(refreshCalls).toBe(2); expect(refreshCalls).toBe(2);
await vi.advanceTimersByTimeAsync(15_000); await vi.advanceTimersByTimeAsync(15_000);
@@ -0,0 +1,121 @@
/** @vitest-environment jsdom */
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
getStoredAuthAccessToken as getApiAccessToken,
requestClientApi,
} from '../src/services/clientApi';
import {
clearStoredAuthAccessToken,
getStoredAuthAccessToken,
setStoredAuthAccessToken,
} from '../src/services/clientAuth';
import { AGC_DEVELOPMENT_API_BASE_URL } from '../src/services/clientHttp';
vi.mock('@tauri-apps/plugin-http', () => ({ fetch: vi.fn() }));
vi.mock('../src/services/errorReporting', () => ({
captureClientError: vi.fn(),
}));
const tokenKey = 'genarrative.auth.access-token.v1';
const originKey = 'genarrative.auth.access-token-origin.v1';
const selectionKey = 'genarrative.client.server-selection.v1';
describe('AGC platform credential origin', () => {
afterEach(() => {
window.localStorage.clear();
vi.restoreAllMocks();
vi.unstubAllEnvs();
});
it('preserves a credential already marked as dev', () => {
window.localStorage.setItem(tokenKey, 'existing-dev-token');
window.localStorage.setItem(originKey, AGC_DEVELOPMENT_API_BASE_URL);
expect(getStoredAuthAccessToken()).toBe('existing-dev-token');
expect(getApiAccessToken()).toBe('existing-dev-token');
expect(window.localStorage.getItem(originKey)).toBe(
AGC_DEVELOPMENT_API_BASE_URL,
);
});
it.each([
JSON.stringify({ preset: 'dev', customBaseUrl: '' }),
JSON.stringify({
preset: 'custom',
customBaseUrl: `${AGC_DEVELOPMENT_API_BASE_URL}/`,
}),
JSON.stringify({ preset: 'release', customBaseUrl: '' }),
JSON.stringify({ preset: 'custom', customBaseUrl: 'https://example.com' }),
JSON.stringify({
preset: 'custom',
customBaseUrl: 'http://localhost:8082',
}),
JSON.stringify({ preset: 'unknown', customBaseUrl: '' }),
'invalid-json',
'null',
])(
'never infers a legacy credential origin from a saved preference: %s',
async (selection) => {
window.localStorage.setItem(tokenKey, 'other-server-token');
window.localStorage.setItem(selectionKey, selection);
vi.stubEnv('MODE', 'production');
const fetchMock = vi
.spyOn(globalThis, 'fetch')
.mockResolvedValue(
new Response(JSON.stringify({ result: true }), { status: 200 }),
);
await requestClientApi('/api/profile/dashboard', {}, '读取失败');
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0];
expect(url).toBe(`${AGC_DEVELOPMENT_API_BASE_URL}/api/profile/dashboard`);
expect(new Headers(init?.headers).get('Authorization')).toBeNull();
expect(window.localStorage.getItem(tokenKey)).toBeNull();
},
);
it.each([true, false])(
'clears an unmarked credential without a preference (development=%s)',
(development) => {
// Vitest 0.34 stores stubbed env values as strings; use a falsy value for DEV=false.
vi.stubEnv('DEV', development ? 'true' : '');
window.localStorage.setItem(tokenKey, 'legacy-token');
expect(getStoredAuthAccessToken()).toBe('');
},
);
it('does not relabel a credential that already belongs to another origin', () => {
window.localStorage.setItem(tokenKey, 'other-origin-token');
window.localStorage.setItem(originKey, 'https://www.genarrative.world');
window.localStorage.setItem(
selectionKey,
JSON.stringify({ preset: 'dev' }),
);
expect(getApiAccessToken()).toBe('');
expect(window.localStorage.getItem(tokenKey)).toBeNull();
expect(window.localStorage.getItem(originKey)).toBeNull();
});
it('stores new dev credentials with their origin and ignores old preferences', () => {
vi.stubEnv('DEV', false);
setStoredAuthAccessToken('new-token');
window.localStorage.setItem(
selectionKey,
JSON.stringify({ preset: 'release' }),
);
expect(getApiAccessToken()).toBe('new-token');
expect(getStoredAuthAccessToken('https://www.genarrative.world')).toBe('');
expect(() =>
setStoredAuthAccessToken('wrong-token', 'https://example.com'),
).toThrow('固定的 dev 服务');
expect(getStoredAuthAccessToken()).toBe('new-token');
clearStoredAuthAccessToken();
expect(window.localStorage.getItem(tokenKey)).toBeNull();
expect(window.localStorage.getItem(originKey)).toBeNull();
});
});
@@ -1,3 +1,4 @@
/** @vitest-environment jsdom */
import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http'; import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -9,16 +10,11 @@ import {
AGC_CLIENT_MARKER_HEADER, AGC_CLIENT_MARKER_HEADER,
AGC_CLIENT_MARKER_VALUE, AGC_CLIENT_MARKER_VALUE,
AGC_DEVELOPMENT_API_BASE_URL, AGC_DEVELOPMENT_API_BASE_URL,
AGC_RELEASE_API_BASE_URL,
ClientHttpTimeoutError, ClientHttpTimeoutError,
fetchClientHttp, fetchClientHttp,
getClientServerBaseUrl, getClientServerBaseUrl,
getClientServerSelection,
normalizeClientServerBaseUrl,
readClientHttpResponseText, readClientHttpResponseText,
resetClientServerSelectionForTests,
resolveClientHttpTarget, resolveClientHttpTarget,
setClientServerSelection,
} from '../src/services/clientHttp'; } from '../src/services/clientHttp';
vi.mock('@tauri-apps/plugin-http', () => ({ vi.mock('@tauri-apps/plugin-http', () => ({
@@ -31,7 +27,7 @@ describe('AGC client HTTP transport', () => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.unstubAllEnvs(); vi.unstubAllEnvs();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
resetClientServerSelectionForTests(); window.localStorage.clear();
}); });
it('adds the AGC marker while preserving and overriding request headers', async () => { it('adds the AGC marker while preserving and overriding request headers', async () => {
@@ -123,37 +119,25 @@ describe('AGC client HTTP transport', () => {
expect(forwardedHeaders.get('Authorization')).toBe('Bearer fixture-token'); expect(forwardedHeaders.get('Authorization')).toBe('Bearer fixture-token');
}); });
it('keeps local development requests on the Vite API proxy', () => { it.each(['development', 'production'])(
expect( 'routes %s Tauri requests through fixed dev',
resolveClientHttpTarget('/api/auth/me', { (mode) => {
isDevelopment: true, expect(
isTauri: true, resolveClientHttpTarget('/api/auth/me', {
pageProtocol: 'http:', isTauri: true,
}), mode,
).toEqual({ transport: 'web', url: '/api/auth/me' }); }),
}); ).toEqual({
transport: 'tauri-http',
url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`,
});
},
);
it('routes release Tauri requests through the scoped dev API transport', () => { it('keeps test fixtures on relative requests after origin validation', () => {
expect( expect(
resolveClientHttpTarget('/api/auth/me', { resolveClientHttpTarget('/api/auth/me', {
isDevelopment: false,
isTauri: true,
pageProtocol: 'tauri:',
mode: 'production',
serverBaseUrl: AGC_RELEASE_API_BASE_URL,
}),
).toEqual({
transport: 'tauri-http',
url: `${AGC_RELEASE_API_BASE_URL}/api/auth/me`,
});
});
it('keeps ordinary web releases on same-origin relative requests', () => {
expect(
resolveClientHttpTarget('/api/auth/me', {
isDevelopment: false,
isTauri: false, isTauri: false,
pageProtocol: 'https:',
mode: 'test', mode: 'test',
}), }),
).toEqual({ transport: 'web', url: '/api/auth/me' }); ).toEqual({ transport: 'web', url: '/api/auth/me' });
@@ -162,97 +146,55 @@ describe('AGC client HTTP transport', () => {
it('rejects release Tauri requests outside the fixed dev API origin', () => { it('rejects release Tauri requests outside the fixed dev API origin', () => {
expect(() => expect(() =>
resolveClientHttpTarget('https://example.com/api/auth/me', { resolveClientHttpTarget('https://example.com/api/auth/me', {
isDevelopment: false,
isTauri: true, isTauri: true,
pageProtocol: 'tauri:',
mode: 'production', mode: 'production',
serverBaseUrl: AGC_RELEASE_API_BASE_URL,
}), }),
).toThrow('当前选择的服务范围'); ).toThrow('固定的 dev 服务范围');
}); });
it('persists release, dev, and custom server selection', () => { it.each(['release', 'dev', 'custom'])(
const release = setClientServerSelection({ 'ignores persisted %s preference when resolving web requests',
preset: 'release', (preset) => {
customBaseUrl: '', window.localStorage.setItem(
}); 'genarrative.client.server-selection.v1',
expect(release).toEqual({ JSON.stringify({
preset: 'release', preset,
customBaseUrl: '', customBaseUrl: 'https://staging.example.com',
}); }),
expect(getClientServerBaseUrl(release)).toBe(AGC_RELEASE_API_BASE_URL); );
const dev = setClientServerSelection({ preset: 'dev', customBaseUrl: '' }); vi.stubEnv('DEV', false);
expect(getClientServerBaseUrl(dev)).toBe(AGC_DEVELOPMENT_API_BASE_URL); expect(getClientServerBaseUrl()).toBe(AGC_DEVELOPMENT_API_BASE_URL);
expect(
resolveClientHttpTarget('/api/auth/me', {
isTauri: false,
mode: 'development',
}),
).toEqual({
transport: 'web',
url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`,
});
},
);
const custom = setClientServerSelection({ it.each(['development', 'production', 'test'])(
preset: 'custom', 'rejects explicit origin overrides before transport in %s',
customBaseUrl: 'https://staging.example.com/', async (mode) => {
}); vi.stubEnv('MODE', mode);
expect(custom).toEqual({ const fetchMock = vi.fn();
preset: 'custom', vi.stubGlobal('fetch', fetchMock);
customBaseUrl: 'https://staging.example.com', await expect(
}); fetchClientHttp(
expect(getClientServerSelection().preset).toBe('dev'); '/api/auth/me',
expect(getClientServerBaseUrl(custom)).toBe('https://staging.example.com'); {},
}); {
serverBaseUrl: 'https://www.genarrative.world',
it('accepts HTTPS custom servers and loopback HTTP only', () => { },
expect(normalizeClientServerBaseUrl('https://example.com/')).toBe( ),
'https://example.com', ).rejects.toThrow('固定的 dev 服务范围');
); expect(fetchMock).not.toHaveBeenCalled();
expect(normalizeClientServerBaseUrl('http://127.0.0.1:8080/')).toBe( expect(tauriHttpFetch).not.toHaveBeenCalled();
'http://127.0.0.1:8080', },
); );
expect(() => normalizeClientServerBaseUrl('http://example.com')).toThrow(
'必须使用 HTTPS',
);
expect(() =>
normalizeClientServerBaseUrl('https://example.com/api'),
).toThrow('纯 HTTP(S)');
});
it('routes selected custom servers for both web and Tauri clients', () => {
const serverBaseUrl = 'https://staging.example.com';
expect(
resolveClientHttpTarget('/api/auth/me', {
isDevelopment: true,
isTauri: false,
pageProtocol: 'http:',
mode: 'development',
serverBaseUrl,
}),
).toEqual({
transport: 'web',
url: `${serverBaseUrl}/api/auth/me`,
});
expect(
resolveClientHttpTarget('/api/auth/me', {
isDevelopment: false,
isTauri: true,
pageProtocol: 'tauri:',
mode: 'production',
serverBaseUrl,
}),
).toEqual({
transport: 'tauri-http',
url: `${serverBaseUrl}/api/auth/me`,
});
});
it('keeps Tauri HTTP transport when the WebView reports an http page protocol', () => {
expect(
resolveClientHttpTarget('/api/auth/me', {
isDevelopment: false,
isTauri: true,
pageProtocol: 'http:',
mode: 'production',
serverBaseUrl: AGC_DEVELOPMENT_API_BASE_URL,
}),
).toEqual({
transport: 'tauri-http',
url: `${AGC_DEVELOPMENT_API_BASE_URL}/api/auth/me`,
});
});
it('aborts a stalled Web request at the configured timeout', async () => { it('aborts a stalled Web request at the configured timeout', async () => {
vi.useFakeTimers(); vi.useFakeTimers();
+1 -1
View File
@@ -42,7 +42,7 @@
- [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。 - [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。
- [AGC Unity 编辑器插件接入](./technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md)DotCraft Attach 来源、Windows Mono 接入、项目身份、执行回执和分发边界。 - [AGC Unity 编辑器插件接入](./technical/【技术方案】AGC Unity编辑器插件接入-2026-09-18.md)DotCraft Attach 来源、Windows Mono 接入、项目身份、执行回执和分发边界。
- [AGC Cocos Creator 编辑器桥接模块](<./technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md>):独立 crate、feature 开关、目标校验与 Windows 注入边界。 - [AGC Cocos Creator 编辑器桥接模块](<./technical/【技术方案】AGC Cocos Creator 编辑器桥接模块-2026-09-09.md>):独立 crate、feature 开关、目标校验与 Windows 注入边界。
- [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、OSS 清单格式和下载约定 - [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、固定 dev 服务、OSS 清单与官网最新客户端下载
- [AGC 模板库与模板建项](./technical/【技术方案】AGC模板库与模板建项-2026-09-17.md)`templates/` 前缀的模板库契约、下载安装与「用模板建项目」链路。 - [AGC 模板库与模板建项](./technical/【技术方案】AGC模板库与模板建项-2026-09-17.md)`templates/` 前缀的模板库契约、下载安装与「用模板建项目」链路。
- [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。 - [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。
- [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md)Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。 - [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md)Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。
@@ -22,6 +22,8 @@
- AGC 批量追加素材标签由原生在一次项目写锁与 revision CAS 下合并各项原标签,先校验全批再写 manifest;前端不能循环单素材分类命令,不回传展示层推导的分类或旧标签全集,以免部分写入或覆盖未编辑字段。 - AGC 批量追加素材标签由原生在一次项目写锁与 revision CAS 下合并各项原标签,先校验全批再写 manifest;前端不能循环单素材分类命令,不回传展示层推导的分类或旧标签全集,以免部分写入或覆盖未编辑字段。
- AGC 平台服务固定为 `https://dev.genarrative.world`,会话凭据按 origin 隔离。官网通过同源公开 `/api/client-downloads` 汇总 Windows/Mac 渠道的首装 `downloads`,按真实平台/架构显示;未发布隐藏,单渠道失败不影响其它下载。发布先上传 EXE/DMG 再写本渠道清单,不维护会互相覆盖的共享 OSS 索引。主站 Vite 代理复用实际 `runtimeServerTarget`,浏览器不直接跨域读取 OSS 清单。完整约定见 AGC 客户端更新检查与下载专题。
- 画布卡片类型与信息角标共用 `CanvasCardCornerActions`;菜单收纳共用 `OverflowActions`,宿主决定展示数量和资源命令。AGC 选中菜单前 5 项直显,Web 默认不折叠;浮层 portal 继续接入现有画布关闭与滚轮归属判据。 - 画布卡片类型与信息角标共用 `CanvasCardCornerActions`;菜单收纳共用 `OverflowActions`,宿主决定展示数量和资源命令。AGC 选中菜单前 5 项直显,Web 默认不折叠;浮层 portal 继续接入现有画布关闭与滚轮归属判据。
- 修改范围保持聚焦;优先扩展现有系统、页面、组件、DTO 和脚本,不新建平行入口或业务真相。 - 修改范围保持聚焦;优先扩展现有系统、页面、组件、DTO 和脚本,不新建平行入口或业务真相。
- UI 开发优先复用现有公共组件;跨页面或跨端重复的视觉/交互模式应沉淀到 `packages/shared`,由现有页面迁移使用,禁止在业务页复制同类 UI。共享组件只承载通用表现与交互,不下沉领域规则、后端副作用或正式业务状态。 - UI 开发优先复用现有公共组件;跨页面或跨端重复的视觉/交互模式应沉淀到 `packages/shared`,由现有页面迁移使用,禁止在业务页复制同类 UI。共享组件只承载通用表现与交互,不下沉领域规则、后端副作用或正式业务状态。
@@ -1,6 +1,6 @@
# AGC 客户端更新检查与下载 # AGC 客户端更新检查与下载
更新时间:`2026-09-17` 更新时间:`2026-09-19`
本文件是 AGC 客户端自动更新的主规范:更新能力由 Tauri 官方插件 `tauri-plugin-updater` 承担,并按下文渠道分发。 本文件是 AGC 客户端自动更新的主规范:更新能力由 Tauri 官方插件 `tauri-plugin-updater` 承担,并按下文渠道分发。
@@ -30,6 +30,18 @@
## 必须成立的行为 ## 必须成立的行为
### 官网下载与客户端服务地址
- 官网首页提供无需登录的「下载客户端」入口,桌面和移动视口均可访问;入口打开独立下载面板,复用平台按钮、弹窗和状态组件。
- 每次打开下载面板请求同源公开 `GET /api/client-downloads`,后端并行读取固定 `dev-win/latest.json``dev-mac/latest.json` 并汇总已发布平台,网页和接口均禁用缓存。平台列表与每项版本完全来自清单;不在网页写死版本或猜测文件名。
- 接口返回 `{ downloads: [{ platform, architecture, version, downloadUrl }], unavailablePlatforms: [] }`,平台为 `windows` / `macos`,架构为 `x86_64` / `aarch64`Windows 仅支持 x86_64Mac 按清单实际提供的架构展示 Apple Silicon / Intel 下载项。DTO 由 `shared-contracts``packages/shared` 对齐,不接受请求参数指定上游 URL,不触及 SpacetimeDB。
- 渠道清单新增可选 `downloads` 字典,键与 updater 平台键一致,值为 `{ url }`,只登记首装包。Windows `.exe` 可同时用于首装和更新;macOS 首装必须为 `.dmg`,不得将 `.app.tar.gz` 当首装包。已发布且没有 `downloads` 字段的 Windows 清单可读取既有 `platforms.windows-x86_64.url`;Mac 没有首装元数据时隐藏,不推导 DMG 地址。
- 渠道 404 视为尚未发布并隐藏该平台;两端均未发布时显示空状态。单渠道请求失败、超时或格式非法时保留另一端有效下载项,同时提示部分平台暂不可用并允许重试;没有任何有效项且存在失败时返回可读 502。关闭面板取消请求,迟到响应不能覆盖下一次打开的状态。
- 每个上游请求总超时 10 秒、响应体上限 128 KiB、不跟随重定向、不附加用户凭据;返回的链接仅接受固定 OSS 来源、对应 `/agc/<channel>/<version>/` 下的 HTTPS `.exe` / `.dmg` 对象,架构键必须属于该渠道。不提供陈旧、未知来源或版本不匹配的下载地址,不泄露上游正文。
- AGC 开发态和正式包的平台服务地址统一固定为 `https://dev.genarrative.world`;登录页不提供服务器选择或自定义地址。旧的服务器偏好不能覆盖固定地址;已有会话仍按 origin 隔离,不能将其他服务的凭据迁往 dev。自定义 LLM 配置不属于平台服务器选择。
- access token 与 origin 一起保存;已有 dev origin 的 token 保留。没有 origin 的旧 token 一律清除,因为旧版可以单独修改服务器偏好,偏好不能证明 token 来源。随后仅使用 dev 自己的 refresh cookie 恢复或重新登录;原生会话回写同样绑定 dev origin。
- 验收覆盖固定 dev 的登录/会话与请求行为、旧服务器偏好、首页入口挂载、动态最新版本链接、清单失败与重试、关闭取消、桌面和移动布局,以及公开清单和安装包的真实可读性。
### 正常路径 ### 正常路径
- 正式包启动时检查一次渠道清单;仅当清单版本高于当前版本时显示更新提示,提示包含目标版本与发布说明。 - 正式包启动时检查一次渠道清单;仅当清单版本高于当前版本时显示更新提示,提示包含目标版本与发布说明。
@@ -66,6 +78,9 @@
"signature": "<.sig 文件内容>", "signature": "<.sig 文件内容>",
"url": "https://<oss>/agc/dev-win/0.1.48/<安装包文件名>" "url": "https://<oss>/agc/dev-win/0.1.48/<安装包文件名>"
} }
},
"downloads": {
"windows-x86_64": { "url": "https://<oss>/agc/dev-win/0.1.48/<安装包文件名>" }
} }
} }
``` ```
@@ -96,11 +111,26 @@
- 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt``AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。锚点仍不可得(清单读取失败或没有 CI 锚点)时降级为「最近客户端改动」列表并注明可能与上一版重复 —— 摘要属于附注,任何情况下都不允许因为它让发布失败。 - 更新摘要自动生成:发布脚本用渠道清单里的 `commit` 字段(上一次发布的提交)到本次提交之间、且只覆盖客户端相关路径的提交列表生成 `notes`(每条 `- 提交标题(短 SHA`,最多 12 条、主题 80 字、整体 900 字,超出折叠或截断),同时写入旧协议清单的 `releaseNotes` 和归档文件 `release-notes.txt``AGC_UPDATE_RELEASE_NOTES` 非空时以手动文案为准;无法判定起点(缺少上次 `commit` 或本地没有该提交)时不写摘要。清单缺少 `commit` 时回退用上一次成功构建的 `COMMIT_HASH`CI 通过 `AGC_UPDATE_PREVIOUS_COMMIT` 传入)作为锚点,因此首次启用摘要或更换渠道后也能立即产出摘要。锚点仍不可得(清单读取失败或没有 CI 锚点)时降级为「最近客户端改动」列表并注明可能与上一版重复 —— 摘要属于附注,任何情况下都不允许因为它让发布失败。
- 清单里的 `commit` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。 - 清单里的 `commit` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。
- 上传:安装包与 `.sig` 上传到 `agc/<channel>/<version>/`,清单以 `--force` 覆盖上传到 `agc/<channel>/latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。 - 上传:安装包与 `.sig` 上传到 `agc/<channel>/<version>/`,清单以 `--force` 覆盖上传到 `agc/<channel>/latest.json`,保证 latest 指针与清单内 URL 指向已存在的对象。
- 首装发布:发布脚本生成 `downloads`Windows 复用已选 NSIS `.exe`,Mac 选择本次版本和目标架构匹配的非空 `.dmg`;缺失、歧义或版本/架构不匹配时失败,不发布带悬空地址的清单。上传顺序为更新包、签名及首装包全部成功后再更新渠道清单,Windows 相同对象只上传一次。`dry-run` 不写 OSS。各渠道独立写自己的清单,由 BFF 汇总,Windows 与 Mac 发布不会覆盖彼此的下载项;Mac 跨架构合并仍遵循现有单架构发布约束。
- Jenkins 流水线需要新增渠道参数与签名凭据;签名私钥与密码只以受保护凭据注入当前进程,不写入 workspace、日志或归档产物。 - Jenkins 流水线需要新增渠道参数与签名凭据;签名私钥与密码只以受保护凭据注入当前进程,不写入 workspace、日志或归档产物。
- 归档证据:安装包、`.sig`、渠道清单与源码 commit。 - 归档证据:安装包、`.sig`、渠道清单与源码 commit。
## 验收标准与证据 ## 验收标准与证据
官网下载与固定服务地址已于 `2026-09-19` 完成源码验收:
| 条款 | 验收方式 | 结果 |
| --- | --- | --- |
| 固定 dev、旧凭据来源隔离与登录恢复 | HTTP/API/存储定向测试、登录会话界面测试、AGC 类型检查 | 通过;无 origin token 不发送,dev cookie 恢复链保留 |
| 动态链接、失败重试、取消与重开竞态 | 下载组件与站点壳定向 Vitest、Web 类型检查 | 通过;桌面与移动首页均挂载入口 |
| 已发布平台自动出现与各平台独立版本 | 多平台聚合测试、组件测试及桌面/320px 浏览器受控清单 | 通过;未发布隐藏,重新打开后展示 Windows、Apple Silicon、Intel 的对应版本与链接,部分失败仍可下载有效项 |
| 首装元数据、DMG 选择与上传顺序 | 发布脚本 39 项临时夹具测试 | 通过;限定当版/当架构唯一非空 DMG,更新包/签名/首装包先于 latest,失败不更新指针,dry-run 不执行上传 |
| 清单传输与下载地址边界 | platform-oss 12 项与 api-server 3 项 `client_downloads` 定向 Cargo 测试 | 通过;含双渠道并行、部分失败、残缺 Windows 清单、响应体超时、大小上限、重定向与非法来源 |
| 官网同源完整链路 | 标准本地后端 `/healthz`、匿名 `/api/client-downloads`、Vite 同源代理与真实浏览器 | 健康检查 200,下载接口 200/no-store;真实列表保留 Windows 0.1.73、隐藏未发布 Mac;桌面和窄屏可操作 |
| 发布对象可读取 | 公开清单、安装包 HEAD 与 Range | 当日 Windows 0.1.73 清单 200,安装包 104867522 字节、Range 206 且为 EXEmacOS 清单 404,不展示下载 |
本次未构建或安装新客户端包,未上传或部署官网;Mac 浏览器证据使用受控清单,不能替代 Mac 原生构建、签名与安装验证。真实安装升级及生产部署后的 smoke 属于发布验收。
已获得的证据: 已获得的证据:
| 条款 | 验收方式 | 证据 | | 条款 | 验收方式 | 证据 |
@@ -69,6 +69,10 @@ Rust 侧在 `server-rs/crates/shared-contracts` 维护唯一权威 `GameCreation
- 当前素材名以现有正式命名链路为准:生成时 assetName 参与落盘名称,重命名更新文件名;卡片消费正式资源 label,不从临时输入或历史任务名覆盖后续重命名,不新增平行显示名持久化。若原有命名链路丢失 assetName,则修复原链路,而非只在卡片本地伪造。文档卡不显示任何正文摘要,但详情原文与 JSON 识别读取不变。 - 当前素材名以现有正式命名链路为准:生成时 assetName 参与落盘名称,重命名更新文件名;卡片消费正式资源 label,不从临时输入或历史任务名覆盖后续重命名,不新增平行显示名持久化。若原有命名链路丢失 assetName,则修复原链路,而非只在卡片本地伪造。文档卡不显示任何正文摘要,但详情原文与 JSON 识别读取不变。
- 验收覆盖空素材项目进入工具、真实引用入参、成功/失败/重试与迟到响应、占位移动后落点、全类型名称、文档详情、当前栏目重排/撤销、不同缩放的多选移动/撤销以及其他栏目不变。自动化、真实客户端和真实 Provider 验证分别报告;未实际运行的路径不得标为通过。 - 验收覆盖空素材项目进入工具、真实引用入参、成功/失败/重试与迟到响应、占位移动后落点、全类型名称、文档详情、当前栏目重排/撤销、不同缩放的多选移动/撤销以及其他栏目不变。自动化、真实客户端和真实 Provider 验证分别报告;未实际运行的路径不得标为通过。
## 平台服务与官网分发
客户端平台服务固定为 dev,登录页不提供服务器选择。凭据按 origin 隔离迁移;官网下载入口汇总最新渠道清单,自动展示已有首装包的 Windows/Mac 平台及架构。完整合同见 [AGC 客户端更新检查与下载](./【技术方案】AGC客户端更新检查与下载-2026-08-31.md) 的“官网下载与客户端服务地址”。
## 策划 Agent 批量局部修改 ## 策划 Agent 批量局部修改
`patch_file` 的所有 edits 均匹配同一份原文件,参数顺序不影响结果。完成唯一匹配与不重叠校验后,按原文起点升序拼接未修改片段与替换文本,最后一次性写入;任一校验失败时不写文件。回归用例覆盖乱序 edits、中文内容与替换长度增减,并核对完整落盘内容。此行为仅属于策划 Agent 文件工具。 `patch_file` 的所有 edits 均匹配同一份原文件,参数顺序不影响结果。完成唯一匹配与不重叠校验后,按原文起点升序拼接未修改片段与替换文本,最后一次性写入;任一校验失败时不写文件。回归用例覆盖乱序 edits、中文内容与替换长度增减,并核对完整落盘内容。此行为仅属于策划 Agent 文件工具。
@@ -1,5 +1,9 @@
# 本地开发验证与生产运维 # 本地开发验证与生产运维
## 官网客户端下载联调
主站 Vite 将 `/api/client-downloads` 转发到当前 `runtimeServerTarget`。通过 `npm run dev:api-server``npm run dev:web` 联调时,先确认运行日志与 `.app/dev-stack.json` 的实际 API 地址,检查 `/healthz`,再从 Vite 同源访问 `/api/client-downloads`;正常应返回 `downloads` 平台列表、`unavailablePlatforms``Cache-Control: no-store`,不能落入 SPA HTML fallback。渠道 404 表示未发布,单端失败只影响对应平台;公网 OSS 清单没有官网 CORS,浏览器不直接读取该清单。
## 构建回归的隔离与发布文件权限 ## 构建回归的隔离与发布文件权限
Git hook 的临时仓库测试必须清除子进程继承的仓库定位环境(例如 `GIT_DIR``GIT_WORK_TREE``GIT_INDEX_FILE`);仅设置 `cwd` 不能隔离 Git。回归应从带这些变量的外层仓库运行,验证外层引用、索引与配置不变。临时测试文件必须留在独立目录并清理,不得通过测试生成主仓库提交或覆盖 ESLint、Prettier 配置。修复 lint 配置时保留原有规则、忽略范围与零警告门禁,不以关闭规则代替排障。 Git hook 的临时仓库测试必须清除子进程继承的仓库定位环境(例如 `GIT_DIR``GIT_WORK_TREE``GIT_INDEX_FILE`);仅设置 `cwd` 不能隔离 Git。回归应从带这些变量的外层仓库运行,验证外层引用、索引与配置不变。临时测试文件必须留在独立目录并清理,不得通过测试生成主仓库提交或覆盖 ESLint、Prettier 配置。修复 lint 配置时保留原有规则、忽略范围与零警告门禁,不以关闭规则代替排障。
@@ -0,0 +1,14 @@
export interface ClientDownloadResponse {
downloads: ClientDownload[];
unavailablePlatforms: ClientDownloadPlatform[];
}
export type ClientDownloadPlatform = 'windows' | 'macos';
export type ClientDownloadArchitecture = 'x86_64' | 'aarch64';
export interface ClientDownload {
platform: ClientDownloadPlatform;
architecture: ClientDownloadArchitecture;
version: string;
downloadUrl: string;
}
+1
View File
@@ -1,5 +1,6 @@
export * from './components'; export * from './components';
export * from './contracts/auth'; export * from './contracts/auth';
export type * from './contracts/clientDownload';
export * from './contracts/common'; export * from './contracts/common';
export type * from './contracts/editorAudio'; export type * from './contracts/editorAudio';
export * from './contracts/editorScene'; export * from './contracts/editorScene';
+1
View File
@@ -44,6 +44,7 @@ pub fn build_router(state: AppState) -> Router {
.merge(modules::health::router(state.clone())) .merge(modules::health::router(state.clone()))
.merge(modules::internal::router(state.clone())) .merge(modules::internal::router(state.clone()))
.merge(modules::auth::router(state.clone())) .merge(modules::auth::router(state.clone()))
.merge(modules::client_downloads::router(state.clone()))
.merge(modules::profile::router(state.clone())) .merge(modules::profile::router(state.clone()))
.merge(modules::external_api::router(state.clone())) .merge(modules::external_api::router(state.clone()))
.merge(modules::frontend_runtime_config::router(state.clone())) .merge(modules::frontend_runtime_config::router(state.clone()))
@@ -1,6 +1,7 @@
pub mod admin; pub mod admin;
pub mod assets; pub mod assets;
pub mod auth; pub mod auth;
pub mod client_downloads;
pub mod editor_project; pub mod editor_project;
pub mod external_api; pub mod external_api;
pub mod external_generation; pub mod external_generation;
@@ -0,0 +1,173 @@
use axum::{
Json, Router,
http::{HeaderValue, StatusCode, header::CACHE_CONTROL},
response::{IntoResponse, Response},
routing::get,
};
use platform_oss::client_downloads::{
ClientDownloadError, ClientDownloads, DownloadArchitecture, DownloadPlatform,
fetch_latest_client_downloads,
};
use shared_contracts::client_downloads::{
ClientDownload, ClientDownloadArchitecture, ClientDownloadPlatform, ClientDownloadResponse,
};
use crate::{http_error::AppError, state::AppState};
pub fn router(_state: AppState) -> Router<AppState> {
Router::new().route("/api/client-downloads", get(client_downloads))
}
async fn client_downloads() -> Response {
download_response(fetch_latest_client_downloads().await)
}
fn download_response(result: Result<ClientDownloads, ClientDownloadError>) -> Response {
let mut response = match result {
Ok(result) => Json(ClientDownloadResponse {
downloads: result
.downloads
.into_iter()
.map(|download| ClientDownload {
platform: map_platform(download.platform),
architecture: match download.architecture {
DownloadArchitecture::X86_64 => ClientDownloadArchitecture::X86_64,
DownloadArchitecture::Aarch64 => ClientDownloadArchitecture::Aarch64,
},
version: download.version,
download_url: download.download_url,
})
.collect(),
unavailable_platforms: result
.unavailable_platforms
.into_iter()
.map(map_platform)
.collect(),
})
.into_response(),
Err(_) => AppError::from_status(StatusCode::BAD_GATEWAY)
.with_message("暂时无法获取最新客户端,请稍后重试")
.into_response(),
};
response
.headers_mut()
.insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
response
}
fn map_platform(platform: DownloadPlatform) -> ClientDownloadPlatform {
match platform {
DownloadPlatform::Windows => ClientDownloadPlatform::Windows,
DownloadPlatform::Macos => ClientDownloadPlatform::Macos,
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::body::to_bytes;
use platform_oss::client_downloads::ClientDownload as SourceDownload;
use serde_json::{Value, json};
async fn read_success(result: ClientDownloads) -> Value {
let response = download_response(Ok(result));
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(response.headers()[CACHE_CONTROL], "no-store");
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.unwrap()).unwrap()
}
#[tokio::test]
async fn download_response_preserves_platform_architecture_and_each_version() {
let body = read_success(ClientDownloads {
downloads: vec![
SourceDownload {
platform: DownloadPlatform::Windows,
architecture: DownloadArchitecture::X86_64,
version: "0.1.73".to_string(),
download_url: "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/0.1.73/app.exe".to_string(),
},
SourceDownload {
platform: DownloadPlatform::Macos,
architecture: DownloadArchitecture::Aarch64,
version: "0.2.0".to_string(),
download_url: "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/0.2.0/app_aarch64.dmg".to_string(),
},
SourceDownload {
platform: DownloadPlatform::Macos,
architecture: DownloadArchitecture::X86_64,
version: "0.2.0".to_string(),
download_url: "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/0.2.0/app_x86_64.dmg".to_string(),
},
],
unavailable_platforms: Vec::new(),
}).await;
assert_eq!(
body,
json!({
"downloads": [
{
"platform": "windows", "architecture": "x86_64", "version": "0.1.73",
"downloadUrl": "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/0.1.73/app.exe"
},
{
"platform": "macos", "architecture": "aarch64", "version": "0.2.0",
"downloadUrl": "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/0.2.0/app_aarch64.dmg"
},
{
"platform": "macos", "architecture": "x86_64", "version": "0.2.0",
"downloadUrl": "https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-mac/0.2.0/app_x86_64.dmg"
}
],
"unavailablePlatforms": []
})
);
}
#[tokio::test]
async fn partial_success_and_unpublished_empty_state_keep_the_public_contract() {
let body = read_success(ClientDownloads {
downloads: vec![SourceDownload {
platform: DownloadPlatform::Windows,
architecture: DownloadArchitecture::X86_64,
version: "0.1.73".to_string(),
download_url:
"https://agc-dev.oss-rg-china-mainland.aliyuncs.com/agc/dev-win/0.1.73/app.exe"
.to_string(),
}],
unavailable_platforms: vec![DownloadPlatform::Macos],
})
.await;
assert_eq!(body["downloads"].as_array().unwrap().len(), 1);
assert_eq!(body["unavailablePlatforms"], json!(["macos"]));
let body = read_success(ClientDownloads {
downloads: Vec::new(),
unavailable_platforms: Vec::new(),
})
.await;
assert_eq!(body, json!({ "downloads": [], "unavailablePlatforms": [] }));
}
#[tokio::test]
async fn upstream_failures_share_a_safe_retryable_uncached_error() {
for error in [
ClientDownloadError::Transport,
ClientDownloadError::UpstreamStatus,
ClientDownloadError::ManifestTooLarge,
ClientDownloadError::InvalidManifest,
] {
let response = download_response(Err(error));
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
assert_eq!(response.headers()[CACHE_CONTROL], "no-store");
let body: Value =
serde_json::from_slice(&to_bytes(response.into_body(), 4096).await.unwrap())
.unwrap();
assert_eq!(
body["error"]["message"],
"暂时无法获取最新客户端,请稍后重试"
);
assert_eq!(body["error"]["code"], "UPSTREAM_ERROR");
assert!(body["error"]["details"].is_null());
}
}
}
+1 -1
View File
@@ -13,7 +13,7 @@ serde = { workspace = true }
serde_json = { workspace = true } serde_json = { workspace = true }
sha2 = { workspace = true } sha2 = { workspace = true }
time = { workspace = true, features = ["formatting"] } time = { workspace = true, features = ["formatting"] }
tokio = { workspace = true, features = ["sync", "time"] } tokio = { workspace = true, features = ["macros", "sync", "time"] }
tracing = { workspace = true } tracing = { workspace = true }
[dev-dependencies] [dev-dependencies]

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