修复素材画布生成恢复与模态交互
素材生成凭据失败保留原幂等身份并支持登录后恢复 素材生成在远端提交、下载暂存和正式提交前复验会话与草稿状态 稳定 staging token 支持半提交修复并对冲突和文件系统异常失败关闭 素材画布三类模态框增加焦点圈闭、背景隔离和焦点恢复 Tauri 命令可达性改为 TypeScript AST 扫描并收紧 allowlist 补充生成恢复、取消态、staging 与模态交互回归及项目决策记录
This commit is contained in:
@@ -4,6 +4,7 @@ import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import ts from 'typescript';
|
||||
|
||||
import {
|
||||
appIdentifier,
|
||||
@@ -80,7 +81,10 @@ const appSource = [
|
||||
readSourceTree(new URL('../src/', import.meta.url), '.ts'),
|
||||
readSourceTree(new URL('../src/', import.meta.url), '.tsx'),
|
||||
].join('\n');
|
||||
const appInvokeSource = appSource;
|
||||
const appInvokeSources = readSourceFiles(
|
||||
new URL('../src/', import.meta.url),
|
||||
new Set(['.ts', '.tsx']),
|
||||
);
|
||||
const appEntrypointSource = fs.readFileSync(
|
||||
new URL('../src/main.tsx', import.meta.url),
|
||||
'utf8',
|
||||
@@ -120,23 +124,10 @@ const rustSharedContractSource = fs.readFileSync(
|
||||
'utf8',
|
||||
);
|
||||
const allowedUncalledTauriCommands = [
|
||||
'archive_failed_local_project_resource_edit',
|
||||
'chat_with_game_creator_agent',
|
||||
'check_ui_editor_font_glyph_coverage',
|
||||
'commit_local_project_asset',
|
||||
'confirm_local_project_asset_canvas_generation_service_identity',
|
||||
'create_local_project_asset_canvas_draft',
|
||||
'discard_local_project_asset_canvas_draft',
|
||||
'generate_local_project_asset_canvas_image',
|
||||
'open_game_creator_launcher_window',
|
||||
'open_game_creator_workspace_window',
|
||||
'read_local_project_asset_canvas_draft',
|
||||
'read_local_project_asset_canvas_media',
|
||||
'recover_local_project_asset_canvas_transactions',
|
||||
'recover_local_project_asset_canvas_generations',
|
||||
'stage_local_project_asset_canvas_image',
|
||||
'store_local_project_asset_canvas_media',
|
||||
'update_local_project_asset_canvas_draft',
|
||||
];
|
||||
const sourceExtensions = new Set([
|
||||
'.json',
|
||||
@@ -186,6 +177,23 @@ function readSourceTree(path, extension) {
|
||||
return fs.readFileSync(path, 'utf8');
|
||||
}
|
||||
|
||||
function readSourceFiles(path, extensions) {
|
||||
const stat = fs.statSync(path);
|
||||
if (stat.isDirectory()) {
|
||||
return fs
|
||||
.readdirSync(path, { withFileTypes: true })
|
||||
.sort((left, right) => left.name.localeCompare(right.name))
|
||||
.flatMap((entry) =>
|
||||
readSourceFiles(
|
||||
new URL(`${entry.name}${entry.isDirectory() ? '/' : ''}`, path),
|
||||
extensions,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (!extensions.has(pathnameExtension(path.pathname))) return [];
|
||||
return [{ fileName: path.pathname, source: fs.readFileSync(path, 'utf8') }];
|
||||
}
|
||||
|
||||
function pathnameExtension(pathname) {
|
||||
const index = pathname.lastIndexOf('.');
|
||||
return index === -1 ? '' : pathname.slice(index);
|
||||
@@ -317,13 +325,94 @@ function assertContractRecordsMatch(label, leftRecords, rightRecords) {
|
||||
}
|
||||
}
|
||||
|
||||
function parseAppInvokeCommandNames(source) {
|
||||
return Array.from(
|
||||
source.matchAll(
|
||||
/(?:invoke|directInvoke)(?:<[^>]*>)?\(\s*['"]([a-z0-9_]+)['"]/g,
|
||||
),
|
||||
([, command]) => command,
|
||||
const APP_INVOKE_FILE_MAX_COUNT = 4 * 1024;
|
||||
const APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH = 16 * 1024 * 1024;
|
||||
const APP_INVOKE_SOURCE_MAX_LENGTH = 2 * 1024 * 1024;
|
||||
const APP_INVOKE_COMMAND_MAX_LENGTH = 128;
|
||||
const APP_INVOKE_CALL_MAX_COUNT = 4 * 1024;
|
||||
const APP_INVOKE_BARE_CALL_NAMES = new Set([
|
||||
'invoke',
|
||||
'directInvoke',
|
||||
'invokeInput',
|
||||
'invokeAuthenticatedInput',
|
||||
]);
|
||||
|
||||
function parseAppInvokeCommandNames(source, fileName = 'fixture.tsx') {
|
||||
const sourceByteLength = Buffer.byteLength(source, 'utf8');
|
||||
if (sourceByteLength > APP_INVOKE_SOURCE_MAX_LENGTH) {
|
||||
throw new Error(
|
||||
`AI game creator shell App invoke source exceeds ${APP_INVOKE_SOURCE_MAX_LENGTH} bytes: ${fileName}`,
|
||||
);
|
||||
}
|
||||
const sourceFile = ts.createSourceFile(
|
||||
fileName,
|
||||
source,
|
||||
ts.ScriptTarget.Latest,
|
||||
true,
|
||||
fileName.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
||||
);
|
||||
const parseDiagnostic = sourceFile.parseDiagnostics[0];
|
||||
if (parseDiagnostic !== undefined) {
|
||||
throw new Error(
|
||||
`AI game creator shell App invoke source cannot be parsed: ${fileName} (TS${parseDiagnostic.code})`,
|
||||
);
|
||||
}
|
||||
const commands = [];
|
||||
const visit = (node) => {
|
||||
if (ts.isCallExpression(node)) {
|
||||
const expression = node.expression;
|
||||
const isBareCall =
|
||||
ts.isIdentifier(expression) &&
|
||||
APP_INVOKE_BARE_CALL_NAMES.has(expression.text);
|
||||
const isObjectInvoke =
|
||||
ts.isPropertyAccessExpression(expression) &&
|
||||
expression.name.text === 'invoke';
|
||||
const commandArgument = node.arguments[0];
|
||||
if (
|
||||
(isBareCall || isObjectInvoke) &&
|
||||
commandArgument !== undefined &&
|
||||
ts.isStringLiteral(commandArgument) &&
|
||||
commandArgument.text.length <= APP_INVOKE_COMMAND_MAX_LENGTH &&
|
||||
/^[a-z0-9_]+$/u.test(commandArgument.text)
|
||||
) {
|
||||
commands.push(commandArgument.text);
|
||||
if (commands.length > APP_INVOKE_CALL_MAX_COUNT) {
|
||||
throw new Error(
|
||||
`AI game creator shell App invoke calls exceed ${APP_INVOKE_CALL_MAX_COUNT}: ${fileName}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
ts.forEachChild(node, visit);
|
||||
};
|
||||
visit(sourceFile);
|
||||
return commands;
|
||||
}
|
||||
|
||||
function parseAppInvokeSourceFiles(files) {
|
||||
if (files.length > APP_INVOKE_FILE_MAX_COUNT) {
|
||||
throw new Error(
|
||||
`AI game creator shell App invoke files exceed ${APP_INVOKE_FILE_MAX_COUNT}`,
|
||||
);
|
||||
}
|
||||
const totalLength = files.reduce(
|
||||
(length, file) => length + Buffer.byteLength(file.source, 'utf8'),
|
||||
0,
|
||||
);
|
||||
if (totalLength > APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH) {
|
||||
throw new Error(
|
||||
`AI game creator shell App invoke sources exceed ${APP_INVOKE_TOTAL_SOURCE_MAX_LENGTH} bytes`,
|
||||
);
|
||||
}
|
||||
const commands = files.flatMap(({ fileName, source }) =>
|
||||
parseAppInvokeCommandNames(source, fileName),
|
||||
);
|
||||
if (commands.length > APP_INVOKE_CALL_MAX_COUNT) {
|
||||
throw new Error(
|
||||
`AI game creator shell App invoke calls exceed ${APP_INVOKE_CALL_MAX_COUNT}`,
|
||||
);
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
function parseTauriHandlerCommandNames(source) {
|
||||
@@ -354,6 +443,101 @@ function assertCommandNamesSubset(label, leftNames, rightNames) {
|
||||
}
|
||||
}
|
||||
|
||||
function assertCommandNamesDisjoint(label, leftNames, rightNames) {
|
||||
const right = new Set(rightNames);
|
||||
const overlapping = Array.from(new Set(leftNames))
|
||||
.filter((name) => right.has(name))
|
||||
.sort((left, rightName) => left.localeCompare(rightName));
|
||||
if (overlapping.length > 0) {
|
||||
throw new Error(`${label} overlapping commands: ${overlapping.join(', ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function runAppInvokeParserRegressionChecks() {
|
||||
assert.deepEqual(
|
||||
parseAppInvokeCommandNames(`
|
||||
invoke('direct_command', {});
|
||||
directInvoke<Result>('generic_direct_command', {});
|
||||
invokeInput < Result > ('input_wrapper_command', {});
|
||||
invokeAuthenticatedInput<Result>(
|
||||
'authenticated_input_wrapper_command',
|
||||
{},
|
||||
);
|
||||
input.invoke<Result>('object_field_command', {});
|
||||
`),
|
||||
[
|
||||
'direct_command',
|
||||
'generic_direct_command',
|
||||
'input_wrapper_command',
|
||||
'authenticated_input_wrapper_command',
|
||||
'object_field_command',
|
||||
],
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
parseAppInvokeCommandNames(`
|
||||
// invoke('line_comment_decoy')
|
||||
/* invokeInput('block_comment_decoy') */
|
||||
const quoted = "directInvoke('string_decoy')";
|
||||
const template = \`input.invoke('template_decoy')\`;
|
||||
const expression = /invokeAuthenticatedInput\\('regex_decoy'\\)/u;
|
||||
invokeCommand('unrelated_name');
|
||||
myinvoke('unrelated_suffix');
|
||||
invoke(dynamicCommand, {});
|
||||
invoke('UPPERCASE_COMMAND', {});
|
||||
`),
|
||||
[],
|
||||
);
|
||||
|
||||
assert.throws(
|
||||
() => parseAppInvokeCommandNames("invoke<Result>('malformed_generic', {"),
|
||||
/source cannot be parsed/u,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
parseAppInvokeCommandNames(
|
||||
`invoke('${'a'.repeat(APP_INVOKE_COMMAND_MAX_LENGTH + 1)}', {})`,
|
||||
),
|
||||
[],
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
parseAppInvokeCommandNames(
|
||||
' '.repeat(APP_INVOKE_SOURCE_MAX_LENGTH + 1),
|
||||
),
|
||||
/source exceeds/u,
|
||||
);
|
||||
|
||||
const wrapperInvocations = parseAppInvokeCommandNames(
|
||||
"invokeInput<Result>('wrapper_reachability_command', {})",
|
||||
);
|
||||
assert.doesNotThrow(() =>
|
||||
assertCommandNamesSubset(
|
||||
'App invoke parser reachability fixture',
|
||||
['wrapper_reachability_command'],
|
||||
wrapperInvocations,
|
||||
),
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertCommandNamesDisjoint(
|
||||
'App invoke parser false allowlist fixture',
|
||||
wrapperInvocations,
|
||||
['wrapper_reachability_command'],
|
||||
),
|
||||
/overlapping commands: wrapper_reachability_command/u,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
assertCommandNamesSubset(
|
||||
'App invoke parser removed wrapper fixture',
|
||||
['wrapper_reachability_command'],
|
||||
parseAppInvokeCommandNames('const wrapperWasRemoved = true;'),
|
||||
),
|
||||
/missing commands: wrapper_reachability_command/u,
|
||||
);
|
||||
}
|
||||
|
||||
function gitCheckResult({ code = 0, signal = null, stdout = '', stderr = '' }) {
|
||||
return { code, signal, stdout, stderr };
|
||||
}
|
||||
@@ -939,6 +1123,10 @@ assertNoEnvironmentConfigFallbacks([
|
||||
assertNoNativeBrowserConfirm([new URL('../src/', import.meta.url)]);
|
||||
assertNoBlockingNativeFilePicker(tauriRustSource);
|
||||
|
||||
runAppInvokeParserRegressionChecks();
|
||||
|
||||
const appInvokeCommandNames = parseAppInvokeSourceFiles(appInvokeSources);
|
||||
|
||||
assertContractRecordsMatch(
|
||||
'AI game creator shell command contract',
|
||||
parseTsCommands(sharedContractSource),
|
||||
@@ -953,7 +1141,7 @@ assertContractRecordsMatch(
|
||||
|
||||
assertCommandNamesSubset(
|
||||
'AI game creator shell Tauri handler',
|
||||
parseAppInvokeCommandNames(appInvokeSource),
|
||||
appInvokeCommandNames,
|
||||
parseTauriHandlerCommandNames(tauriHandlerSource),
|
||||
);
|
||||
|
||||
@@ -966,10 +1154,7 @@ assertCommandNamesSubset(
|
||||
assertCommandNamesSubset(
|
||||
'AI game creator shell App invoke or explicit native-only allowlist',
|
||||
parseTauriHandlerCommandNames(tauriHandlerSource),
|
||||
[
|
||||
...parseAppInvokeCommandNames(appInvokeSource),
|
||||
...allowedUncalledTauriCommands,
|
||||
],
|
||||
[...appInvokeCommandNames, ...allowedUncalledTauriCommands],
|
||||
);
|
||||
|
||||
assertCommandNamesSubset(
|
||||
@@ -978,6 +1163,12 @@ assertCommandNamesSubset(
|
||||
parseTauriHandlerCommandNames(tauriHandlerSource),
|
||||
);
|
||||
|
||||
assertCommandNamesDisjoint(
|
||||
'AI game creator shell App invoke and explicit native-only allowlist',
|
||||
appInvokeCommandNames,
|
||||
allowedUncalledTauriCommands,
|
||||
);
|
||||
|
||||
const tauriHandlerCommandNames =
|
||||
parseTauriHandlerCommandNames(tauriHandlerSource);
|
||||
if (!tauriHandlerCommandNames.includes('create_automatic_local_game_project')) {
|
||||
|
||||
@@ -1552,6 +1552,12 @@ fn stage_asset_canvas_image_with_token_at(
|
||||
let manifest = validate_asset_canvas_project_identity(root, &input.expected_project_id)?;
|
||||
let draft = read_asset_canvas_draft_locked(root, &manifest.project_id, &input.draft_id)?
|
||||
.ok_or_else(|| "素材画布草稿不存在".to_string())?;
|
||||
if matches!(
|
||||
draft.status,
|
||||
AssetCanvasDraftStatus::Cancelled | AssetCanvasDraftStatus::Committed
|
||||
) {
|
||||
return Err("素材画布草稿已取消或提交,不能继续暂存图片".to_string());
|
||||
}
|
||||
if draft.revision != input.expected_draft_revision {
|
||||
return Ok(StageAssetCanvasImageResult {
|
||||
status: "conflict".to_string(),
|
||||
@@ -1574,48 +1580,15 @@ fn stage_asset_canvas_image_with_token_at(
|
||||
}
|
||||
None => new_asset_canvas_token()?,
|
||||
};
|
||||
if stable_token.is_some() {
|
||||
match read_staged_image_locked(root, &token) {
|
||||
Ok((metadata, existing_bytes)) => {
|
||||
if metadata.project_id != manifest.project_id
|
||||
|| metadata.draft_id != input.draft_id
|
||||
|| metadata.draft_revision != draft.revision
|
||||
|| metadata.media_type != media_type
|
||||
|| existing_bytes != input.bytes
|
||||
{
|
||||
return Err("稳定 staging token 已绑定到不同图片".to_string());
|
||||
}
|
||||
return Ok(StageAssetCanvasImageResult {
|
||||
status: "staged".to_string(),
|
||||
staged_image_token: Some(token),
|
||||
draft_id: input.draft_id.clone(),
|
||||
draft_revision: draft.revision,
|
||||
media_type: Some(metadata.media_type),
|
||||
sha256: Some(metadata.sha256),
|
||||
byte_length: Some(metadata.byte_length),
|
||||
pixel_width: Some(metadata.pixel_width),
|
||||
pixel_height: Some(metadata.pixel_height),
|
||||
expires_at: Some(metadata.expires_at),
|
||||
draft: None,
|
||||
});
|
||||
}
|
||||
Err(error) if !error.contains("不存在") => return Err(error),
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
let extension = media_extension(&media_type)?;
|
||||
let image_relative = format!("{ASSET_CANVAS_ROOT}/staging/{token}/image.{extension}");
|
||||
install_new_asset_canvas_file(
|
||||
&resolve_local_project_path(root, &image_relative)?,
|
||||
&input.bytes,
|
||||
"素材画布 staging 图片",
|
||||
)?;
|
||||
let image_path = resolve_local_project_path(root, &image_relative)?;
|
||||
let expires_at = asset_canvas_now()
|
||||
.saturating_add(ASSET_CANVAS_STAGING_TTL_MILLIS)
|
||||
.min(ASSET_CANVAS_MAX_SAFE_INTEGER);
|
||||
let metadata = AssetCanvasStagedImage {
|
||||
let expected_metadata = AssetCanvasStagedImage {
|
||||
schema_version: "game-creator-asset-canvas-staging.v1".to_string(),
|
||||
project_id: manifest.project_id,
|
||||
project_id: manifest.project_id.clone(),
|
||||
draft_id: input.draft_id.clone(),
|
||||
draft_revision: draft.revision,
|
||||
staged_image_token: token.clone(),
|
||||
@@ -1626,6 +1599,84 @@ fn stage_asset_canvas_image_with_token_at(
|
||||
pixel_height: height,
|
||||
expires_at,
|
||||
};
|
||||
if stable_token.is_some() {
|
||||
let existing_metadata = read_staged_image_metadata_locked(root, &token)?;
|
||||
if existing_metadata.as_ref().is_some_and(|metadata| {
|
||||
metadata.project_id != expected_metadata.project_id
|
||||
|| metadata.draft_id != expected_metadata.draft_id
|
||||
|| metadata.draft_revision != expected_metadata.draft_revision
|
||||
|| metadata.media_type != expected_metadata.media_type
|
||||
|| metadata.sha256 != expected_metadata.sha256
|
||||
|| metadata.byte_length != expected_metadata.byte_length
|
||||
|| metadata.pixel_width != expected_metadata.pixel_width
|
||||
|| metadata.pixel_height != expected_metadata.pixel_height
|
||||
}) {
|
||||
return Err("稳定 staging token 已绑定到不同图片".to_string());
|
||||
}
|
||||
for candidate_extension in ["png", "jpg", "webp"] {
|
||||
if candidate_extension == extension {
|
||||
continue;
|
||||
}
|
||||
let candidate = resolve_local_project_path(
|
||||
root,
|
||||
&format!("{ASSET_CANVAS_ROOT}/staging/{token}/image.{candidate_extension}"),
|
||||
)?;
|
||||
match fs::symlink_metadata(candidate) {
|
||||
Ok(_) => return Err("稳定 staging token 已绑定到不同图片".to_string()),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(_) => return Err("读取素材画布 staging 图片失败".to_string()),
|
||||
}
|
||||
}
|
||||
let existing_image = match fs::symlink_metadata(&image_path) {
|
||||
Ok(_) => Some(open_and_validate_image_file(
|
||||
&image_path,
|
||||
&media_type,
|
||||
Some(&expected_metadata.sha256),
|
||||
)?),
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(_) => return Err("读取素材画布 staging 图片失败".to_string()),
|
||||
};
|
||||
if existing_image
|
||||
.as_ref()
|
||||
.is_some_and(|(bytes, existing_width, existing_height)| {
|
||||
bytes != &input.bytes || *existing_width != width || *existing_height != height
|
||||
})
|
||||
{
|
||||
return Err("稳定 staging token 已绑定到不同图片".to_string());
|
||||
}
|
||||
if existing_image.is_none() {
|
||||
install_new_asset_canvas_file(&image_path, &input.bytes, "素材画布 staging 图片")?;
|
||||
}
|
||||
let metadata = existing_metadata.unwrap_or(expected_metadata);
|
||||
if read_staged_image_metadata_locked(root, &token)?.is_none() {
|
||||
write_agent_runtime_json_sidecar_with_max_bytes(
|
||||
root,
|
||||
&format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"),
|
||||
"素材画布 staging 元数据",
|
||||
&metadata,
|
||||
16 * 1024,
|
||||
)?;
|
||||
}
|
||||
let (metadata, existing_bytes) = read_staged_image_locked(root, &token)?;
|
||||
if existing_bytes != input.bytes {
|
||||
return Err("稳定 staging token 已绑定到不同图片".to_string());
|
||||
}
|
||||
return Ok(StageAssetCanvasImageResult {
|
||||
status: "staged".to_string(),
|
||||
staged_image_token: Some(token),
|
||||
draft_id: input.draft_id.clone(),
|
||||
draft_revision: draft.revision,
|
||||
media_type: Some(metadata.media_type),
|
||||
sha256: Some(metadata.sha256),
|
||||
byte_length: Some(metadata.byte_length),
|
||||
pixel_width: Some(metadata.pixel_width),
|
||||
pixel_height: Some(metadata.pixel_height),
|
||||
expires_at: Some(metadata.expires_at),
|
||||
draft: None,
|
||||
});
|
||||
}
|
||||
install_new_asset_canvas_file(&image_path, &input.bytes, "素材画布 staging 图片")?;
|
||||
let metadata = expected_metadata;
|
||||
write_agent_runtime_json_sidecar_with_max_bytes(
|
||||
root,
|
||||
&format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"),
|
||||
@@ -2214,18 +2265,8 @@ fn read_staged_image_locked(
|
||||
token: &str,
|
||||
) -> Result<(AssetCanvasStagedImage, Vec<u8>), String> {
|
||||
validate_plain_component(token, "stagedImageToken", 128)?;
|
||||
let metadata = read_agent_runtime_json_sidecar_with_max_bytes::<AssetCanvasStagedImage>(
|
||||
root,
|
||||
&format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"),
|
||||
"素材画布 staging 元数据",
|
||||
16 * 1024,
|
||||
)?
|
||||
.ok_or_else(|| "素材画布 staging 元数据不存在".to_string())?;
|
||||
if metadata.staged_image_token != token
|
||||
|| metadata.schema_version != "game-creator-asset-canvas-staging.v1"
|
||||
{
|
||||
return Err("素材画布 staging 身份无效".to_string());
|
||||
}
|
||||
let metadata = read_staged_image_metadata_locked(root, token)?
|
||||
.ok_or_else(|| "素材画布 staging 元数据不存在".to_string())?;
|
||||
let extension = media_extension(&metadata.media_type)?;
|
||||
let path = resolve_local_project_path(
|
||||
root,
|
||||
@@ -2242,6 +2283,27 @@ fn read_staged_image_locked(
|
||||
Ok((metadata, bytes))
|
||||
}
|
||||
|
||||
fn read_staged_image_metadata_locked(
|
||||
root: &Path,
|
||||
token: &str,
|
||||
) -> Result<Option<AssetCanvasStagedImage>, String> {
|
||||
validate_plain_component(token, "stagedImageToken", 128)?;
|
||||
let metadata = read_agent_runtime_json_sidecar_with_max_bytes::<AssetCanvasStagedImage>(
|
||||
root,
|
||||
&format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"),
|
||||
"素材画布 staging 元数据",
|
||||
16 * 1024,
|
||||
)?;
|
||||
if let Some(metadata) = metadata.as_ref() {
|
||||
if metadata.staged_image_token != token
|
||||
|| metadata.schema_version != "game-creator-asset-canvas-staging.v1"
|
||||
{
|
||||
return Err("素材画布 staging 身份无效".to_string());
|
||||
}
|
||||
}
|
||||
Ok(metadata)
|
||||
}
|
||||
|
||||
fn committed_result_from_ledger(
|
||||
root: &Path,
|
||||
ledger: &AssetCanvasCommitLedger,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -130,6 +130,151 @@ fn stage_image(fixture: &Fixture, draft: &AssetCanvasDraft) -> StageAssetCanvasI
|
||||
.expect("stage image")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_staging_token_repairs_image_first_and_metadata_first_partial_installs() {
|
||||
for image_first in [true, false] {
|
||||
let fixture = initialize_fixture();
|
||||
let token = Uuid::new_v4().to_string();
|
||||
let staging_directory = fixture
|
||||
.root()
|
||||
.join(format!("{ASSET_CANVAS_ROOT}/staging/{token}"));
|
||||
fs::create_dir_all(&staging_directory).expect("create partial staging directory");
|
||||
let expires_at = asset_canvas_now()
|
||||
.saturating_add(ASSET_CANVAS_STAGING_TTL_MILLIS)
|
||||
.min(ASSET_CANVAS_MAX_SAFE_INTEGER);
|
||||
if image_first {
|
||||
fs::write(staging_directory.join("image.png"), &fixture.png)
|
||||
.expect("simulate image-first crash");
|
||||
} else {
|
||||
let metadata = AssetCanvasStagedImage {
|
||||
schema_version: "game-creator-asset-canvas-staging.v1".to_string(),
|
||||
project_id: PROJECT_ID.to_string(),
|
||||
draft_id: fixture.draft.draft_id.clone(),
|
||||
draft_revision: fixture.draft.revision,
|
||||
staged_image_token: token.clone(),
|
||||
media_type: "image/png".to_string(),
|
||||
sha256: asset_canvas_sha256(&fixture.png),
|
||||
byte_length: fixture.png.len() as u64,
|
||||
pixel_width: 4,
|
||||
pixel_height: 3,
|
||||
expires_at,
|
||||
};
|
||||
write_agent_runtime_json_sidecar_with_max_bytes(
|
||||
fixture.root(),
|
||||
&format!("{ASSET_CANVAS_ROOT}/staging/{token}/metadata.json"),
|
||||
"素材画布 staging 元数据",
|
||||
&metadata,
|
||||
16 * 1024,
|
||||
)
|
||||
.expect("simulate metadata-first crash");
|
||||
}
|
||||
|
||||
let repaired = stage_asset_canvas_image_with_token_at(
|
||||
fixture.root(),
|
||||
&StageAssetCanvasImageInput {
|
||||
project_path: project_path(fixture.root()),
|
||||
expected_project_id: PROJECT_ID.to_string(),
|
||||
draft_id: fixture.draft.draft_id.clone(),
|
||||
expected_draft_revision: fixture.draft.revision,
|
||||
media_type: "image/png".to_string(),
|
||||
bytes: fixture.png.clone(),
|
||||
},
|
||||
Some(&token),
|
||||
)
|
||||
.expect("repair partial stable staging install");
|
||||
assert_eq!(repaired.status, "staged");
|
||||
assert_eq!(repaired.staged_image_token.as_deref(), Some(token.as_str()));
|
||||
let (metadata, bytes) =
|
||||
read_staged_image_locked(fixture.root(), &token).expect("read repaired staging");
|
||||
assert_eq!(metadata.project_id, PROJECT_ID);
|
||||
assert_eq!(metadata.draft_id, fixture.draft.draft_id);
|
||||
assert_eq!(bytes, fixture.png);
|
||||
|
||||
let replay = stage_asset_canvas_image_with_token_at(
|
||||
fixture.root(),
|
||||
&StageAssetCanvasImageInput {
|
||||
project_path: project_path(fixture.root()),
|
||||
expected_project_id: PROJECT_ID.to_string(),
|
||||
draft_id: fixture.draft.draft_id.clone(),
|
||||
expected_draft_revision: fixture.draft.revision,
|
||||
media_type: "image/png".to_string(),
|
||||
bytes: fixture.png.clone(),
|
||||
},
|
||||
Some(&token),
|
||||
)
|
||||
.expect("replay repaired stable staging install");
|
||||
assert_eq!(replay.staged_image_token, repaired.staged_image_token);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_staging_token_rejects_conflicting_partial_install() {
|
||||
let fixture = initialize_fixture();
|
||||
let token = Uuid::new_v4().to_string();
|
||||
let staging_directory = fixture
|
||||
.root()
|
||||
.join(format!("{ASSET_CANVAS_ROOT}/staging/{token}"));
|
||||
fs::create_dir_all(&staging_directory).expect("create conflicting staging directory");
|
||||
fs::write(staging_directory.join("image.png"), &fixture.png)
|
||||
.expect("write conflicting image-first residue");
|
||||
let different_png = png_bytes([220, 31, 54, 255]);
|
||||
let error = stage_asset_canvas_image_with_token_at(
|
||||
fixture.root(),
|
||||
&StageAssetCanvasImageInput {
|
||||
project_path: project_path(fixture.root()),
|
||||
expected_project_id: PROJECT_ID.to_string(),
|
||||
draft_id: fixture.draft.draft_id.clone(),
|
||||
expected_draft_revision: fixture.draft.revision,
|
||||
media_type: "image/png".to_string(),
|
||||
bytes: different_png,
|
||||
},
|
||||
Some(&token),
|
||||
)
|
||||
.expect_err("conflicting half-installed stable token must fail closed");
|
||||
assert!(error.contains("摘要不匹配") || error.contains("绑定到不同图片"));
|
||||
assert!(read_staged_image_metadata_locked(fixture.root(), &token)
|
||||
.expect("read missing conflicting metadata")
|
||||
.is_none());
|
||||
assert_eq!(
|
||||
fs::read(staging_directory.join("image.png")).expect("read preserved residue"),
|
||||
fixture.png
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cancelled_draft_rejects_staging_before_writing_token() {
|
||||
let fixture = initialize_fixture();
|
||||
discard_asset_canvas_draft_at(
|
||||
fixture.root(),
|
||||
&DiscardAssetCanvasDraftInput {
|
||||
project_path: project_path(fixture.root()),
|
||||
expected_project_id: PROJECT_ID.to_string(),
|
||||
draft_id: fixture.draft.draft_id.clone(),
|
||||
expected_draft_revision: fixture.draft.revision,
|
||||
},
|
||||
)
|
||||
.expect("cancel staging fixture draft");
|
||||
let token = Uuid::new_v4().to_string();
|
||||
let error = stage_asset_canvas_image_with_token_at(
|
||||
fixture.root(),
|
||||
&StageAssetCanvasImageInput {
|
||||
project_path: project_path(fixture.root()),
|
||||
expected_project_id: PROJECT_ID.to_string(),
|
||||
draft_id: fixture.draft.draft_id.clone(),
|
||||
expected_draft_revision: fixture.draft.revision,
|
||||
media_type: "image/png".to_string(),
|
||||
bytes: fixture.png.clone(),
|
||||
},
|
||||
Some(&token),
|
||||
)
|
||||
.expect_err("cancelled draft must reject stable staging");
|
||||
assert!(error.contains("草稿已取消或提交"));
|
||||
assert!(!fixture
|
||||
.root()
|
||||
.join(format!("{ASSET_CANVAS_ROOT}/staging/{token}"))
|
||||
.exists());
|
||||
}
|
||||
|
||||
fn commit_input(
|
||||
fixture: &Fixture,
|
||||
draft: &AssetCanvasDraft,
|
||||
|
||||
@@ -58,6 +58,7 @@ import {
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
@@ -139,6 +140,25 @@ type PendingGenerationIdentity = {
|
||||
commitIdempotencyKey: string;
|
||||
};
|
||||
|
||||
const MODAL_FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
|
||||
function modalFocusableElements(dialog: HTMLElement) {
|
||||
return Array.from(
|
||||
dialog.querySelectorAll<HTMLElement>(MODAL_FOCUSABLE_SELECTOR),
|
||||
).filter(
|
||||
(element) =>
|
||||
element.getAttribute('aria-hidden') !== 'true' &&
|
||||
!element.closest<HTMLElement>('[inert]'),
|
||||
);
|
||||
}
|
||||
|
||||
type DragState =
|
||||
| {
|
||||
kind: 'pan';
|
||||
@@ -533,6 +553,10 @@ export function AssetCanvasSurface({
|
||||
const generationFocusEpochRef = useRef(0);
|
||||
const generationStopButtonRef = useRef<HTMLButtonElement | null>(null);
|
||||
const modalInitialFocusRef = useRef<HTMLButtonElement | null>(null);
|
||||
const modalDialogRef = useRef<HTMLElement | null>(null);
|
||||
const modalReturnFocusRef = useRef<HTMLElement | null>(null);
|
||||
const modalFallbackFocusRef = useRef<HTMLButtonElement | null>(null);
|
||||
const modalWasOpenRef = useRef(false);
|
||||
const generationDialogRef = useRef(generationDialog);
|
||||
const exitDialogOpenRef = useRef(exitDialogOpen);
|
||||
const serviceIdentityDialogOpenRef = useRef(serviceIdentityDialogOpen);
|
||||
@@ -1903,12 +1927,87 @@ export function AssetCanvasSurface({
|
||||
}
|
||||
}, [generationInteractionLocked]);
|
||||
|
||||
useEffect(() => {
|
||||
if (modalOpen) {
|
||||
modalInitialFocusRef.current?.focus();
|
||||
const dismissActiveModal = useCallback(() => {
|
||||
if (generationDialogRef.current !== null) {
|
||||
closeGenerationDialog();
|
||||
return;
|
||||
}
|
||||
if (serviceIdentityDialogOpenRef.current) {
|
||||
if (!serviceIdentityPending) {
|
||||
setServiceIdentityDialogOpen(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (exitDialogOpenRef.current && !exitActionPending) {
|
||||
setExitDialogOpen(false);
|
||||
}
|
||||
}, [closeGenerationDialog, exitActionPending, serviceIdentityPending]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!modalOpen) {
|
||||
if (!modalWasOpenRef.current) return;
|
||||
modalWasOpenRef.current = false;
|
||||
const returnFocus = modalReturnFocusRef.current;
|
||||
modalReturnFocusRef.current = null;
|
||||
const focusTarget =
|
||||
returnFocus?.isConnected === true
|
||||
? returnFocus
|
||||
: modalFallbackFocusRef.current;
|
||||
focusTarget?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!modalWasOpenRef.current) {
|
||||
const activeElement = document.activeElement;
|
||||
modalReturnFocusRef.current =
|
||||
activeElement instanceof HTMLElement &&
|
||||
activeElement !== document.body &&
|
||||
activeElement !== document.documentElement
|
||||
? activeElement
|
||||
: null;
|
||||
modalWasOpenRef.current = true;
|
||||
}
|
||||
(modalInitialFocusRef.current ?? modalDialogRef.current)?.focus();
|
||||
}, [generationDialog, modalOpen, serviceIdentityDialogOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!modalOpen) return undefined;
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
dismissActiveModal();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const dialog = modalDialogRef.current;
|
||||
if (!dialog) return;
|
||||
const focusable = modalFocusableElements(dialog);
|
||||
if (!focusable.length) {
|
||||
event.preventDefault();
|
||||
dialog.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const activeElement = document.activeElement;
|
||||
if (
|
||||
event.shiftKey &&
|
||||
(activeElement === first || !dialog.contains(activeElement))
|
||||
) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
} else if (
|
||||
!event.shiftKey &&
|
||||
(activeElement === last || !dialog.contains(activeElement))
|
||||
) {
|
||||
event.preventDefault();
|
||||
first?.focus();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', onKeyDown);
|
||||
return () => document.removeEventListener('keydown', onKeyDown);
|
||||
}, [dismissActiveModal, modalOpen]);
|
||||
|
||||
const failurePresentation =
|
||||
lifecycle.kind === 'canvas.failed'
|
||||
? assetCanvasFailurePresentation(lifecycle)
|
||||
@@ -1944,6 +2043,7 @@ export function AssetCanvasSurface({
|
||||
<header
|
||||
className="asset-canvas-surface__toolbar-shell"
|
||||
inert={backgroundInteractionLocked || undefined}
|
||||
aria-hidden={modalOpen || undefined}
|
||||
>
|
||||
<CanvasToolbar
|
||||
label="素材画布工具栏"
|
||||
@@ -2002,6 +2102,7 @@ export function AssetCanvasSurface({
|
||||
<CanvasToolbarDivider />
|
||||
<CanvasToolbarGroup>
|
||||
<CanvasChromeButton
|
||||
ref={modalFallbackFocusRef}
|
||||
label="AI 生成图片"
|
||||
icon={<Sparkles size={15} aria-hidden="true" />}
|
||||
onClick={openGenerationDialog}
|
||||
@@ -2108,8 +2209,10 @@ export function AssetCanvasSurface({
|
||||
role="presentation"
|
||||
>
|
||||
<section
|
||||
ref={modalDialogRef}
|
||||
className="asset-canvas-surface__generation-dialog"
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
aria-modal="true"
|
||||
aria-labelledby="asset-canvas-generation-title"
|
||||
>
|
||||
@@ -2266,8 +2369,10 @@ export function AssetCanvasSurface({
|
||||
role="presentation"
|
||||
>
|
||||
<section
|
||||
ref={modalDialogRef}
|
||||
className="asset-canvas-surface__generation-dialog"
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
aria-modal="true"
|
||||
aria-labelledby="asset-canvas-service-identity-title"
|
||||
>
|
||||
@@ -2327,8 +2432,10 @@ export function AssetCanvasSurface({
|
||||
role="presentation"
|
||||
>
|
||||
<section
|
||||
ref={modalDialogRef}
|
||||
className="asset-canvas-surface__generation-dialog"
|
||||
role="dialog"
|
||||
tabIndex={-1}
|
||||
aria-modal="true"
|
||||
aria-labelledby="asset-canvas-exit-title"
|
||||
>
|
||||
@@ -2373,6 +2480,8 @@ export function AssetCanvasSurface({
|
||||
{lifecycle.kind === 'canvas.generating' ? (
|
||||
<section
|
||||
className="asset-canvas-surface__operation-overlay"
|
||||
inert={modalOpen || undefined}
|
||||
aria-hidden={modalOpen || undefined}
|
||||
aria-live="polite"
|
||||
aria-label="图片生成进度"
|
||||
>
|
||||
@@ -2402,6 +2511,8 @@ export function AssetCanvasSurface({
|
||||
{lifecycle.kind === 'canvas.failed' ? (
|
||||
<section
|
||||
className="asset-canvas-surface__operation-overlay"
|
||||
inert={modalOpen || undefined}
|
||||
aria-hidden={modalOpen || undefined}
|
||||
role="alert"
|
||||
aria-label={failurePresentation?.ariaLabel}
|
||||
>
|
||||
@@ -2493,6 +2604,7 @@ export function AssetCanvasSurface({
|
||||
backgroundColor={backgroundColor}
|
||||
isInteractionPaused={backgroundInteractionLocked}
|
||||
inert={backgroundInteractionLocked || undefined}
|
||||
aria-hidden={modalOpen || undefined}
|
||||
isPanning={dragRef.current?.kind === 'pan'}
|
||||
onPointerDown={(event) => {
|
||||
if (backgroundInteractionLockedRef.current) return;
|
||||
@@ -2777,7 +2889,12 @@ export function AssetCanvasSurface({
|
||||
) : null}
|
||||
</aside>
|
||||
|
||||
<footer className="asset-canvas-surface__status" role="status">
|
||||
<footer
|
||||
className="asset-canvas-surface__status"
|
||||
role="status"
|
||||
inert={modalOpen || undefined}
|
||||
aria-hidden={modalOpen || undefined}
|
||||
>
|
||||
<span>
|
||||
{lifecycle.kind === 'canvas.editing'
|
||||
? '画布可编辑'
|
||||
|
||||
@@ -538,11 +538,17 @@ function renderSurface(
|
||||
};
|
||||
}
|
||||
|
||||
function expectAssetCanvasBackgroundLocked() {
|
||||
function expectAssetCanvasBackgroundLocked(
|
||||
hiddenFromAccessibilityTree = false,
|
||||
) {
|
||||
const toolbarShell = document.querySelector(
|
||||
'.asset-canvas-surface__toolbar-shell',
|
||||
);
|
||||
const viewport = document.querySelector('.asset-canvas-surface__viewport');
|
||||
const viewportTools = document.querySelector(
|
||||
'.asset-canvas-surface__viewport-tools',
|
||||
);
|
||||
const status = document.querySelector('.asset-canvas-surface__status');
|
||||
const generate = screen.getByRole('button', {
|
||||
name: 'AI 生成图片',
|
||||
hidden: true,
|
||||
@@ -554,6 +560,14 @@ function expectAssetCanvasBackgroundLocked() {
|
||||
|
||||
expect(toolbarShell?.hasAttribute('inert')).toBe(true);
|
||||
expect(viewport?.hasAttribute('inert')).toBe(true);
|
||||
expect(viewportTools?.hasAttribute('inert')).toBe(true);
|
||||
if (hiddenFromAccessibilityTree) {
|
||||
expect(toolbarShell?.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(viewport?.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(viewportTools?.getAttribute('aria-hidden')).toBe('true');
|
||||
expect(status?.hasAttribute('inert')).toBe(true);
|
||||
expect(status?.getAttribute('aria-hidden')).toBe('true');
|
||||
}
|
||||
expect(generate.disabled).toBe(true);
|
||||
expect(commit.disabled).toBe(true);
|
||||
}
|
||||
@@ -654,34 +668,70 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
|
||||
});
|
||||
|
||||
it('任一独立 modal 打开时都隔离背景焦点并阻断生成与提交端口', async () => {
|
||||
const user = userEvent.setup();
|
||||
const memory = memoryHost({
|
||||
initialDraft: draftFixture(scope, keyboardCanvas()),
|
||||
});
|
||||
const view = renderSurface(memory.host);
|
||||
await screen.findByText('画布可编辑');
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'AI 生成图片' }));
|
||||
const generateTrigger = screen.getByRole('button', {
|
||||
name: 'AI 生成图片',
|
||||
});
|
||||
generateTrigger.focus();
|
||||
await user.click(generateTrigger);
|
||||
expect(screen.getByRole('dialog', { name: 'AI 图片生成' })).toBeTruthy();
|
||||
expectAssetCanvasBackgroundLocked();
|
||||
expectAssetCanvasBackgroundLocked(true);
|
||||
expectLockedBackgroundCannotCallPaidPorts(memory);
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole('button', { name: '关闭图片生成面板' }),
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭图片生成面板' }));
|
||||
await user.type(
|
||||
screen.getByRole('textbox', { name: '图片提示词' }),
|
||||
'角色立绘',
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '继续确认' }));
|
||||
expect(screen.getByRole('dialog', { name: '确认图片生成' })).toBeTruthy();
|
||||
const generationFirst = screen.getByRole('button', {
|
||||
name: '关闭图片生成面板',
|
||||
});
|
||||
const generationLast = screen.getByRole('button', { name: '确认并生成' });
|
||||
await waitFor(() => expect(document.activeElement).toBe(generationFirst));
|
||||
generationLast.focus();
|
||||
await user.tab();
|
||||
expect(document.activeElement).toBe(generationFirst);
|
||||
await user.tab({ shift: true });
|
||||
expect(document.activeElement).toBe(generationLast);
|
||||
await user.keyboard('{Escape}');
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: '确认图片生成' })).toBeNull(),
|
||||
);
|
||||
expect(document.activeElement).toBe(generateTrigger);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '选择图层 第一层' }));
|
||||
fireEvent.click(screen.getByRole('button', { name: '取消并返回' }));
|
||||
const exitTrigger = screen.getByRole('button', { name: '取消并返回' });
|
||||
exitTrigger.focus();
|
||||
await user.click(exitTrigger);
|
||||
expect(screen.getByRole('dialog', { name: '返回资源总览' })).toBeTruthy();
|
||||
expectAssetCanvasBackgroundLocked();
|
||||
expectAssetCanvasBackgroundLocked(true);
|
||||
expectLockedBackgroundCannotCallPaidPorts(memory);
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole('button', { name: '继续编辑' }),
|
||||
),
|
||||
);
|
||||
fireEvent.click(screen.getByRole('button', { name: '继续编辑' }));
|
||||
const exitFirst = screen.getByRole('button', { name: '放弃草稿' });
|
||||
const exitLast = screen.getByRole('button', { name: '保留草稿并退出' });
|
||||
exitLast.focus();
|
||||
await user.tab();
|
||||
expect(document.activeElement).toBe(exitFirst);
|
||||
await user.keyboard('{Escape}');
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole('dialog', { name: '返回资源总览' })).toBeNull(),
|
||||
);
|
||||
expect(document.activeElement).toBe(exitTrigger);
|
||||
view.unmount();
|
||||
|
||||
const identityMemory = memoryHost({
|
||||
@@ -699,13 +749,31 @@ describe('Tauri 素材创作无限画布独立 Surface', () => {
|
||||
expect(
|
||||
await screen.findByRole('dialog', { name: '确认旧生成任务服务' }),
|
||||
).toBeTruthy();
|
||||
expectAssetCanvasBackgroundLocked();
|
||||
expectAssetCanvasBackgroundLocked(true);
|
||||
expectLockedBackgroundCannotCallPaidPorts(identityMemory);
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole('button', { name: '暂不恢复旧任务' }),
|
||||
),
|
||||
);
|
||||
const serviceFirst = screen.getByRole('button', {
|
||||
name: '暂不恢复旧任务',
|
||||
});
|
||||
const serviceLast = screen.getByRole('button', {
|
||||
name: '确认当前服务并恢复原任务',
|
||||
});
|
||||
serviceLast.focus();
|
||||
await user.tab();
|
||||
expect(document.activeElement).toBe(serviceFirst);
|
||||
await user.keyboard('{Escape}');
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByRole('dialog', { name: '确认旧生成任务服务' }),
|
||||
).toBeNull(),
|
||||
);
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole('button', { name: 'AI 生成图片' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('原 generation recovery 完成前保持 recovering 并锁闭付费端口', async () => {
|
||||
|
||||
@@ -14337,3 +14337,20 @@
|
||||
- 决策:预览 secrets 权威源固定为 Jenkins 宿主 `/data/jenkins/preview-secrets/.env.secrets.local`;目录 / 文件由 Jenkins 运行账号所有且权限分别为 `0700` / `0600`,缺失、链接、非普通文件、owner 异常或权限过宽时构建失败关闭。
|
||||
- 构建边界:只通过 BuildKit secret mount 把文件提供给 `api-runtime` stage,并安装为 `/srv/genarrative/.env.secrets.local` (`genarrative:genarrative`, `0400`)。文件不进 Git、build context、日志或 artifact,不进入 Web / Nginx、SpacetimeDB 或其它镜像。容器显式运行 env 优先覆盖内置值。
|
||||
- 更新与分发:固定源文件更新后必须重建并替换镜像,只重启容器无效。镜像可读者必然可提取内置 secrets,因此只允许留在当前受信任内网 Docker 主机,禁止 push、`docker save` 或作为 artifact 导出到跨信任边界的 registry、主机或存储。
|
||||
|
||||
## 2026-08-22 AGC Tauri 命令调用可达性失败关闭
|
||||
|
||||
- 决策:`check-config.mjs` 的 App 调用扫描必须识别现役精确形态:裸 `invoke`、`directInvoke`、素材画布的 `invokeInput` / `invokeAuthenticatedInput` wrapper,以及对象字段 `.invoke`;不以包含 `invoke` 的任意名称、动态命令变量、注释、字符串、模板或正则文本作为可达证据。
|
||||
- allowlist 边界:前端源码已调用的命令不得继续保留在 explicit native-only allowlist。allowlist 只承载确实由原生窗口或原生侧流程触发、App 源码不直接调用的 handler;源码调用与 allowlist 必须互斥。
|
||||
- 门禁:逐文件使用仓库锁定的 TypeScript AST 解析,设置文件数量、单文件 / 总源码长度、命令长度和调用数量上限。回归测试同时锁定直接、wrapper、对象字段的正例与诱饵 / 动态 / 畸形输入的反例,并证明删除真实 wrapper 调用后 handler 可达性检查失败,不能由错误 allowlist 继续误绿。
|
||||
|
||||
## 2026-08-22 AGC 素材画布生成恢复与提交边界
|
||||
|
||||
- 凭据失败:`configuration-missing` / `authentication-required` 不是永久业务失败。尚无远端副作用时保留原 generation、commit 与 idempotency 身份并按 `context-preparing` / `prepared` 恢复;已有 operation 时只能进入 reconciliation。旧 `failed` 账本仅按这两个错误码和已有副作用证据白名单迁移,确定性业务失败继续终态。
|
||||
- 提交边界:恢复先按项目、草稿和终态预过滤,再解析凭据;上下文准备完成后、首次可计费 POST 前必须重新校验冻结的平台账号会话。草稿一旦 cancelled,不再公开投影、下载后 staging 或提交资产,私有账本保留最后一份 durable 证据。
|
||||
- staging 原子恢复:稳定 staging token 必须同时校验媒体类型、摘要、尺寸与已有文件。图片先落盘或 metadata 先落盘的同身份半提交允许补齐后幂等重放;任一身份或内容冲突失败关闭并保留现场,不覆盖残片。
|
||||
|
||||
## 2026-08-22 AGC 素材画布模态框焦点合同
|
||||
|
||||
- 素材生成确认、离开确认和旧服务身份确认沿用现有独立 modal,不在当前面板下方追加内容。modal 打开后焦点必须进入对话框,并同时隔离工具栏、画布视口、缩放/小地图、状态区和并存操作层,使背景从键盘焦点顺序与 accessibility tree 中退出。
|
||||
- `Tab` / `Shift+Tab` 必须在当前 modal 内双向循环;非异步 pending 状态允许 `Escape` 安全关闭。关闭后优先恢复到原触发器,自动弹出的 modal 则回退到可操作的工具栏入口,不能把焦点遗留在已卸载节点或被隔离背景中。
|
||||
|
||||
Reference in New Issue
Block a user