修复素材画布生成恢复与模态交互

素材生成凭据失败保留原幂等身份并支持登录后恢复
素材生成在远端提交、下载暂存和正式提交前复验会话与草稿状态
稳定 staging token 支持半提交修复并对冲突和文件系统异常失败关闭
素材画布三类模态框增加焦点圈闭、背景隔离和焦点恢复
Tauri 命令可达性改为 TypeScript AST 扫描并收紧 allowlist
补充生成恢复、取消态、staging 与模态交互回归及项目决策记录
This commit is contained in:
2026-08-22 21:54:36 +08:00
parent 32da013545
commit 01b10aafb2
7 changed files with 1197 additions and 112 deletions
@@ -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 则回退到可操作的工具栏入口,不能把焦点遗留在已卸载节点或被隔离背景中。