固定客户端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
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首装元数据和上传顺序校验 同步定向测试与下载发布规范
This commit is contained in:
@@ -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) {
|
||||
const signaturePath = `${artifactPath}.sig`;
|
||||
if (!fs.existsSync(signaturePath)) {
|
||||
@@ -521,23 +556,32 @@ export function createUpdateManifest(
|
||||
publishedAt = new Date().toISOString(),
|
||||
notes = readReleaseNotes(),
|
||||
commit = readHeadCommit(),
|
||||
downloadArtifact,
|
||||
} = {},
|
||||
) {
|
||||
validateReleaseTarget(target);
|
||||
resolveReleaseChannel({ AGC_UPDATE_CHANNEL: channel }, target);
|
||||
const signature = readUpdaterSignature(artifactPath);
|
||||
const version = readPackageJson().version;
|
||||
const firstInstallArtifact = selectFirstInstallArtifact(
|
||||
downloadArtifact ? [downloadArtifact] : [],
|
||||
{ target, version, artifact: artifactPath },
|
||||
);
|
||||
const fileName = path.basename(artifactPath);
|
||||
const url = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(fileName)}`;
|
||||
const downloadUrl = `${ossBaseUrl()}/${channel}/${encodeURIComponent(version)}/${encodeURIComponent(path.basename(firstInstallArtifact))}`;
|
||||
const platforms = {};
|
||||
const downloads = {};
|
||||
for (const key of resolveManifestPlatformKeys(target)) {
|
||||
platforms[key] = { signature, url };
|
||||
downloads[key] = { url: downloadUrl };
|
||||
}
|
||||
return {
|
||||
version,
|
||||
...(notes ? { notes } : {}),
|
||||
pub_date: publishedAt,
|
||||
platforms,
|
||||
downloads,
|
||||
// 非标准字段:更新插件会忽略,发布脚本用它定位下一次自动更新摘要的起点。
|
||||
...(commit ? { commit } : {}),
|
||||
};
|
||||
@@ -676,10 +720,16 @@ export async function generateUpdateManifest(
|
||||
context = resolveReleaseContext(),
|
||||
) {
|
||||
const { channel, target, bundleRoot } = context;
|
||||
const artifact = selectReleaseArtifact(listFiles(bundleRoot), target);
|
||||
const files = listFiles(bundleRoot);
|
||||
const artifact = selectReleaseArtifact(files, target);
|
||||
if (!artifact) {
|
||||
throw new Error(`未找到可发布的 AGC 安装包:${bundleRoot}`);
|
||||
}
|
||||
const downloadArtifact = selectFirstInstallArtifact(files, {
|
||||
target,
|
||||
version: readPackageJson().version,
|
||||
artifact,
|
||||
});
|
||||
const manualNotes = readReleaseNotes();
|
||||
const previousCommit = await resolvePreviousReleaseCommit(channel);
|
||||
const commits = collectReleaseCommits(previousCommit);
|
||||
@@ -693,7 +743,12 @@ export async function generateUpdateManifest(
|
||||
`[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');
|
||||
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
||||
const notesPath = path.join(bundleRoot, 'release-notes.txt');
|
||||
@@ -718,6 +773,7 @@ export async function generateUpdateManifest(
|
||||
`[ai-game-creator-shell] 渠道 ${channel}:已生成 ${manifestPath}`,
|
||||
);
|
||||
console.log(`[ai-game-creator-shell] 安装包:${artifact}`);
|
||||
console.log(`[ai-game-creator-shell] 首装包:${downloadArtifact}`);
|
||||
console.log(
|
||||
manualNotes
|
||||
? '[ai-game-creator-shell] 更新摘要:使用 AGC_UPDATE_RELEASE_NOTES 手动文案'
|
||||
@@ -734,6 +790,7 @@ export async function generateUpdateManifest(
|
||||
return {
|
||||
channel,
|
||||
artifact,
|
||||
downloadArtifact,
|
||||
manifest,
|
||||
manifestPath,
|
||||
notes,
|
||||
|
||||
@@ -32,12 +32,23 @@ import {
|
||||
resolveReleaseContext,
|
||||
resolveRemoteHighWaterVersion,
|
||||
runTauriBuild,
|
||||
selectFirstInstallArtifact,
|
||||
selectReleaseArtifact,
|
||||
updateManifestUrl,
|
||||
} from './build-release.mjs';
|
||||
|
||||
const windowsTarget = 'x86_64-pc-windows-msvc';
|
||||
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', () => {
|
||||
assert.throws(() => buildTauriBuildArguments([], universalTarget), /单架构/);
|
||||
@@ -254,7 +265,13 @@ test('explicit macOS target drives version lookup, Tauri endpoint, artifact and
|
||||
),
|
||||
artifact,
|
||||
);
|
||||
const manifest = createUpdateManifest(artifact, context);
|
||||
const manifest = createUpdateManifest(artifact, {
|
||||
...context,
|
||||
downloadArtifact: createDmgFixture(
|
||||
path.dirname(artifact),
|
||||
context.target,
|
||||
),
|
||||
});
|
||||
assert.deepEqual(Object.keys(manifest.platforms), [
|
||||
'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]));
|
||||
});
|
||||
|
||||
test('real manifest writer uses the resolved bundle root and does not emit Windows artifacts', async () => {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-mac-manifest-'));
|
||||
for (const target of ['aarch64-apple-darwin', 'x86_64-apple-darwin']) {
|
||||
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 {
|
||||
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');
|
||||
writeFileSync(artifact, 'updater archive');
|
||||
writeFileSync(`${artifact}.sig`, 'signature');
|
||||
const context = {
|
||||
...resolveReleaseContext(['--target=x86_64-apple-darwin'], {}),
|
||||
...resolveReleaseContext(['--target=aarch64-apple-darwin'], {}),
|
||||
bundleRoot: root,
|
||||
};
|
||||
const result = await withStubbedFetch(
|
||||
(url) => {
|
||||
assert.match(url, /\/dev-mac\/latest\.json$/);
|
||||
return jsonResponse({}, 404);
|
||||
},
|
||||
await assert.rejects(
|
||||
() => generateUpdateManifest(context),
|
||||
/首装 DMG 必须唯一匹配/u,
|
||||
);
|
||||
assert.equal(result.artifact, artifact);
|
||||
assert.equal(result.manifestPath, path.join(root, 'latest.json'));
|
||||
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\//);
|
||||
assert.throws(() => readFileSync(path.join(root, 'latest.json')), {
|
||||
code: 'ENOENT',
|
||||
});
|
||||
} finally {
|
||||
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.pub_date, '2026-09-17T00:00:00.000Z');
|
||||
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(
|
||||
manifest.platforms['windows-x86_64'].signature,
|
||||
'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(
|
||||
new URL('./release-upload.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.equal(
|
||||
(source.match(/runOssutil\(\[\s*'cp',\s*'--force'/gu) ?? []).length,
|
||||
4,
|
||||
assert.match(
|
||||
source,
|
||||
/const release = await buildRelease\(process\.argv\.slice\(2\)\)/u,
|
||||
);
|
||||
assert.match(source, /agc\/\$\{channel\}\/latest\.json/u);
|
||||
assert.match(source, /agc\/latest\.json/u);
|
||||
assert.match(source, /await buildRelease\(process\.argv\.slice\(2\)\)/u);
|
||||
assert.match(source, /uploadReleaseArtifacts\(release, \{/u);
|
||||
assert.match(source, /const dryRun = readReleaseDryRun\(\);/u);
|
||||
assert.ok(source.includes('\n dryRun,\n'));
|
||||
});
|
||||
|
||||
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 参数与凭据整理成可执行或可打印的形式,
|
||||
* 便于在 dry-run 下核对将要执行的上传,同时保证任何输出都不回显凭据明文。
|
||||
@@ -25,3 +28,89 @@ export function formatOssutilCommand({ binary, args, endpoint, credentials }) {
|
||||
}
|
||||
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 { 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 { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs';
|
||||
import {
|
||||
createReleaseUploadPlan,
|
||||
formatOssutilCommand,
|
||||
readReleaseDryRun,
|
||||
uploadReleaseArtifacts,
|
||||
} from './release-oss.mjs';
|
||||
|
||||
test('dry run only accepts explicit truthy values', () => {
|
||||
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', () => {
|
||||
const source = readFileSync(
|
||||
new URL('./release-upload.mjs', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
assert.match(source, /const dryRun = readReleaseDryRun\(\);/u);
|
||||
assert.match(source, /if \(dryRun\) \{/u);
|
||||
assert.match(source, /dry-run:未写入任何 OSS 对象/u);
|
||||
function withReleaseFixture(channel, architecture, run) {
|
||||
const root = mkdtempSync(path.join(os.tmpdir(), 'agc-upload-plan-'));
|
||||
try {
|
||||
const artifact = path.join(
|
||||
root,
|
||||
channel === 'dev-win'
|
||||
? '陶泥儿_1.2.3_x64-setup.exe'
|
||||
: '陶泥儿.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 path from 'node:path';
|
||||
|
||||
import { formatOssutilCommand, readReleaseDryRun } from './release-oss.mjs';
|
||||
import { readReleaseDryRun, uploadReleaseArtifacts } from './release-oss.mjs';
|
||||
|
||||
const bucket = process.env.AGC_OSS_BUCKET?.trim() || 'agc-dev';
|
||||
const endpoint =
|
||||
@@ -14,76 +11,12 @@ const dryRun = readReleaseDryRun();
|
||||
|
||||
const { buildRelease } = await import('./build-release.mjs');
|
||||
|
||||
function runOssutil(args) {
|
||||
const binary = process.env.OSSUTIL_BIN?.trim() || 'ossutil';
|
||||
const accessKeyId = process.env.AGC_OSS_ACCESS_KEY_ID?.trim();
|
||||
const accessKeySecret = process.env.AGC_OSS_ACCESS_KEY_SECRET;
|
||||
if (Boolean(accessKeyId) !== Boolean(accessKeySecret)) {
|
||||
throw new Error('OSS AccessKey ID 和 Secret 必须同时提供');
|
||||
}
|
||||
if (dryRun) {
|
||||
// 演练:只打印将要执行的上传,凭据以占位符呈现,不写入 OSS。
|
||||
console.log(
|
||||
`[dry-run] ${formatOssutilCommand({
|
||||
binary,
|
||||
args,
|
||||
endpoint,
|
||||
credentials: Boolean(accessKeyId),
|
||||
})}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
const credentialArgs = accessKeyId
|
||||
? ['--access-key-id', accessKeyId, '--access-key-secret', accessKeySecret]
|
||||
: [];
|
||||
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 对象');
|
||||
}
|
||||
const release = await buildRelease(process.argv.slice(2));
|
||||
uploadReleaseArtifacts(release, {
|
||||
bucket,
|
||||
endpoint,
|
||||
binary: process.env.OSSUTIL_BIN?.trim() || 'ossutil',
|
||||
accessKeyId: process.env.AGC_OSS_ACCESS_KEY_ID?.trim(),
|
||||
accessKeySecret: process.env.AGC_OSS_ACCESS_KEY_SECRET,
|
||||
dryRun,
|
||||
});
|
||||
|
||||
@@ -23,14 +23,7 @@ import {
|
||||
normalizeAuthPhoneInput,
|
||||
sendClientPhoneLoginCode,
|
||||
} from '../services/clientAuth';
|
||||
import {
|
||||
type ClientServerPreset,
|
||||
type ClientServerSelection,
|
||||
getClientServerBaseUrl,
|
||||
getClientServerSelection,
|
||||
normalizeClientServerBaseUrl,
|
||||
setClientServerSelection,
|
||||
} from '../services/clientHttp';
|
||||
import { getClientServerBaseUrl } from '../services/clientHttp';
|
||||
import {
|
||||
captureClientError,
|
||||
installWebviewLogBridge,
|
||||
@@ -158,13 +151,6 @@ export function AuthenticatedClient({
|
||||
const [loginBusy, setLoginBusy] = useState(false);
|
||||
const [codeBusy, setCodeBusy] = useState(false);
|
||||
const [codeCooldownSeconds, setCodeCooldownSeconds] = useState(0);
|
||||
const initialServerSelection = getClientServerSelection();
|
||||
const [serverSelection, setServerSelection] = useState<ClientServerSelection>(
|
||||
initialServerSelection,
|
||||
);
|
||||
const [customServerUrl, setCustomServerUrl] = useState(
|
||||
initialServerSelection.customBaseUrl,
|
||||
);
|
||||
useEffect(() => {
|
||||
const uninstallWebviewLogBridge = installWebviewLogBridge();
|
||||
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(() => {
|
||||
let disposed = false;
|
||||
async function hydrateAuth() {
|
||||
@@ -430,11 +385,7 @@ export function AuthenticatedClient({
|
||||
if (codeBusy || codeCooldownSeconds > 0) {
|
||||
return;
|
||||
}
|
||||
const persistedSelection = persistServerSelection();
|
||||
if (!persistedSelection) {
|
||||
return;
|
||||
}
|
||||
const apiBaseUrl = getClientServerBaseUrl(persistedSelection);
|
||||
const apiBaseUrl = getClientServerBaseUrl();
|
||||
const normalizedPhone = normalizeAuthPhoneInput(phone);
|
||||
if (!normalizedPhone) {
|
||||
setLoginStatus('请输入手机号');
|
||||
@@ -479,11 +430,7 @@ export function AuthenticatedClient({
|
||||
setLoginStatus('请输入密码');
|
||||
return;
|
||||
}
|
||||
const persistedSelection = persistServerSelection();
|
||||
if (!persistedSelection) {
|
||||
return;
|
||||
}
|
||||
const loginApiBaseUrl = getClientServerBaseUrl(persistedSelection);
|
||||
const loginApiBaseUrl = getClientServerBaseUrl();
|
||||
const loginAttempt = (loginAttemptRef.current += 1);
|
||||
setLoginBusy(true);
|
||||
setLoginStatus('正在登录');
|
||||
@@ -635,50 +582,6 @@ export function AuthenticatedClient({
|
||||
</button>
|
||||
</div>
|
||||
) : 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="登录方式">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
RedeemProfileRewardCodeResponse,
|
||||
unwrapApiResponse,
|
||||
} from '../../../../packages/shared/src';
|
||||
import { getStoredAuthAccessToken } from './clientAuth';
|
||||
import { fetchClientHttp, readClientHttpResponseText } from './clientHttp';
|
||||
import { captureClientError } from './errorReporting';
|
||||
import {
|
||||
@@ -16,7 +17,11 @@ import {
|
||||
requestPlatformSessionRefresh,
|
||||
} from './platformSession';
|
||||
|
||||
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
|
||||
export {
|
||||
clearStoredAuthAccessToken,
|
||||
getStoredAuthAccessToken,
|
||||
setStoredAuthAccessToken,
|
||||
} from './clientAuth';
|
||||
|
||||
export class ClientAuthRequestError extends Error {
|
||||
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(
|
||||
response: Response,
|
||||
fallback: string,
|
||||
|
||||
@@ -29,6 +29,10 @@ import {
|
||||
} from './clientOperation';
|
||||
|
||||
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) {
|
||||
const compactPhone = phone.replace(/[^\d+]/gu, '').trim();
|
||||
@@ -44,21 +48,44 @@ function buildClientAuthPhoneInput(phone: string): AuthPhoneNumberInput {
|
||||
};
|
||||
}
|
||||
|
||||
export function getStoredAuthAccessToken() {
|
||||
return window.localStorage.getItem(ACCESS_TOKEN_STORAGE_KEY)?.trim() || '';
|
||||
export function getStoredAuthAccessToken(
|
||||
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();
|
||||
if (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;
|
||||
}
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
|
||||
clearStoredAuthAccessToken();
|
||||
}
|
||||
|
||||
export function clearStoredAuthAccessToken() {
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_ORIGIN_STORAGE_KEY);
|
||||
}
|
||||
|
||||
const clientAuthRefreshPromises = new Map<string, Promise<string>>();
|
||||
@@ -102,7 +129,7 @@ function getClientAuthNetworkErrorMessage(error: unknown) {
|
||||
return '无法连接登录服务:服务器拒绝连接,请确认服务已启动并检查端口';
|
||||
}
|
||||
if (/dns|resolve|name or service not known|无法解析/iu.test(detail)) {
|
||||
return '无法连接登录服务:服务器地址无法解析,请检查服务器选择';
|
||||
return '无法连接登录服务:服务器地址无法解析,请检查网络后重试';
|
||||
}
|
||||
if (/certificate|tls|ssl|证书/iu.test(detail)) {
|
||||
return '无法连接登录服务:安全连接失败,请检查服务器地址和证书';
|
||||
@@ -202,7 +229,7 @@ async function requestAuthJson<T>(
|
||||
const headers = new Headers(init.headers);
|
||||
headers.set(API_RESPONSE_ENVELOPE_HEADER, API_RESPONSE_ENVELOPE_VERSION);
|
||||
if (!options.skipAuth) {
|
||||
const token = getStoredAuthAccessToken();
|
||||
const token = getStoredAuthAccessToken(options.apiBaseUrl);
|
||||
if (token) {
|
||||
headers.set('Authorization', `Bearer ${token}`);
|
||||
}
|
||||
@@ -284,7 +311,7 @@ export async function refreshClientAuthAccessToken(
|
||||
apiBaseUrl,
|
||||
transitionClientOperation(operation, 'success'),
|
||||
);
|
||||
setStoredAuthAccessToken(response.token);
|
||||
setStoredAuthAccessToken(response.token, apiBaseUrl);
|
||||
return response.token;
|
||||
})
|
||||
.catch((error) => {
|
||||
@@ -322,7 +349,7 @@ export async function loginClientWithPassword(
|
||||
'登录失败',
|
||||
{ skipAuth: true, apiBaseUrl },
|
||||
);
|
||||
setStoredAuthAccessToken(response.token);
|
||||
setStoredAuthAccessToken(response.token, apiBaseUrl);
|
||||
return response.user;
|
||||
}
|
||||
|
||||
@@ -365,7 +392,7 @@ export async function loginClientWithPhoneCode(
|
||||
'登录失败',
|
||||
{ skipAuth: true, apiBaseUrl },
|
||||
);
|
||||
setStoredAuthAccessToken(response.token);
|
||||
setStoredAuthAccessToken(response.token, apiBaseUrl);
|
||||
return response.user;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { fetch as tauriHttpFetch } from '@tauri-apps/plugin-http';
|
||||
|
||||
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_VALUE = 'agc';
|
||||
/**
|
||||
* Upper bound for the initial network transaction (DNS/connect/response
|
||||
* headers). Callers may override this for a request that legitimately needs
|
||||
* 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;
|
||||
|
||||
@@ -85,119 +84,12 @@ export async function readClientHttpResponseText(
|
||||
}
|
||||
}
|
||||
|
||||
export type ClientServerPreset = 'release' | 'dev' | 'custom';
|
||||
|
||||
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);
|
||||
export function getClientServerBaseUrl() {
|
||||
return AGC_DEVELOPMENT_API_BASE_URL;
|
||||
}
|
||||
|
||||
type ClientHttpContext = {
|
||||
isDevelopment: boolean;
|
||||
isTauri: boolean;
|
||||
pageProtocol: string;
|
||||
mode?: string;
|
||||
serverBaseUrl?: string;
|
||||
};
|
||||
@@ -215,9 +107,7 @@ function withAgcClientMarker(init: RequestInit): RequestInit {
|
||||
|
||||
function currentClientHttpContext(): ClientHttpContext {
|
||||
return {
|
||||
isDevelopment: import.meta.env.DEV,
|
||||
isTauri: typeof window !== 'undefined' && Boolean(window.__TAURI__),
|
||||
pageProtocol: typeof window === 'undefined' ? '' : window.location.protocol,
|
||||
mode: import.meta.env.MODE,
|
||||
};
|
||||
}
|
||||
@@ -226,20 +116,20 @@ export function resolveClientHttpTarget(
|
||||
url: string,
|
||||
context: ClientHttpContext = currentClientHttpContext(),
|
||||
): ClientHttpTarget {
|
||||
// Existing unit fixtures omit mode; retain the Vite-relative transport for
|
||||
// them while real development/release clients use the selected server.
|
||||
const serverBaseUrl = getClientServerBaseUrl();
|
||||
const target = new URL(url, `${serverBaseUrl}/`);
|
||||
if (
|
||||
!context.serverBaseUrl &&
|
||||
(context.mode === 'test' || (!context.mode && context.isDevelopment))
|
||||
(context.serverBaseUrl && context.serverBaseUrl !== serverBaseUrl) ||
|
||||
target.origin !== serverBaseUrl ||
|
||||
target.username ||
|
||||
target.password
|
||||
) {
|
||||
return { transport: 'web', url };
|
||||
throw new Error('请求目标不在客户端固定的 dev 服务范围内');
|
||||
}
|
||||
|
||||
const serverBaseUrl =
|
||||
context.serverBaseUrl ?? getClientServerBaseUrl(getClientServerSelection());
|
||||
const target = new URL(url, `${serverBaseUrl}/`);
|
||||
if (target.origin !== serverBaseUrl) {
|
||||
throw new Error('请求目标不在当前选择的服务器范围内');
|
||||
// Unit fixtures use relative requests after the same origin validation.
|
||||
if (context.mode === 'test') {
|
||||
return { transport: 'web', url };
|
||||
}
|
||||
|
||||
if (!context.isTauri) {
|
||||
@@ -258,17 +148,10 @@ export async function fetchClientHttp(
|
||||
} = {},
|
||||
): Promise<Response> {
|
||||
const currentContext = currentClientHttpContext();
|
||||
const serverBaseUrl = options.serverBaseUrl
|
||||
? normalizeClientServerBaseUrl(options.serverBaseUrl)
|
||||
: undefined;
|
||||
// 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 target = resolveClientHttpTarget(url, {
|
||||
...currentContext,
|
||||
serverBaseUrl: options.serverBaseUrl,
|
||||
});
|
||||
const markedInit = withAgcClientMarker(init);
|
||||
|
||||
// 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 { resolveTauriInvoke } from '../app/tauri';
|
||||
import {
|
||||
clearStoredAuthAccessToken,
|
||||
getCurrentClientAuthUser,
|
||||
getStoredAuthAccessToken,
|
||||
isClientAuthAuthorityFailure,
|
||||
refreshClientAuthAccessToken,
|
||||
setStoredAuthAccessToken,
|
||||
} from './clientAuth';
|
||||
import { getClientServerBaseUrl } from './clientHttp';
|
||||
import {
|
||||
@@ -13,10 +15,8 @@ import {
|
||||
transitionClientOperation,
|
||||
} from './clientOperation';
|
||||
|
||||
const ACCESS_TOKEN_STORAGE_KEY = 'genarrative.auth.access-token.v1';
|
||||
|
||||
function readStoredAccessTokenOrThrow() {
|
||||
const accessToken = getStoredAuthAccessToken();
|
||||
function readStoredAccessTokenOrThrow(apiBaseUrl: string) {
|
||||
const accessToken = getStoredAuthAccessToken(apiBaseUrl);
|
||||
if (!accessToken) {
|
||||
throw new Error('陶泥儿登录凭据缺失,请重新登录');
|
||||
}
|
||||
@@ -94,18 +94,18 @@ export function getPlatformSessionOperation() {
|
||||
|
||||
function restoreCommittedAccessToken() {
|
||||
if (committedPlatformSession?.accessToken) {
|
||||
window.localStorage.setItem(
|
||||
ACCESS_TOKEN_STORAGE_KEY,
|
||||
setStoredAuthAccessToken(
|
||||
committedPlatformSession.accessToken,
|
||||
committedPlatformSession.apiBaseUrl,
|
||||
);
|
||||
return;
|
||||
}
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
|
||||
clearStoredAuthAccessToken();
|
||||
}
|
||||
|
||||
function restoreCurrentRendererAccessToken() {
|
||||
if (!desiredPlatformSession) {
|
||||
window.localStorage.removeItem(ACCESS_TOKEN_STORAGE_KEY);
|
||||
clearStoredAuthAccessToken();
|
||||
return;
|
||||
}
|
||||
restoreCommittedAccessToken();
|
||||
@@ -424,7 +424,7 @@ export async function commitAuthenticatedPlatformSession(
|
||||
expectedGeneration: number,
|
||||
apiBaseUrl = resolvePlatformApiBaseUrl(),
|
||||
) {
|
||||
const accessToken = readStoredAccessTokenOrThrow();
|
||||
const accessToken = readStoredAccessTokenOrThrow(apiBaseUrl);
|
||||
const operation = createClientOperation(
|
||||
'auth-transition',
|
||||
{ userId: user.id },
|
||||
@@ -508,7 +508,7 @@ export function requestPlatformSessionRefresh(expectedUserId?: string) {
|
||||
const committed = await enqueuePlatformSessionNativeMutation(() =>
|
||||
commitPlatformCredentialRefresh(
|
||||
user,
|
||||
readStoredAccessTokenOrThrow(),
|
||||
readStoredAccessTokenOrThrow(apiBaseUrl),
|
||||
apiBaseUrl,
|
||||
expectedGeneration,
|
||||
),
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
import { afterEach } from 'vitest';
|
||||
|
||||
import {
|
||||
AGC_DEVELOPMENT_API_BASE_URL,
|
||||
AGC_RELEASE_API_BASE_URL,
|
||||
resetClientServerSelectionForTests,
|
||||
setClientServerSelection,
|
||||
} from '../../src/services/clientHttp';
|
||||
import { setStoredAuthAccessToken } from '../../src/services/clientAuth';
|
||||
import { AGC_DEVELOPMENT_API_BASE_URL } from '../../src/services/clientHttp';
|
||||
import {
|
||||
beginPlatformSessionClearTransition,
|
||||
beginPlatformSessionTransition,
|
||||
@@ -33,7 +29,6 @@ import {
|
||||
export function registerAuthTests() {
|
||||
afterEach(() => {
|
||||
resetPlatformSessionStateForTests();
|
||||
resetClientServerSelectionForTests();
|
||||
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 () => {
|
||||
setClientServerSelection({ preset: 'dev', customBaseUrl: '' });
|
||||
const invoke = vi.fn(async () => null);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
let resolveLogin: ((response: Response) => void) | null = null;
|
||||
@@ -184,11 +178,12 @@ export function registerAuthTests() {
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: '登录' }));
|
||||
await waitFor(() => expect(resolveLogin).not.toBeNull());
|
||||
expect(
|
||||
(screen.getByLabelText('服务器') as HTMLSelectElement).disabled,
|
||||
).toBe(true);
|
||||
expect(screen.queryByLabelText('服务器')).toBeNull();
|
||||
|
||||
setClientServerSelection({ preset: 'release', customBaseUrl: '' });
|
||||
window.localStorage.setItem(
|
||||
'genarrative.client.server-selection.v1',
|
||||
JSON.stringify({ preset: 'release', customBaseUrl: '' }),
|
||||
);
|
||||
resolveLogin?.(
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
@@ -211,7 +206,7 @@ export function registerAuthTests() {
|
||||
);
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'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 installIdentityFloor = nativeFloor.identityGeneration;
|
||||
const loginGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'renderer-reload-token',
|
||||
);
|
||||
setStoredAuthAccessToken('renderer-reload-token');
|
||||
await commitAuthenticatedPlatformSession(testAuthUser, loginGeneration);
|
||||
expect(mutations[0]?.command).toBe('install_platform_account_session');
|
||||
expect(mutations[0]?.identityGeneration).toBeGreaterThan(
|
||||
@@ -310,10 +302,7 @@ export function registerAuthTests() {
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const firstGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'retry-floor-token',
|
||||
);
|
||||
setStoredAuthAccessToken('retry-floor-token');
|
||||
|
||||
await expect(
|
||||
commitAuthenticatedPlatformSession(testAuthUser, firstGeneration),
|
||||
@@ -321,10 +310,7 @@ export function registerAuthTests() {
|
||||
|
||||
// 瞬时读取失败不能被缓存成永久失败:第二次登录必须重新读取并成功。
|
||||
const secondGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'retry-floor-token',
|
||||
);
|
||||
setStoredAuthAccessToken('retry-floor-token');
|
||||
await expect(
|
||||
commitAuthenticatedPlatformSession(testAuthUser, secondGeneration),
|
||||
).resolves.toEqual(expect.any(Number));
|
||||
@@ -358,10 +344,7 @@ export function registerAuthTests() {
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
|
||||
const stalledGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'stalled-token',
|
||||
);
|
||||
setStoredAuthAccessToken('stalled-token');
|
||||
const stalled = commitAuthenticatedPlatformSession(
|
||||
testAuthUser,
|
||||
stalledGeneration,
|
||||
@@ -370,10 +353,7 @@ export function registerAuthTests() {
|
||||
expect(installedTokens).toEqual(['stalled-token']);
|
||||
|
||||
const retryGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'retry-token',
|
||||
);
|
||||
setStoredAuthAccessToken('retry-token');
|
||||
const retry = commitAuthenticatedPlatformSession(
|
||||
testAuthUser,
|
||||
retryGeneration,
|
||||
@@ -477,17 +457,11 @@ export function registerAuthTests() {
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const accountAGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'account-a-token',
|
||||
);
|
||||
setStoredAuthAccessToken('account-a-token');
|
||||
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
|
||||
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
|
||||
const accountBGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'account-b-token',
|
||||
);
|
||||
setStoredAuthAccessToken('account-b-token');
|
||||
|
||||
await expect(
|
||||
commitAuthenticatedPlatformSession(accountB, accountBGeneration),
|
||||
@@ -513,17 +487,11 @@ export function registerAuthTests() {
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const accountAGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'account-a-token',
|
||||
);
|
||||
setStoredAuthAccessToken('account-a-token');
|
||||
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
|
||||
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
|
||||
const accountBGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'account-b-token',
|
||||
);
|
||||
setStoredAuthAccessToken('account-b-token');
|
||||
const accountBCommit = commitAuthenticatedPlatformSession(
|
||||
accountB,
|
||||
accountBGeneration,
|
||||
@@ -562,17 +530,11 @@ export function registerAuthTests() {
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const accountAGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'account-a-token',
|
||||
);
|
||||
setStoredAuthAccessToken('account-a-token');
|
||||
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
|
||||
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
|
||||
const accountBGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'account-b-token',
|
||||
);
|
||||
setStoredAuthAccessToken('account-b-token');
|
||||
const accountBCommit = commitAuthenticatedPlatformSession(
|
||||
accountB,
|
||||
accountBGeneration,
|
||||
@@ -613,18 +575,12 @@ export function registerAuthTests() {
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const accountAGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'account-a-token',
|
||||
);
|
||||
setStoredAuthAccessToken('account-a-token');
|
||||
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
|
||||
|
||||
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
|
||||
const accountBGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'account-b-token',
|
||||
);
|
||||
setStoredAuthAccessToken('account-b-token');
|
||||
const accountBCommit = commitAuthenticatedPlatformSession(
|
||||
accountB,
|
||||
accountBGeneration,
|
||||
@@ -633,10 +589,7 @@ export function registerAuthTests() {
|
||||
|
||||
const accountC = { ...testAuthUser, id: 'user-c', displayName: '用户 C' };
|
||||
const accountCGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'account-c-token',
|
||||
);
|
||||
setStoredAuthAccessToken('account-c-token');
|
||||
const accountCCommit = commitAuthenticatedPlatformSession(
|
||||
accountC,
|
||||
accountCGeneration,
|
||||
@@ -661,18 +614,12 @@ export function registerAuthTests() {
|
||||
const invoke = vi.fn(async () => null);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const accountAGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'account-a-token',
|
||||
);
|
||||
setStoredAuthAccessToken('account-a-token');
|
||||
await commitAuthenticatedPlatformSession(testAuthUser, accountAGeneration);
|
||||
|
||||
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
|
||||
const accountBGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'account-b-token',
|
||||
);
|
||||
setStoredAuthAccessToken('account-b-token');
|
||||
const accountBCommit = commitAuthenticatedPlatformSession(
|
||||
accountB,
|
||||
accountBGeneration,
|
||||
@@ -694,10 +641,7 @@ export function registerAuthTests() {
|
||||
const invoke = vi.fn(async () => null);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const initialGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'account-a-token',
|
||||
);
|
||||
setStoredAuthAccessToken('account-a-token');
|
||||
await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration);
|
||||
|
||||
let resolveRefresh: ((response: Response) => void) | null = null;
|
||||
@@ -731,10 +675,7 @@ export function registerAuthTests() {
|
||||
|
||||
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
|
||||
const accountBGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'account-b-token',
|
||||
);
|
||||
setStoredAuthAccessToken('account-b-token');
|
||||
await commitAuthenticatedPlatformSession(accountB, accountBGeneration);
|
||||
resolveRefresh?.(
|
||||
new Response(JSON.stringify({ token: 'late-account-a-token' }), {
|
||||
@@ -768,10 +709,7 @@ export function registerAuthTests() {
|
||||
const invoke = vi.fn(async () => null);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const initialGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'account-a-token',
|
||||
);
|
||||
setStoredAuthAccessToken('account-a-token');
|
||||
await commitAuthenticatedPlatformSession(testAuthUser, initialGeneration);
|
||||
|
||||
let rejectRefresh: ((error: Error) => void) | null = null;
|
||||
@@ -788,10 +726,7 @@ export function registerAuthTests() {
|
||||
const staleRefresh = requestPlatformSessionRefresh(testAuthUser.id);
|
||||
const accountB = { ...testAuthUser, id: 'user-b', displayName: '用户 B' };
|
||||
const accountBGeneration = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'account-b-token',
|
||||
);
|
||||
setStoredAuthAccessToken('account-b-token');
|
||||
await commitAuthenticatedPlatformSession(accountB, accountBGeneration);
|
||||
rejectRefresh?.(new Error('late account A refresh failed'));
|
||||
|
||||
@@ -805,10 +740,7 @@ export function registerAuthTests() {
|
||||
const invoke = vi.fn(async () => null);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const generation = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'expired-token',
|
||||
);
|
||||
setStoredAuthAccessToken('expired-token');
|
||||
await commitAuthenticatedPlatformSession(testAuthUser, generation);
|
||||
let refreshCalls = 0;
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
||||
@@ -862,10 +794,7 @@ export function registerAuthTests() {
|
||||
});
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const generation = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'expired-token',
|
||||
);
|
||||
setStoredAuthAccessToken('expired-token');
|
||||
await commitAuthenticatedPlatformSession(testAuthUser, generation);
|
||||
const identityGenerationAfterLogin =
|
||||
currentPlatformNativeIdentityGenerationForTests();
|
||||
@@ -915,10 +844,7 @@ export function registerAuthTests() {
|
||||
const invoke = vi.fn(async () => null);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const generation = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'still-valid-token',
|
||||
);
|
||||
setStoredAuthAccessToken('still-valid-token');
|
||||
await commitAuthenticatedPlatformSession(testAuthUser, generation);
|
||||
const sessionGeneration = currentPlatformSessionGeneration();
|
||||
|
||||
@@ -947,14 +873,10 @@ export function registerAuthTests() {
|
||||
});
|
||||
|
||||
it('keeps refresh, current-user lookup, and native commit on the frozen origin', async () => {
|
||||
setClientServerSelection({ preset: 'dev', customBaseUrl: '' });
|
||||
const invoke = vi.fn(async () => null);
|
||||
window.__TAURI__ = { core: { invoke } };
|
||||
const generation = beginPlatformSessionTransition();
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'expired-token',
|
||||
);
|
||||
setStoredAuthAccessToken('expired-token');
|
||||
await commitAuthenticatedPlatformSession(
|
||||
testAuthUser,
|
||||
generation,
|
||||
@@ -983,7 +905,10 @@ export function registerAuthTests() {
|
||||
},
|
||||
);
|
||||
const refresh = requestPlatformSessionRefresh(testAuthUser.id);
|
||||
setClientServerSelection({ preset: 'release', customBaseUrl: '' });
|
||||
window.localStorage.setItem(
|
||||
'genarrative.client.server-selection.v1',
|
||||
JSON.stringify({ preset: 'release', customBaseUrl: '' }),
|
||||
);
|
||||
resolveRefresh?.(
|
||||
new Response(JSON.stringify({ token: 'replacement-token' }), {
|
||||
status: 200,
|
||||
@@ -1074,7 +999,7 @@ export function registerAuthTests() {
|
||||
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(
|
||||
async (input: RequestInfo | URL) => {
|
||||
if (String(input) === '/api/auth/refresh') {
|
||||
@@ -1091,22 +1016,74 @@ export function registerAuthTests() {
|
||||
);
|
||||
|
||||
await screen.findByRole('main', { name: '登录' });
|
||||
const server = screen.getByRole('combobox', { name: '服务器' });
|
||||
expect(server).not.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');
|
||||
expect(screen.queryByRole('combobox', { name: '服务器' })).toBeNull();
|
||||
expect(screen.queryByLabelText('自定义服务器地址')).toBeNull();
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
@@ -1445,10 +1422,7 @@ export function registerAuthTests() {
|
||||
});
|
||||
|
||||
it('keeps the stored token when startup auth check cannot reach the service', async () => {
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'existing-token',
|
||||
);
|
||||
setStoredAuthAccessToken('existing-token');
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
.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 () => {
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'existing-token',
|
||||
);
|
||||
setStoredAuthAccessToken('existing-token');
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(
|
||||
async (input: RequestInfo | URL) => {
|
||||
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 () => {
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'existing-token',
|
||||
);
|
||||
setStoredAuthAccessToken('existing-token');
|
||||
let logoutCalls = 0;
|
||||
const fetchSpy = vi
|
||||
.spyOn(globalThis, 'fetch')
|
||||
@@ -1581,10 +1549,7 @@ export function registerAuthTests() {
|
||||
});
|
||||
|
||||
it('fails the renderer closed when native session clear is rejected during logout', async () => {
|
||||
window.localStorage.setItem(
|
||||
'genarrative.auth.access-token.v1',
|
||||
'existing-token',
|
||||
);
|
||||
setStoredAuthAccessToken('existing-token');
|
||||
window.__TAURI__ = {
|
||||
core: {
|
||||
invoke: vi.fn(async (command: string) => {
|
||||
|
||||
@@ -11,7 +11,10 @@ import {
|
||||
getStoredAuthAccessToken,
|
||||
refreshClientAuthAccessToken,
|
||||
} 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 {
|
||||
cachedLlmModelCatalog,
|
||||
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();
|
||||
await vi.advanceTimersByTimeAsync(15_000);
|
||||
await firstAssertion;
|
||||
expect(getClientAuthRefreshOperation('http://localhost:3000')).toMatchObject({
|
||||
expect(
|
||||
getClientAuthRefreshOperation(AGC_DEVELOPMENT_API_BASE_URL),
|
||||
).toMatchObject({
|
||||
kind: 'auth-refresh',
|
||||
phase: 'retryable-failure',
|
||||
});
|
||||
|
||||
const second = refreshClientAuthAccessToken('http://localhost:3000');
|
||||
const second = refreshClientAuthAccessToken(AGC_DEVELOPMENT_API_BASE_URL);
|
||||
const secondAssertion = expect(second).rejects.toThrow();
|
||||
expect(refreshCalls).toBe(2);
|
||||
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 { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
@@ -9,16 +10,11 @@ import {
|
||||
AGC_CLIENT_MARKER_HEADER,
|
||||
AGC_CLIENT_MARKER_VALUE,
|
||||
AGC_DEVELOPMENT_API_BASE_URL,
|
||||
AGC_RELEASE_API_BASE_URL,
|
||||
ClientHttpTimeoutError,
|
||||
fetchClientHttp,
|
||||
getClientServerBaseUrl,
|
||||
getClientServerSelection,
|
||||
normalizeClientServerBaseUrl,
|
||||
readClientHttpResponseText,
|
||||
resetClientServerSelectionForTests,
|
||||
resolveClientHttpTarget,
|
||||
setClientServerSelection,
|
||||
} from '../src/services/clientHttp';
|
||||
|
||||
vi.mock('@tauri-apps/plugin-http', () => ({
|
||||
@@ -31,7 +27,7 @@ describe('AGC client HTTP transport', () => {
|
||||
vi.clearAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
resetClientServerSelectionForTests();
|
||||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
it('keeps local development requests on the Vite API proxy', () => {
|
||||
expect(
|
||||
resolveClientHttpTarget('/api/auth/me', {
|
||||
isDevelopment: true,
|
||||
isTauri: true,
|
||||
pageProtocol: 'http:',
|
||||
}),
|
||||
).toEqual({ transport: 'web', url: '/api/auth/me' });
|
||||
});
|
||||
it.each(['development', 'production'])(
|
||||
'routes %s Tauri requests through fixed dev',
|
||||
(mode) => {
|
||||
expect(
|
||||
resolveClientHttpTarget('/api/auth/me', {
|
||||
isTauri: true,
|
||||
mode,
|
||||
}),
|
||||
).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(
|
||||
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,
|
||||
pageProtocol: 'https:',
|
||||
mode: 'test',
|
||||
}),
|
||||
).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', () => {
|
||||
expect(() =>
|
||||
resolveClientHttpTarget('https://example.com/api/auth/me', {
|
||||
isDevelopment: false,
|
||||
isTauri: true,
|
||||
pageProtocol: 'tauri:',
|
||||
mode: 'production',
|
||||
serverBaseUrl: AGC_RELEASE_API_BASE_URL,
|
||||
}),
|
||||
).toThrow('当前选择的服务器范围');
|
||||
).toThrow('固定的 dev 服务范围');
|
||||
});
|
||||
|
||||
it('persists release, dev, and custom server selection', () => {
|
||||
const release = setClientServerSelection({
|
||||
preset: 'release',
|
||||
customBaseUrl: '',
|
||||
});
|
||||
expect(release).toEqual({
|
||||
preset: 'release',
|
||||
customBaseUrl: '',
|
||||
});
|
||||
expect(getClientServerBaseUrl(release)).toBe(AGC_RELEASE_API_BASE_URL);
|
||||
const dev = setClientServerSelection({ preset: 'dev', customBaseUrl: '' });
|
||||
expect(getClientServerBaseUrl(dev)).toBe(AGC_DEVELOPMENT_API_BASE_URL);
|
||||
it.each(['release', 'dev', 'custom'])(
|
||||
'ignores persisted %s preference when resolving web requests',
|
||||
(preset) => {
|
||||
window.localStorage.setItem(
|
||||
'genarrative.client.server-selection.v1',
|
||||
JSON.stringify({
|
||||
preset,
|
||||
customBaseUrl: 'https://staging.example.com',
|
||||
}),
|
||||
);
|
||||
vi.stubEnv('DEV', false);
|
||||
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({
|
||||
preset: 'custom',
|
||||
customBaseUrl: 'https://staging.example.com/',
|
||||
});
|
||||
expect(custom).toEqual({
|
||||
preset: 'custom',
|
||||
customBaseUrl: 'https://staging.example.com',
|
||||
});
|
||||
expect(getClientServerSelection().preset).toBe('dev');
|
||||
expect(getClientServerBaseUrl(custom)).toBe('https://staging.example.com');
|
||||
});
|
||||
|
||||
it('accepts HTTPS custom servers and loopback HTTP only', () => {
|
||||
expect(normalizeClientServerBaseUrl('https://example.com/')).toBe(
|
||||
'https://example.com',
|
||||
);
|
||||
expect(normalizeClientServerBaseUrl('http://127.0.0.1:8080/')).toBe(
|
||||
'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.each(['development', 'production', 'test'])(
|
||||
'rejects explicit origin overrides before transport in %s',
|
||||
async (mode) => {
|
||||
vi.stubEnv('MODE', mode);
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
await expect(
|
||||
fetchClientHttp(
|
||||
'/api/auth/me',
|
||||
{},
|
||||
{
|
||||
serverBaseUrl: 'https://www.genarrative.world',
|
||||
},
|
||||
),
|
||||
).rejects.toThrow('固定的 dev 服务范围');
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(tauriHttpFetch).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it('aborts a stalled Web request at the configured timeout', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
+1
-1
@@ -42,7 +42,7 @@
|
||||
- [AGC 通用插件宿主与编辑器适配](./technical/【技术方案】AGC通用插件宿主与编辑器适配-2026-09-09.md):通用插件宿主、SDK、权限审计、UI 挂载和 Cocos 编辑器适配边界。
|
||||
- [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 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、OSS 清单格式和下载约定。
|
||||
- [AGC 客户端更新检查与下载](./technical/【技术方案】AGC客户端更新检查与下载-2026-08-31.md):启动版本检测、固定 dev 服务、OSS 清单与官网最新客户端下载。
|
||||
- [AGC 模板库与模板建项](./technical/【技术方案】AGC模板库与模板建项-2026-09-17.md):`templates/` 前缀的模板库契约、下载安装与「用模板建项目」链路。
|
||||
- [DirectProject 本轮附件路径映射](./technical/【技术方案】DirectProject本轮附件路径映射-2026-08-31.md):Direct 首轮只映射附件原名与项目相对路径,不灌正文、不区别 GDD。
|
||||
- [Direct 回合行为审计账本](./technical/【技术方案】Direct回合行为审计账本-2026-08-31.md):Direct GUI 回合把 native 读 / MCP / 写文件落成项目内有界时间线,用于判断有没有打开本轮附件。
|
||||
|
||||
@@ -22,6 +22,8 @@
|
||||
|
||||
- AGC 批量追加素材标签由原生在一次项目写锁与 revision CAS 下合并各项原标签,先校验全批再写 manifest;前端不能循环单素材分类命令,不回传展示层推导的分类或旧标签全集,以免部分写入或覆盖未编辑字段。
|
||||
|
||||
- AGC 平台服务固定为 `https://dev.genarrative.world`,会话凭据按 origin 隔离。官网通过同源公开 `/api/client-downloads` 汇总 Windows/Mac 渠道的首装 `downloads`,按真实平台/架构显示;未发布隐藏,单渠道失败不影响其它下载。发布先上传 EXE/DMG 再写本渠道清单,不维护会互相覆盖的共享 OSS 索引。主站 Vite 代理复用实际 `runtimeServerTarget`,浏览器不直接跨域读取 OSS 清单。完整约定见 AGC 客户端更新检查与下载专题。
|
||||
|
||||
- 画布卡片类型与信息角标共用 `CanvasCardCornerActions`;菜单收纳共用 `OverflowActions`,宿主决定展示数量和资源命令。AGC 选中菜单前 5 项直显,Web 默认不折叠;浮层 portal 继续接入现有画布关闭与滚轮归属判据。
|
||||
- 修改范围保持聚焦;优先扩展现有系统、页面、组件、DTO 和脚本,不新建平行入口或业务真相。
|
||||
- UI 开发优先复用现有公共组件;跨页面或跨端重复的视觉/交互模式应沉淀到 `packages/shared`,由现有页面迁移使用,禁止在业务页复制同类 UI。共享组件只承载通用表现与交互,不下沉领域规则、后端副作用或正式业务状态。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# AGC 客户端更新检查与下载
|
||||
|
||||
更新时间:`2026-09-17`
|
||||
更新时间:`2026-09-19`
|
||||
|
||||
本文件是 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_64,Mac 按清单实际提供的架构展示 Apple Silicon / Intel 下载项。DTO 由 `shared-contracts` 与 `packages/shared` 对齐,不接受请求参数指定上游 URL,不触及 SpacetimeDB。
|
||||
- 渠道清单新增可选 `downloads` 字典,键与 updater 平台键一致,值为 `{ url }`,只登记首装包。Windows `.exe` 可同时用于首装和更新;macOS 首装必须为 `.dmg`,不得将 `.app.tar.gz` 当首装包。已发布且没有 `downloads` 字段的 Windows 清单可读取既有 `platforms.windows-x86_64.url`;Mac 没有首装元数据时隐藏,不推导 DMG 地址。
|
||||
- 渠道 404 视为尚未发布并隐藏该平台;两端均未发布时显示空状态。单渠道请求失败、超时或格式非法时保留另一端有效下载项,同时提示部分平台暂不可用并允许重试;没有任何有效项且存在失败时返回可读 502。关闭面板取消请求,迟到响应不能覆盖下一次打开的状态。
|
||||
- 每个上游请求总超时 10 秒、响应体上限 128 KiB、不跟随重定向、不附加用户凭据;返回的链接仅接受固定 OSS 来源、对应 `/agc/<channel>/<version>/` 下的 HTTPS `.exe` / `.dmg` 对象,架构键必须属于该渠道。不提供陈旧、未知来源或版本不匹配的下载地址,不泄露上游正文。
|
||||
- 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 文件内容>",
|
||||
"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` 是非标准字段:更新插件忽略未知字段,发布脚本用它定位下一次摘要的起点。
|
||||
- 上传:安装包与 `.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、日志或归档产物。
|
||||
- 归档证据:安装包、`.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 且为 EXE;macOS 清单 404,不展示下载 |
|
||||
|
||||
本次未构建或安装新客户端包,未上传或部署官网;Mac 浏览器证据使用受控清单,不能替代 Mac 原生构建、签名与安装验证。真实安装升级及生产部署后的 smoke 属于发布验收。
|
||||
|
||||
已获得的证据:
|
||||
|
||||
| 条款 | 验收方式 | 证据 |
|
||||
|
||||
@@ -69,6 +69,10 @@ Rust 侧在 `server-rs/crates/shared-contracts` 维护唯一权威 `GameCreation
|
||||
- 当前素材名以现有正式命名链路为准:生成时 assetName 参与落盘名称,重命名更新文件名;卡片消费正式资源 label,不从临时输入或历史任务名覆盖后续重命名,不新增平行显示名持久化。若原有命名链路丢失 assetName,则修复原链路,而非只在卡片本地伪造。文档卡不显示任何正文摘要,但详情原文与 JSON 识别读取不变。
|
||||
- 验收覆盖空素材项目进入工具、真实引用入参、成功/失败/重试与迟到响应、占位移动后落点、全类型名称、文档详情、当前栏目重排/撤销、不同缩放的多选移动/撤销以及其他栏目不变。自动化、真实客户端和真实 Provider 验证分别报告;未实际运行的路径不得标为通过。
|
||||
|
||||
## 平台服务与官网分发
|
||||
|
||||
客户端平台服务固定为 dev,登录页不提供服务器选择。凭据按 origin 隔离迁移;官网下载入口汇总最新渠道清单,自动展示已有首装包的 Windows/Mac 平台及架构。完整合同见 [AGC 客户端更新检查与下载](./【技术方案】AGC客户端更新检查与下载-2026-08-31.md) 的“官网下载与客户端服务地址”。
|
||||
|
||||
## 策划 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 配置时保留原有规则、忽略范围与零警告门禁,不以关闭规则代替排障。
|
||||
|
||||
@@ -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,5 +1,6 @@
|
||||
export * from './components';
|
||||
export * from './contracts/auth';
|
||||
export type * from './contracts/clientDownload';
|
||||
export * from './contracts/common';
|
||||
export type * from './contracts/editorAudio';
|
||||
export * from './contracts/editorScene';
|
||||
|
||||
@@ -44,6 +44,7 @@ pub fn build_router(state: AppState) -> Router {
|
||||
.merge(modules::health::router(state.clone()))
|
||||
.merge(modules::internal::router(state.clone()))
|
||||
.merge(modules::auth::router(state.clone()))
|
||||
.merge(modules::client_downloads::router(state.clone()))
|
||||
.merge(modules::profile::router(state.clone()))
|
||||
.merge(modules::external_api::router(state.clone()))
|
||||
.merge(modules::frontend_runtime_config::router(state.clone()))
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
pub mod admin;
|
||||
pub mod assets;
|
||||
pub mod auth;
|
||||
pub mod client_downloads;
|
||||
pub mod editor_project;
|
||||
pub mod external_api;
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
sha2 = { workspace = true }
|
||||
time = { workspace = true, features = ["formatting"] }
|
||||
tokio = { workspace = true, features = ["sync", "time"] }
|
||||
tokio = { workspace = true, features = ["macros", "sync", "time"] }
|
||||
tracing = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user