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 契约
- 润色失败、资源版本替换、首页创建失败、启动浮层的测试期望对齐脱敏后的详情文案
435 lines
14 KiB
TypeScript
435 lines
14 KiB
TypeScript
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>;
|