Files
Genarrative/apps/ai-game-creator-shell/src/features/app-shell/useRecentProjects.ts
T
k88936 2daf2c8a7f
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
修复错误正文收敛合并后遗留的前端失败测试
- 恢复合并时丢失的 homeCreationFailure 安全正文实现:按工作区是否已建好拼接脱敏后的 IPC / Provider / 宿主失败原因
- errorReporting:映射外 Tauri 失败被 ClientAuthErrorWrapper 序列化成 `{}` 时落回兜底文案,不再把 `{}` 当正文展示
- useRecentProjects:提权失败分类改看原始正文,避免脱敏把 `(DACL …)` 连同路径吞成 `<path>` 后误判可重试、再弹一次 UAC
- PluginPanelHost:插件面板是不可信内容,原始错误只进错误池,用户可见文案保持「插件面板加载失败」
- platformSession / platformSessionRetry:订阅替身返回 Promise,对齐真实 subscribeClientAuthState 契约
- 润色失败、资源版本替换、首页创建失败、启动浮层的测试期望对齐脱敏后的详情文案
2026-10-07 13:29:47 +08:00

435 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
type Dispatch,
type SetStateAction,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { resolveTauriInvoke } from '../../app/tauri';
import type { LocalProjectDirectoryStatus, TauriInvoke } from '../../app/types';
import {
clientErrorRawText,
visibleClientErrorMessage,
} from '../../services/errorReporting';
import {
isAbsoluteProjectPath,
projectPathHasControlCharacter,
} from '../project-summary/projectSummary';
import { clearAclElevationDenials } from './aclElevation';
import { buildRecentProjectRows } from './model';
import {
readWorkspacePreferences,
rememberRecentWorkspace as rememberRecentWorkspacePreference,
removeRecentWorkspace as removeRecentWorkspacePreference,
subscribeWorkspacePreferences,
} from './workspacePreferences';
const RECENT_WORKSPACE_CHECK_TIMEOUT_MS = 5_000;
/** 单次检查失败后的就地重试退避,数组长度即重试次数。 */
const RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS = [300];
/** 一轮结束仍有可重试失败时重跑整张列表的退避,数组长度即重跑次数上限。 */
const RECENT_WORKSPACE_FAILURE_RECHECK_DELAYS_MS = [15_000, 45_000, 120_000];
/**
* 提权/权限类失败不重试:Rust 侧会重新走 `Start-Process -Verb RunAs -Wait`,
* 重试等于在用户刚点「否」后再弹一次 UAC。
* `AGC_ACL_ELEVATION_DENIED` 是 Rust 侧用户取消 UAC 的稳定标记(config.rs),
* 其余为 ACL/DACL 判据与历史文案,与 `windows_acl_error_may_need_elevation` 同口径。
*/
const RECENT_WORKSPACE_ELEVATION_ERROR_MARKERS = [
'AGC_ACL_ELEVATION_DENIED',
'DACL',
'权限',
'error 5',
'安全对象不属于当前用户',
'启用 Windows',
'特权',
'1300',
'AGC ACL 提权修复未成功',
];
type RecentWorkspaceInspection = {
path: string;
status: LocalProjectDirectoryStatus | null;
/** false 表示提权/权限类失败:既不重试,也不驱动整表重查。 */
retryable: boolean;
};
function recentWorkspaceFailureIsRetryable(message: string): boolean {
return !RECENT_WORKSPACE_ELEVATION_ERROR_MARKERS.some((marker) =>
message.includes(marker),
);
}
async function inspectRecentWorkspace(
invoke: TauriInvoke,
workspace: string,
): Promise<RecentWorkspaceInspection> {
let timeoutHandle: number | undefined;
try {
const status = await Promise.race([
invoke<LocalProjectDirectoryStatus>('inspect_local_project_directory', {
projectPath: workspace,
}),
new Promise<never>((_, reject) => {
timeoutHandle = window.setTimeout(
() => reject(new Error('项目目录检查超时')),
RECENT_WORKSPACE_CHECK_TIMEOUT_MS,
);
}),
]);
return { path: workspace, status, retryable: true };
} catch (error) {
// 分类判据看**原始**正文:脱敏会把 `C:\a\b(DACL …)` 这类无空格路径连同后面的 DACL
// 一起吞成 `<path>`,标记消失后提权失败会被误判成可重试,等于在用户点「否」后再弹一次 UAC。
return {
path: workspace,
status: null,
retryable: recentWorkspaceFailureIsRetryable(clientErrorRawText(error)),
};
} finally {
if (timeoutHandle !== undefined) {
window.clearTimeout(timeoutHandle);
}
}
}
async function inspectRecentWorkspaceWithRetry(
invoke: TauriInvoke,
workspace: string,
): Promise<RecentWorkspaceInspection> {
for (let attempt = 0; ; attempt += 1) {
const inspection = await inspectRecentWorkspace(invoke, workspace);
const retryDelayMs = RECENT_WORKSPACE_CHECK_RETRY_DELAYS_MS[attempt];
if (
inspection.status ||
!inspection.retryable ||
retryDelayMs === undefined
) {
return inspection;
}
await new Promise<void>((resolve) => {
window.setTimeout(resolve, retryDelayMs);
});
}
}
export function useRecentProjects(setStatus: Dispatch<SetStateAction<string>>) {
const setStatusRef = useRef(setStatus);
setStatusRef.current = setStatus;
const [recentWorkspaces, setRecentWorkspaces] = useState<string[]>([]);
const [recentWorkspaceStatuses, setRecentWorkspaceStatuses] = useState<
Record<string, LocalProjectDirectoryStatus | null>
>({});
const [recentWorkspaceRefreshKey, setRecentWorkspaceRefreshKey] = useState(0);
const [recentWorkspaceRefreshing, setRecentWorkspaceRefreshing] =
useState(false);
const [projectSearchQuery, setProjectSearchQuery] = useState('');
const recentWorkspacesRef = useRef(recentWorkspaces);
recentWorkspacesRef.current = recentWorkspaces;
const inspectionGenerationRef = useRef(0);
const failureRecheckTimerRef = useRef<number | undefined>(undefined);
const failureStreakRef = useRef(0);
const lastRetryableFailureKeyRef = useRef('');
// 提权类失败在用户再次主动操作前不再自动重试。
const nonRetryablePathsRef = useRef<Set<string>>(new Set());
const recentWorkspaceSignatureRef = useRef('');
const workspacePreferenceEventGenerationRef = useRef(0);
function applyWorkspacePreferencesSnapshot(
recent: readonly string[],
): boolean {
const signature = recent.join('\u0000');
if (recentWorkspaceSignatureRef.current === signature) {
return false;
}
recentWorkspaceSignatureRef.current = signature;
setRecentWorkspaces([...recent]);
setRecentWorkspaceRefreshKey((current) => current + 1);
return true;
}
useEffect(() => {
let disposed = false;
let unsubscribe: (() => void) | null = null;
const setup = async () => {
try {
unsubscribe = await subscribeWorkspacePreferences((snapshot) => {
if (!disposed) {
workspacePreferenceEventGenerationRef.current += 1;
applyWorkspacePreferencesSnapshot(snapshot.recentWorkspaces);
}
});
} catch (error) {
if (!disposed) {
setStatusRef.current(visibleClientErrorMessage(error));
}
}
if (disposed) {
return;
}
const eventGenerationAtRead =
workspacePreferenceEventGenerationRef.current;
try {
const snapshot = await readWorkspacePreferences();
if (
!disposed &&
workspacePreferenceEventGenerationRef.current ===
eventGenerationAtRead
) {
applyWorkspacePreferencesSnapshot(snapshot.recentWorkspaces);
}
} catch (error) {
if (!disposed) {
setStatusRef.current(visibleClientErrorMessage(error));
}
}
};
void setup();
return () => {
disposed = true;
unsubscribe?.();
};
}, []);
function scheduleFailureRecheck(retryableFailureCount: number) {
window.clearTimeout(failureRecheckTimerRef.current);
if (retryableFailureCount === 0) {
failureStreakRef.current = 0;
return;
}
const recheckDelayMs =
RECENT_WORKSPACE_FAILURE_RECHECK_DELAYS_MS[failureStreakRef.current];
if (recheckDelayMs === undefined) {
return;
}
failureStreakRef.current += 1;
failureRecheckTimerRef.current = window.setTimeout(() => {
failureRecheckTimerRef.current = undefined;
setRecentWorkspaceRefreshKey((current) => current + 1);
}, recheckDelayMs);
}
useEffect(() => {
const invoke = resolveTauriInvoke();
if (!invoke || recentWorkspaces.length === 0) {
inspectionGenerationRef.current += 1;
setRecentWorkspaceStatuses({});
setRecentWorkspaceRefreshing(false);
return;
}
const inspectionGeneration = ++inspectionGenerationRef.current;
let disposed = false;
const pendingWorkspaces = recentWorkspaces.filter(
(workspace) => !nonRetryablePathsRef.current.has(workspace),
);
let pendingCount = pendingWorkspaces.length;
const retryableFailures: string[] = [];
const finishRound = () => {
setRecentWorkspaceRefreshing(false);
// 失败集合变化即重置退避预算,避免长期坏目录吃满新瞬时失败的额度。
const failureKey = [...retryableFailures].sort().join('|');
if (failureKey !== lastRetryableFailureKeyRef.current) {
lastRetryableFailureKeyRef.current = failureKey;
failureStreakRef.current = 0;
}
scheduleFailureRecheck(retryableFailures.length);
};
// 刷新是增量投影:成功结果保留,但上一轮的失败结果不进新投影,
// 失败项回到「检查中」并在本轮重新检查。
setRecentWorkspaceStatuses((current) => {
const next: Record<string, LocalProjectDirectoryStatus | null> = {};
for (const workspace of recentWorkspaces) {
const status = current[workspace];
if (status) {
next[workspace] = status;
}
}
return next;
});
setRecentWorkspaceRefreshing(true);
if (pendingCount === 0) {
finishRound();
return;
}
for (const workspace of pendingWorkspaces) {
void inspectRecentWorkspaceWithRetry(invoke, workspace).then(
({ path: projectPath, status, retryable }) => {
if (
disposed ||
inspectionGeneration !== inspectionGenerationRef.current ||
!recentWorkspacesRef.current.includes(projectPath)
) {
return;
}
if (status) {
nonRetryablePathsRef.current.delete(projectPath);
} else if (retryable) {
retryableFailures.push(projectPath);
} else {
nonRetryablePathsRef.current.add(projectPath);
}
setRecentWorkspaceStatuses((current) => ({
...current,
[projectPath]: status,
}));
pendingCount -= 1;
if (pendingCount === 0) {
finishRound();
}
},
);
}
return () => {
disposed = true;
window.clearTimeout(failureRecheckTimerRef.current);
failureRecheckTimerRef.current = undefined;
};
}, [recentWorkspaces, recentWorkspaceRefreshKey]);
function rememberRecentWorkspace(projectPath: string) {
clearAclElevationDenials();
nonRetryablePathsRef.current.clear();
const eventGenerationAtCommand =
workspacePreferenceEventGenerationRef.current;
void rememberRecentWorkspacePreference(projectPath)
.then((snapshot) => {
if (
workspacePreferenceEventGenerationRef.current ===
eventGenerationAtCommand
) {
applyWorkspacePreferencesSnapshot(snapshot.recentWorkspaces);
}
})
.catch((error) => {
setStatusRef.current(visibleClientErrorMessage(error));
});
}
async function refreshRecentWorkspace(projectPath: string) {
const invoke = resolveTauriInvoke();
if (!invoke) {
return;
}
clearAclElevationDenials();
nonRetryablePathsRef.current.delete(projectPath);
const inspection = await inspectRecentWorkspaceWithRetry(
invoke,
projectPath,
);
if (!recentWorkspacesRef.current.includes(projectPath)) {
return;
}
if (inspection.status) {
nonRetryablePathsRef.current.delete(projectPath);
} else if (inspection.retryable) {
scheduleFailureRecheck(1);
} else {
nonRetryablePathsRef.current.add(projectPath);
}
setRecentWorkspaceStatuses((current) => ({
...current,
[projectPath]: inspection.status,
}));
}
function handleRecentWorkspaceRemove(projectPath: string) {
nonRetryablePathsRef.current.delete(projectPath);
const eventGenerationAtCommand =
workspacePreferenceEventGenerationRef.current;
void removeRecentWorkspacePreference(projectPath)
.then((snapshot) => {
if (
workspacePreferenceEventGenerationRef.current ===
eventGenerationAtCommand
) {
applyWorkspacePreferencesSnapshot(snapshot.recentWorkspaces);
}
setRecentWorkspaceStatuses((current) => {
const { [projectPath]: _removed, ...rest } = current;
return rest;
});
})
.catch((error) => {
setStatusRef.current(visibleClientErrorMessage(error));
});
}
async function handleRevealProjectDirectory(projectPath: string) {
const trimmedProjectPath = projectPath.trim();
if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) {
setStatus('请提供项目绝对路径');
return;
}
if (projectPathHasControlCharacter(trimmedProjectPath)) {
setStatus('项目目录不能包含控制字符');
return;
}
const invoke = resolveTauriInvoke();
if (!invoke) {
setStatus('需要在 Tauri App 内运行');
return;
}
try {
await invoke('open_local_project_directory', {
projectPath: trimmedProjectPath,
});
setStatus('已打开项目目录');
} catch (error) {
setStatus(visibleClientErrorMessage(error));
}
}
const projectRows = buildRecentProjectRows(
recentWorkspaces,
recentWorkspaceStatuses,
recentWorkspaceRefreshing,
);
const filteredProjectRows = useMemo(() => {
const normalizedQuery = projectSearchQuery.trim().toLocaleLowerCase();
if (!normalizedQuery) {
return projectRows;
}
return projectRows.filter((project) =>
[
project.name,
project.path,
project.status,
project.godotProjectRoot ?? '',
project.projectKind === 'cocos'
? 'cocos creator'
: project.projectKind === 'unity'
? 'unity'
: project.projectKind === 'ue'
? 'ue unreal engine'
: project.projectKind === 'godot'
? 'godot'
: project.projectKind === 'web'
? 'phaser gameagent web'
: 'unknown',
].some((value) => value.toLocaleLowerCase().includes(normalizedQuery)),
);
}, [projectRows, projectSearchQuery]);
return {
recentWorkspaces,
recentWorkspaceRefreshing,
projectRows,
filteredProjectRows,
projectSearchQuery,
setProjectSearchQuery,
recentProjectRows: projectRows
.filter((project) => project.canOpen)
.slice(0, 3),
rememberRecentWorkspace,
refreshRecentWorkspace,
handleRecentWorkspaceRemove,
handleRevealProjectDirectory,
};
}
export type RecentProjectsController = ReturnType<typeof useRecentProjects>;