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