diff --git a/apps/ai-game-creator-shell/scripts/check-config.mjs b/apps/ai-game-creator-shell/scripts/check-config.mjs index 4ad40ee2a..bb8a9748f 100644 --- a/apps/ai-game-creator-shell/scripts/check-config.mjs +++ b/apps/ai-game-creator-shell/scripts/check-config.mjs @@ -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('generic_direct_command', {}); + invokeInput < Result > ('input_wrapper_command', {}); + invokeAuthenticatedInput( + 'authenticated_input_wrapper_command', + {}, + ); + input.invoke('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('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('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')) { diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs index 28b5f56bb..94a07ba68 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas.rs @@ -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), String> { validate_plain_component(token, "stagedImageToken", 128)?; - let metadata = read_agent_runtime_json_sidecar_with_max_bytes::( - 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, String> { + validate_plain_component(token, "stagedImageToken", 128)?; + let metadata = read_agent_runtime_json_sidecar_with_max_bytes::( + 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, diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs index eec7d1e65..b66561170 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas/generation.rs @@ -765,6 +765,72 @@ fn set_private_phase( write_generation_ledger(root, ledger) } +fn retryable_credential_failure_phase( + ledger: &AssetCanvasGenerationLedger, +) -> Option { + if ledger.phase != GenerationLedgerPhase::Failed + || !matches!( + ledger.error_code.as_deref(), + Some("configuration-missing" | "authentication-required") + ) + { + return None; + } + Some(if ledger.operation_id.is_some() { + GenerationLedgerPhase::ReconciliationRequired + } else if ledger.request_body_json.is_some() { + GenerationLedgerPhase::Prepared + } else { + GenerationLedgerPhase::ContextPreparing + }) +} + +fn migrate_retryable_credential_failure( + root: &Path, + ledger: &mut AssetCanvasGenerationLedger, +) -> Result { + let Some(phase) = retryable_credential_failure_phase(ledger) else { + return Ok(false); + }; + ensure_generation_draft_active(root, ledger)?; + ledger.phase = phase; + write_generation_ledger(root, ledger)?; + Ok(true) +} + +fn validate_generation_draft_active( + ledger: &AssetCanvasGenerationLedger, + draft: &AssetCanvasDraft, +) -> Result<(), String> { + if draft.project_id != ledger.project_id + || draft.draft_id != ledger.draft_id + || draft.intent != ledger.intent + || draft.source_asset_id != ledger.source_asset_id + { + return Err("素材画布生成账本与草稿身份不一致".to_string()); + } + if draft.status == AssetCanvasDraftStatus::Cancelled { + return Err( + "generation-cancelled: 素材画布草稿已取消;私有生成账本将保留,但不得继续公开投影或提交资产" + .to_string(), + ); + } + if draft.status == AssetCanvasDraftStatus::Committed { + return Err("素材画布草稿已经提交,不能继续推进其它生成账本".to_string()); + } + Ok(()) +} + +fn ensure_generation_draft_active( + root: &Path, + ledger: &AssetCanvasGenerationLedger, +) -> Result<(), String> { + let _lock = acquire_asset_canvas_draft_lock(root)?; + let draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())?; + validate_generation_draft_active(ledger, &draft) +} + fn upsert_public_generation_record( root: &Path, ledger: &AssetCanvasGenerationLedger, @@ -774,12 +840,7 @@ fn upsert_public_generation_record( let _lock = acquire_asset_canvas_draft_lock(root)?; let mut draft = read_asset_canvas_draft_locked(root, &ledger.project_id, &ledger.draft_id)? .ok_or_else(|| "素材画布草稿不存在".to_string())?; - if draft.project_id != ledger.project_id - || draft.intent != ledger.intent - || draft.source_asset_id != ledger.source_asset_id - { - return Err("素材画布生成账本与草稿身份不一致".to_string()); - } + validate_generation_draft_active(ledger, &draft)?; let now = asset_canvas_now(); let output_asset_id = ledger .commit_result @@ -1785,6 +1846,10 @@ fn mark_generation_error( error_code: &str, emit: &mut (dyn FnMut(AssetCanvasGenerationProgressEvent) + Send), ) -> Result<(), String> { + // Cancellation is authoritative and must not be overwritten by a late + // network/commit error. The private ledger keeps its last durable phase as + // evidence while all public projection and candidate persistence stop. + ensure_generation_draft_active(root, ledger)?; set_private_phase( root, ledger, @@ -1961,11 +2026,13 @@ async fn reconcile_generation( if ledger.phase == GenerationLedgerPhase::AssetDurableCommitted { return committed_execution_from_private_result(root, &ledger); } + migrate_retryable_credential_failure(root, &mut ledger)?; if ledger.phase == GenerationLedgerPhase::Failed { return Err(sanitized_generation_error( ledger.error_code.as_deref().unwrap_or("generation-failed"), )); } + ensure_generation_draft_active(root, &ledger)?; bind_generation_platform_owner(root, &mut ledger, platform_session)?; ensure_frozen_generation_platform_session(platform_session)?; let configuration_fingerprint = canvas_api_identity_fingerprint(api_base_url, api_mode); @@ -2030,7 +2097,12 @@ async fn reconcile_generation( } else { "platform-service-configuration" }; - mark_generation_error(root, &mut ledger, false, code, emit)?; + if code == "authentication-required" { + ledger.error_code = Some(code.to_string()); + write_generation_ledger(root, &mut ledger)?; + } else { + mark_generation_error(root, &mut ledger, false, code, emit)?; + } return Err(sanitized_generation_error(code)); } }; @@ -2041,7 +2113,12 @@ async fn reconcile_generation( ensure_reference_states(root, &mut ledger, &client, api_base_url, api_mode).await { let code = error.code(); - mark_generation_error(root, &mut ledger, false, code, emit)?; + if code == "authentication-required" { + ledger.error_code = Some(code.to_string()); + write_generation_ledger(root, &mut ledger)?; + } else { + mark_generation_error(root, &mut ledger, false, code, emit)?; + } return Err(sanitized_generation_error(code)); } let (endpoint, body_json) = build_generation_request_snapshot(&ledger)?; @@ -2056,6 +2133,11 @@ async fn reconcile_generation( GenerationLedgerPhase::Prepared | GenerationLedgerPhase::ReconciliationRequired ) && ledger.operation_id.is_none() { + ensure_generation_draft_active(root, &ledger)?; + // Context/reference preparation can contain several awaits. Re-check the + // exact account snapshot after those side effects and immediately before + // the first chargeable/idempotent remote submission. + ensure_frozen_generation_platform_session(platform_session)?; let endpoint = ledger .endpoint .as_deref() @@ -2086,7 +2168,16 @@ async fn reconcile_generation( let (code, reconciliation) = classify_canvas_submit_error(response).await; let reconciliation = preserve_submit_reconciliation(&ledger.phase, code, reconciliation); - mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; + if code == "authentication-required" && !reconciliation { + set_private_phase( + root, + &mut ledger, + GenerationLedgerPhase::Prepared, + Some(code), + )?; + } else { + mark_generation_error(root, &mut ledger, reconciliation, code, emit)?; + } return Err(sanitized_classified_generation_error(code, reconciliation)); } let submission = match response.json::().await { @@ -2204,6 +2295,7 @@ async fn reconcile_generation( let download = match download_result { Ok(Some(download)) => { ensure_frozen_generation_platform_session(platform_session)?; + ensure_generation_draft_active(root, &ledger)?; download } _ => { @@ -2260,6 +2352,7 @@ async fn reconcile_generation( if ledger.phase == GenerationLedgerPhase::MediaDownloaded { ensure_frozen_generation_platform_session(platform_session)?; + ensure_generation_draft_active(root, &ledger)?; if !synchronize_staged_image_revision(root, &mut ledger)? { mark_generation_error( root, @@ -2440,7 +2533,11 @@ pub(crate) async fn generate_asset_canvas_image_at( Ok(value) => value, Err(_) => { let code = "configuration-missing"; - mark_generation_error(root, &mut ledger, false, code, &mut emit)?; + // Credential availability is not a durable business outcome. Keep + // the original private identity retryable so login/configuration + // repair can resume the same idempotency keys. + ledger.error_code = Some(code.to_string()); + write_generation_ledger(root, &mut ledger)?; return Err(sanitized_generation_error(code)); } }; @@ -2504,29 +2601,56 @@ pub(crate) async fn recover_asset_canvas_generations_at( events: Vec::new(), }); } + let draft_status = { + let _lock = acquire_asset_canvas_draft_lock(root)?; + read_asset_canvas_draft_locked(root, &input.expected_project_id, &input.draft_id)? + .ok_or_else(|| "素材画布草稿不存在".to_string())? + .status + }; + if draft_status == AssetCanvasDraftStatus::Cancelled { + return Ok(RecoverAssetCanvasGenerationsExecution { + result: RecoverAssetCanvasGenerationsResult { + resumed_generation_ids: Vec::new(), + service_identity_confirmations: Vec::new(), + }, + events: Vec::new(), + }); + } + let mut recoverable_generation_ids = Vec::new(); + for generation_id in generation_ids { + let Some(ledger) = read_generation_ledger(root, &generation_id)? else { + continue; + }; + if ledger.project_id == input.expected_project_id + && ledger.draft_id == input.draft_id + && ledger.phase != GenerationLedgerPhase::AssetDurableCommitted + && (ledger.phase != GenerationLedgerPhase::Failed + || retryable_credential_failure_phase(&ledger).is_some()) + { + recoverable_generation_ids.push(generation_id); + } + } + if recoverable_generation_ids.is_empty() { + return Ok(RecoverAssetCanvasGenerationsExecution { + result: RecoverAssetCanvasGenerationsResult { + resumed_generation_ids: Vec::new(), + service_identity_confirmations: Vec::new(), + }, + events: Vec::new(), + }); + } let (api_base_url, api_key, platform_session) = resolve_canvas_sync_api_credentials(None, None) .map_err(|_| sanitized_generation_error("configuration-missing"))?; let api_mode = CanvasGenerationApiMode { api_key }; let mut resumed = Vec::new(); let mut service_identity_confirmations = Vec::new(); let mut events = Vec::new(); - for generation_id in generation_ids { - let Some(initial_ledger) = read_generation_ledger(root, &generation_id)? else { - continue; - }; - if initial_ledger.project_id != input.expected_project_id - || initial_ledger.draft_id != input.draft_id - || matches!( - initial_ledger.phase, - GenerationLedgerPhase::AssetDurableCommitted | GenerationLedgerPhase::Failed - ) - { - continue; - } - let _guard = generation_singleflight_lock(&initial_ledger.project_id, &generation_id).await; + for generation_id in recoverable_generation_ids { + let _guard = generation_singleflight_lock(&input.expected_project_id, &generation_id).await; let Some(mut ledger) = read_generation_ledger(root, &generation_id)? else { continue; }; + migrate_retryable_credential_failure(root, &mut ledger)?; if ledger.project_id != input.expected_project_id || ledger.draft_id != input.draft_id || matches!( @@ -2536,6 +2660,9 @@ pub(crate) async fn recover_asset_canvas_generations_at( { continue; } + if bind_generation_platform_owner(root, &mut ledger, platform_session.as_ref()).is_err() { + continue; + } match prepare_generation_service_identity(root, &mut ledger, &api_base_url, &api_mode) { Ok(CanvasServiceIdentityDecision::ConfirmationRequired(confirmation)) => { service_identity_confirmations.push(confirmation); @@ -2545,9 +2672,6 @@ pub(crate) async fn recover_asset_canvas_generations_at( Err(_) => {} } resumed.push(generation_id.clone()); - if bind_generation_platform_owner(root, &mut ledger, platform_session.as_ref()).is_err() { - continue; - } match reconcile_generation( root, ledger, @@ -4321,5 +4445,366 @@ mod tests { let public = serde_json::to_string(&public_draft).expect("serialize failed draft"); assert!(!public.contains(prompt)); assert!(!public.contains(&input.idempotency_key)); + let ledger = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read retryable generation ledger") + .expect("missing-login ledger exists"); + assert_eq!(ledger.phase, GenerationLedgerPhase::ContextPreparing); + assert_eq!(ledger.error_code.as_deref(), Some("configuration-missing")); + assert_eq!(ledger.idempotency_key, input.idempotency_key); + assert_eq!(ledger.commit_id, input.commit_id); + + let listener = TcpListener::bind("127.0.0.1:0").expect("bind login recovery server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("login recovery address") + ); + let signed_url = format!("{base_url}/login-recovered.png"); + let server_signed_url = signed_url.clone(); + let recovered_png = test_png(); + let server = std::thread::spawn(move || { + listener + .set_nonblocking(true) + .expect("set login recovery fixture nonblocking"); + for request_index in 0..6 { + let mut stream = accept_generation_fixture_connection( + &listener, + "login recovery fixture", + request_index, + ); + let request = read_http_request(&mut stream); + if request.starts_with("GET /api/editor/projects ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"projects": [{ + "projectId": "login-recovery-project", + "title": "阶段五缺失配置测试", + }]}}), + ); + } else if request.starts_with("GET /api/editor/assets/library ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"library": {"folders": [{ + "folderId": "login-recovery-folder", + "label": "阶段五缺失配置测试", + }]}}}), + ); + } else if request.starts_with("POST /api/editor/images/generations ") { + write_json( + &mut stream, + "202 Accepted", + serde_json::json!({"data": { + "operationId": "login-recovery-operation", + "status": "queued", + "pollAfterMs": 0, + }}), + ); + } else if request.starts_with( + "GET /api/runtime/external-generation/jobs/login-recovery-operation ", + ) { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": { + "operationId": "login-recovery-operation", + "status": "completed", + "pollAfterMs": 0, + "result": {"resource": { + "resourceId": "login-recovery-resource", + "objectKey": "generated/login-recovered.png", + "assetObjectId": "login-recovery-object", + }} + }}), + ); + } else if request.starts_with("GET /api/assets/read-url?") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"read": {"signedUrl": server_signed_url}}), + ); + } else if request.starts_with("GET /login-recovered.png ") { + write_png(&mut stream, &recovered_png); + } else { + panic!("unexpected login recovery request: {request}"); + } + } + }); + let _session = crate::platform_session::install_test_platform_session( + "original-login-owner", + "fresh-login-token", + &base_url, + ); + let recovered = recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: ledger.draft_id.clone(), + }, + |_| {}, + ) + .await + .expect("fresh login resumes original private identity"); + server.join().expect("join login recovery server"); + assert_eq!( + recovered.result.resumed_generation_ids, + vec![input.generation_id.clone()] + ); + let resumed = read_generation_ledger(directory.path(), &input.generation_id) + .expect("read resumed login ledger") + .expect("resumed login ledger exists"); + assert_eq!(resumed.idempotency_key, input.idempotency_key); + assert_eq!(resumed.commit_id, input.commit_id); + assert_eq!( + resumed.platform_owner_user_id.as_deref(), + Some("original-login-owner") + ); + assert_eq!(resumed.phase, GenerationLedgerPhase::AssetDurableCommitted); + assert_eq!(resumed.error_code, None); + assert!(resumed.commit_result.is_some()); + let manifest = + current_asset_canvas_manifest(directory.path()).expect("read login-recovered manifest"); + assert_eq!(manifest.assets.len(), 1); + } + + #[tokio::test] + async fn recovery_filters_mismatched_and_terminal_ledgers_before_resolving_credentials() { + let project_id = "generation-recovery-prefilter"; + let (directory, draft) = create_generation_fixture(project_id, "生成恢复预过滤测试"); + let mut terminal = accepted_ledger( + project_id, + &draft, + "https://editor.example.test", + "private-key", + ); + terminal.phase = GenerationLedgerPhase::Failed; + terminal.error_code = Some("generation-failed".to_string()); + write_generation_ledger(directory.path(), &mut terminal).expect("write terminal ledger"); + let mut mismatched = accepted_ledger( + "other-project", + &draft, + "https://editor.example.test", + "private-key", + ); + write_generation_ledger(directory.path(), &mut mismatched) + .expect("write mismatched ledger"); + + let recovered = recover_asset_canvas_generations_at( + directory.path(), + &RecoverAssetCanvasGenerationsInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id, + }, + |_| {}, + ) + .await + .expect("terminal and mismatched ledgers do not require credentials"); + assert!(recovered.result.resumed_generation_ids.is_empty()); + assert!(recovered.result.service_identity_confirmations.is_empty()); + assert!(recovered.events.is_empty()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn platform_session_is_revalidated_after_context_and_before_remote_submit() { + let project_id = "generation-session-submit-gate"; + let project_name = "生成提交登录态复验测试"; + let (directory, draft) = create_generation_fixture(project_id, project_name); + let listener = TcpListener::bind("127.0.0.1:0").expect("bind session gate server"); + let base_url = format!( + "http://{}", + listener.local_addr().expect("session gate address") + ); + let (ready_sender, ready_receiver) = mpsc::channel(); + let (resume_sender, resume_receiver) = mpsc::channel(); + let (request_sender, request_receiver) = mpsc::channel(); + let server = std::thread::spawn(move || { + listener + .set_nonblocking(true) + .expect("set session gate fixture nonblocking"); + for request_index in 0..2 { + let mut stream = accept_generation_fixture_connection( + &listener, + "session gate fixture", + request_index, + ); + let request = read_http_request(&mut stream); + request_sender + .send(request.clone()) + .expect("capture session gate request"); + if request.starts_with("GET /api/editor/projects ") { + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"projects": [{ + "projectId": "remote-project", + "title": project_name, + }]}}), + ); + } else if request.starts_with("GET /api/editor/assets/library ") { + ready_sender.send(()).expect("signal context prepared"); + resume_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("resume context response after session switch"); + write_json( + &mut stream, + "200 OK", + serde_json::json!({"data": {"library": {"folders": [{ + "folderId": "remote-folder", + "label": project_name, + }]}}}), + ); + } else { + panic!("unexpected session gate request: {request}"); + } + } + }); + let _session = crate::platform_session::install_test_platform_session( + "session-owner", + "session-token", + &base_url, + ); + let input = generation_input( + directory.path(), + project_id, + &draft, + "切换登录态后不得提交远端生成", + ); + let root = directory.path().to_path_buf(); + let generation_id = input.generation_id.clone(); + let generation = + tokio::spawn( + async move { generate_asset_canvas_image_at(&root, &input, |_| {}).await }, + ); + ready_receiver + .recv_timeout(Duration::from_secs(5)) + .expect("context preparation reached final await"); + crate::platform_session::clear_platform_session(2); + resume_sender.send(()).expect("release context response"); + let error = generation + .await + .expect("join session gate generation") + .err() + .expect("changed session must stop before submit"); + server.join().expect("join session gate server"); + assert!(error.contains("登录态已变化")); + let requests = std::iter::from_fn(|| request_receiver.try_recv().ok()).collect::>(); + assert_eq!(requests.len(), 2); + assert!(requests.iter().all(|request| request.starts_with("GET "))); + let ledger = read_generation_ledger(directory.path(), &generation_id) + .expect("read session gate ledger") + .expect("session gate ledger exists"); + assert_eq!(ledger.phase, GenerationLedgerPhase::Prepared); + assert!(ledger.operation_id.is_none()); + } + + #[test] + fn cancelled_draft_rejects_public_generation_projection_and_keeps_private_evidence() { + let project_id = "cancelled-generation-projection"; + let (directory, draft) = create_generation_fixture(project_id, "取消生成投影测试"); + let mut ledger = accepted_ledger( + project_id, + &draft, + "https://editor.example.test", + "private-key", + ); + write_generation_ledger(directory.path(), &mut ledger).expect("write accepted ledger"); + let cancelled = discard_asset_canvas_draft_at( + directory.path(), + &DiscardAssetCanvasDraftInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id.clone(), + expected_draft_revision: draft.revision, + }, + ) + .expect("cancel generation draft"); + assert_eq!(cancelled.status, "cancelled"); + + let error = mark_generation_error( + directory.path(), + &mut ledger, + true, + "poll-result-unknown", + &mut |_| {}, + ) + .expect_err("cancelled draft must reject public projection"); + assert!(error.contains("generation-cancelled")); + let persisted = read_generation_ledger(directory.path(), &ledger.generation_id) + .expect("read private cancelled ledger") + .expect("private cancelled ledger exists"); + assert_eq!(persisted.phase, GenerationLedgerPhase::Accepted); + assert_eq!(persisted.error_code, None); + assert_eq!(persisted.operation_id, ledger.operation_id); + let public = read_asset_canvas_draft_at( + directory.path(), + &ReadAssetCanvasDraftInput { + project_path: directory.path().to_string_lossy().into_owned(), + expected_project_id: project_id.to_string(), + draft_id: draft.draft_id, + }, + ) + .expect("read cancelled draft") + .draft + .expect("cancelled draft exists"); + assert_eq!(public.status, AssetCanvasDraftStatus::Cancelled); + assert!(public.generations.is_empty()); + } + + #[test] + fn legacy_failed_credential_ledgers_migrate_by_remote_side_effect_boundary_only() { + let project_id = "legacy-credential-ledger-migration"; + let (directory, draft) = create_generation_fixture(project_id, "旧凭据账本恢复测试"); + for (has_request, has_operation, expected_phase) in [ + (false, false, GenerationLedgerPhase::ContextPreparing), + (true, false, GenerationLedgerPhase::Prepared), + (true, true, GenerationLedgerPhase::ReconciliationRequired), + ] { + let mut ledger = accepted_ledger( + project_id, + &draft, + "https://editor.example.test", + "private-key", + ); + ledger.phase = GenerationLedgerPhase::Failed; + ledger.error_code = Some("authentication-required".to_string()); + if !has_request { + ledger.request_body_json = None; + ledger.request_body_sha256 = None; + ledger.endpoint = None; + } + if !has_operation { + ledger.operation_id = None; + ledger.poll_after_ms = None; + } + write_generation_ledger(directory.path(), &mut ledger) + .expect("write legacy failed credential ledger"); + assert!( + migrate_retryable_credential_failure(directory.path(), &mut ledger) + .expect("migrate legacy failed credential ledger") + ); + assert_eq!(ledger.phase, expected_phase); + assert_eq!( + ledger.error_code.as_deref(), + Some("authentication-required") + ); + } + + let mut business_failure = accepted_ledger( + project_id, + &draft, + "https://editor.example.test", + "private-key", + ); + business_failure.phase = GenerationLedgerPhase::Failed; + business_failure.error_code = Some("generation-rejected".to_string()); + write_generation_ledger(directory.path(), &mut business_failure) + .expect("write definitive business failure"); + assert!( + !migrate_retryable_credential_failure(directory.path(), &mut business_failure) + .expect("leave definitive business failure terminal") + ); + assert_eq!(business_failure.phase, GenerationLedgerPhase::Failed); } } diff --git a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs index ea302ecea..ab7ec8fc9 100644 --- a/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs +++ b/apps/ai-game-creator-shell/src-tauri/src/project/asset_canvas_tests.rs @@ -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, diff --git a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx index 3fc6544b7..765c7bf85 100644 --- a/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx +++ b/apps/ai-game-creator-shell/src/features/asset-canvas/AssetCanvasSurface.tsx @@ -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(MODAL_FOCUSABLE_SELECTOR), + ).filter( + (element) => + element.getAttribute('aria-hidden') !== 'true' && + !element.closest('[inert]'), + ); +} + type DragState = | { kind: 'pan'; @@ -533,6 +553,10 @@ export function AssetCanvasSurface({ const generationFocusEpochRef = useRef(0); const generationStopButtonRef = useRef(null); const modalInitialFocusRef = useRef(null); + const modalDialogRef = useRef(null); + const modalReturnFocusRef = useRef(null); + const modalFallbackFocusRef = useRef(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({