8701f43f75
- 删除渲染层传输层:services/clientApi.ts、clientHttp 的 fetchClientHttp 与超时封装整体退场,账户、认证、素材直传、错误上报与游戏分发发布改走 Rust typed command。 - 新增宿主封装:accountHost、editorAssetHost、platformSessionRetry、agentRuntimeErrorDetail 与 features/app-shell/workspacePreferences;渲染层不再读写 access token / refresh cookie,localStorage 只剩测试态旧键迁移 shim。 - 修复 DirectProject 失败文案 v2 映射与「查看详情」按需读取,画布标签面板高度夹紧、框选每帧重算、文档预览入口与场景标题栏 key、资源筛选面板样式。 - 补齐与调整回归:accountHost、agentRuntimeErrorDetail、clientAuthHost、clientHttp、platformSession、platformSessionRetry、resourceFilterPanelStyle、directThreadChatSubscription 等定向用例,并清理测试 harness 中无调用方的残留。
237 lines
7.1 KiB
TypeScript
237 lines
7.1 KiB
TypeScript
import { invoke } from '@tauri-apps/api/core';
|
|
|
|
import {
|
|
ackErrorReports,
|
|
getPendingErrorReports,
|
|
reportClientError,
|
|
subscribeErrorReportUpdates,
|
|
} from './errorReportingBridge';
|
|
|
|
export type ClientErrorEvent = {
|
|
eventId: string;
|
|
fingerprint: string;
|
|
source: string;
|
|
message: string;
|
|
stack?: string;
|
|
occurredAt: string;
|
|
count: number;
|
|
};
|
|
|
|
export type DiagnosticLogFile = { name: string; content: string };
|
|
|
|
type WebviewLogLevel = 'debug' | 'info' | 'warn' | 'error' | 'log';
|
|
|
|
export function shouldCaptureClientError(error: unknown) {
|
|
if (!error || typeof error !== 'object') return true;
|
|
const candidate = error as { status?: unknown; networkError?: unknown };
|
|
if (candidate.networkError === true) return true;
|
|
if (typeof candidate.status === 'number') {
|
|
return candidate.status === 408 || candidate.status >= 500;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export async function invokeDiagnostic<T>(
|
|
invokeFn: (command: string, args?: Record<string, unknown>) => Promise<T>,
|
|
command: string,
|
|
args?: Record<string, unknown>,
|
|
) {
|
|
try {
|
|
return await invokeFn(command, args);
|
|
} catch (error) {
|
|
void captureClientError(error, { source: 'tauri', action: command });
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 诊断文本脱敏:抹掉授权头、Bearer、api key / token、URL 与本地绝对路径、长十六进制 id。
|
|
*
|
|
* 交互层的失败提示要保留原因时就复用它,别在业务文件里另写一套正则 ——
|
|
* 脱敏口径必须只有一份,否则"某条路径漏了"会随调用点漂移。
|
|
*/
|
|
function redactDiagnosticUrl(value: string) {
|
|
return value.replace(/https?:\/\/[^\s"'<>]+/giu, (rawUrl) => {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(rawUrl.replace(/[),.;!?]+$/u, ''));
|
|
} catch {
|
|
return '<url>';
|
|
}
|
|
const pathname = parsed.pathname || '/';
|
|
if (
|
|
pathname.startsWith('/api/') ||
|
|
pathname.startsWith('/v1/') ||
|
|
pathname.startsWith('/admin/')
|
|
) {
|
|
return `<origin>${pathname}`;
|
|
}
|
|
return '<url>';
|
|
});
|
|
}
|
|
|
|
export function normalizeDiagnosticText(value: string) {
|
|
return redactDiagnosticUrl(value)
|
|
.replace(
|
|
/authorization\s*:\s*(?:bearer\s+)?\S+/giu,
|
|
'authorization: [REDACTED]',
|
|
)
|
|
.replace(/bearer\s+\S+/giu, 'Bearer [REDACTED]')
|
|
.replace(/(?:api[_-]?key|token)\s*[=:]\s*\S+/giu, '[REDACTED]')
|
|
.replace(/[A-Z]:\\[^\s]+|\/(?:Users|home|private|tmp)\/[^\s]+/giu, '<path>')
|
|
.replace(/\b[0-9a-f]{8,}\b/giu, '<id>');
|
|
}
|
|
|
|
export async function captureClientError(
|
|
error: unknown,
|
|
context: { source?: string; action?: string; page?: string } = {},
|
|
) {
|
|
const errorValue = error instanceof Error ? error : new Error(String(error));
|
|
const message = errorValue.message || '未知客户端错误';
|
|
const stack = errorValue.stack ? errorValue.stack.slice(0, 8_000) : undefined;
|
|
return reportClientError({
|
|
source: context.source ?? 'client',
|
|
message,
|
|
stack,
|
|
action: context.action,
|
|
page: context.page,
|
|
}).catch(() => undefined);
|
|
}
|
|
|
|
export function captureAgentRuntimeError(error: unknown, agentId: string) {
|
|
return captureClientError(error, {
|
|
source: 'agent-runtime',
|
|
action: 'agent-runtime',
|
|
page: agentId,
|
|
});
|
|
}
|
|
|
|
function formatConsoleArgument(value: unknown) {
|
|
if (value instanceof Error) return value.stack || value.message;
|
|
if (typeof value === 'string') return value;
|
|
try {
|
|
return JSON.stringify(value);
|
|
} catch {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
export function appendWebviewLog(level: WebviewLogLevel, values: unknown[]) {
|
|
const message = normalizeDiagnosticText(
|
|
values.map(formatConsoleArgument).join(' ').slice(0, 8_000),
|
|
);
|
|
void invoke('append_application_log', {
|
|
level,
|
|
source: 'webview',
|
|
message,
|
|
}).catch(() => {
|
|
// 浏览器预览或 Tauri 尚未初始化时不阻断主流程。
|
|
});
|
|
}
|
|
|
|
/** 将 WebView console 输出镜像到 Rust 的普通文本 application.log。 */
|
|
export function installWebviewLogBridge() {
|
|
const marker = '__agcWebviewLogBridgeInstalled';
|
|
const target = globalThis as typeof globalThis & { [marker]?: boolean };
|
|
const targetConsole = globalThis.console;
|
|
if (target[marker] || !targetConsole) return () => {};
|
|
target[marker] = true;
|
|
const levels: WebviewLogLevel[] = ['debug', 'info', 'warn', 'error', 'log'];
|
|
const originals = new Map<WebviewLogLevel, (...args: unknown[]) => void>();
|
|
for (const level of levels) {
|
|
const original = targetConsole[level].bind(targetConsole) as (
|
|
...args: unknown[]
|
|
) => void;
|
|
originals.set(level, original);
|
|
targetConsole[level] = (...args: unknown[]) => {
|
|
original(...args);
|
|
appendWebviewLog(level, args);
|
|
};
|
|
}
|
|
return () => {
|
|
for (const level of levels) {
|
|
const original = originals.get(level);
|
|
if (original) targetConsole[level] = original;
|
|
}
|
|
delete target[marker];
|
|
};
|
|
}
|
|
|
|
export function getPendingClientErrorEvents() {
|
|
return getPendingErrorReports();
|
|
}
|
|
|
|
export function subscribeClientErrorEvents(listener: () => void) {
|
|
let disposed = false;
|
|
let unlisten: (() => void) | undefined;
|
|
void subscribeErrorReportUpdates(() => {
|
|
if (!disposed) listener();
|
|
})
|
|
.then((stop) => {
|
|
if (disposed) stop();
|
|
else unlisten = stop;
|
|
})
|
|
.catch(() => {
|
|
// Browser previews and partially initialized native webviews do not have
|
|
// an event bridge; snapshots and focus/visibility refreshes remain the
|
|
// source of truth in those environments.
|
|
});
|
|
return () => {
|
|
disposed = true;
|
|
unlisten?.();
|
|
};
|
|
}
|
|
|
|
export async function readApplicationDiagnosticLogs(): Promise<
|
|
DiagnosticLogFile[]
|
|
> {
|
|
return invoke<DiagnosticLogFile[]>('read_diagnostic_logs');
|
|
}
|
|
|
|
/**
|
|
* 提交一批脱敏错误报告。
|
|
*
|
|
* 平台会话、origin、稳定 submissionId、响应 envelope 与错误分类都由 Rust typed
|
|
* command 持有;渲染层只交出脱敏事件、可选说明和诊断日志,不接触 access token。
|
|
*/
|
|
export async function submitErrorReportBatch(payload: {
|
|
events: ClientErrorEvent[];
|
|
userDescription?: string;
|
|
logs: DiagnosticLogFile[];
|
|
}) {
|
|
return invoke<unknown>('submit_error_report', {
|
|
events: payload.events,
|
|
logs: payload.logs,
|
|
userDescription: payload.userDescription?.trim() || null,
|
|
});
|
|
}
|
|
|
|
export async function markClientErrorEventsSubmitted(
|
|
events: ClientErrorEvent[],
|
|
) {
|
|
await ackErrorReports(events.map((event) => event.eventId));
|
|
}
|
|
|
|
export async function ackClientErrorEventsWithRetry(
|
|
events: ClientErrorEvent[],
|
|
) {
|
|
for (const [attempt, delay] of [0, 500, 1_000, 2_000].entries()) {
|
|
if (delay) await new Promise((resolve) => setTimeout(resolve, delay));
|
|
try {
|
|
await markClientErrorEventsSubmitted(events);
|
|
return;
|
|
} catch (error) {
|
|
console.warn(
|
|
`[error-report] 本地 ack 第 ${attempt + 1} 次失败,将继续重试`,
|
|
error,
|
|
);
|
|
}
|
|
}
|
|
console.warn('[error-report] 本地 ack 重试耗尽,事件仍保留在本地队列');
|
|
}
|
|
|
|
/** 仅供单元测试隔离进程内错误池;生产流程不调用。 */
|
|
export function resetClientErrorEventsForTests() {
|
|
// Rust 队列按进程生命周期管理;测试通过 fake bridge 重建进程内状态。
|
|
}
|