fix: 修复桌面 DOCX 媒体捕获超时
This commit is contained in:
@@ -31,6 +31,18 @@ export type DesktopDocxExportOutcome =
|
||||
|
||||
export type DesktopDocxCapability = DocxCapability;
|
||||
|
||||
function readSafeDesktopMediaDetail(error: DocxExportServiceError) {
|
||||
const cause = error.cause;
|
||||
if (!(cause instanceof Error)) {
|
||||
return undefined;
|
||||
}
|
||||
return /^桌面 DOCX (?:媒体布局超过 \d+ms|第 \d+\/\d+ 个媒体((?:image|mermaid|echarts))PNG 捕获超过 \d+ms)$/u.test(
|
||||
cause.message
|
||||
)
|
||||
? cause.message
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function toDesktopDocxExportFailure(
|
||||
error: unknown
|
||||
): DesktopDocxExportFailure {
|
||||
@@ -54,10 +66,11 @@ export function toDesktopDocxExportFailure(
|
||||
};
|
||||
}
|
||||
if (error instanceof DocxExportServiceError) {
|
||||
const detail = readSafeDesktopMediaDetail(error);
|
||||
return {
|
||||
ok: false,
|
||||
error: error.code,
|
||||
message: error.message,
|
||||
message: detail ? `${error.message}:${detail}` : error.message,
|
||||
retryable: error.retryable
|
||||
};
|
||||
}
|
||||
|
||||
@@ -9,6 +9,51 @@ interface CaptureScreenshotResult {
|
||||
data: string;
|
||||
}
|
||||
|
||||
export interface ElectronDocxMediaCaptureTimeouts {
|
||||
renderMs: number;
|
||||
screenshotMs: number;
|
||||
}
|
||||
|
||||
const DEFAULT_CAPTURE_TIMEOUTS: ElectronDocxMediaCaptureTimeouts = {
|
||||
renderMs: 30_000,
|
||||
screenshotMs: 20_000
|
||||
};
|
||||
|
||||
function withTimeout<T>(
|
||||
operation: Promise<T>,
|
||||
timeoutMs: number,
|
||||
message: string,
|
||||
signal?: AbortSignal
|
||||
) {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let settled = false;
|
||||
const finish = (callback: () => void) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeout);
|
||||
signal?.removeEventListener("abort", abort);
|
||||
callback();
|
||||
};
|
||||
const abort = () =>
|
||||
finish(() => reject(signal?.reason ?? new Error("操作已取消")));
|
||||
const timeout = setTimeout(
|
||||
() => finish(() => reject(new Error(message))),
|
||||
timeoutMs
|
||||
);
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
if (signal?.aborted) {
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
operation.then(
|
||||
(value) => finish(() => resolve(value)),
|
||||
(error) => finish(() => reject(error))
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function createRenderScript(request: DocxMediaCaptureRequest) {
|
||||
const serialized = JSON.stringify(request);
|
||||
return `(async () => {
|
||||
@@ -24,12 +69,15 @@ function createRenderScript(request: DocxMediaCaptureRequest) {
|
||||
export async function captureDocxMediaWithElectronWebContents(
|
||||
webContents: WebContents,
|
||||
request: DocxMediaCaptureRequest,
|
||||
signal?: AbortSignal
|
||||
signal?: AbortSignal,
|
||||
timeouts: ElectronDocxMediaCaptureTimeouts = DEFAULT_CAPTURE_TIMEOUTS
|
||||
) {
|
||||
signal?.throwIfAborted();
|
||||
const plan = (await webContents.executeJavaScript(
|
||||
createRenderScript(request),
|
||||
true
|
||||
const plan = (await withTimeout(
|
||||
webContents.executeJavaScript(createRenderScript(request), true),
|
||||
timeouts.renderMs,
|
||||
`桌面 DOCX 媒体布局超过 ${timeouts.renderMs}ms`,
|
||||
signal
|
||||
)) as DocxMediaCapturePlan;
|
||||
signal?.throwIfAborted();
|
||||
const ownsDebugger = !webContents.debugger.isAttached();
|
||||
@@ -41,20 +89,25 @@ export async function captureDocxMediaWithElectronWebContents(
|
||||
const captures = [];
|
||||
for (const target of plan.targets) {
|
||||
signal?.throwIfAborted();
|
||||
const result = (await webContents.debugger.sendCommand(
|
||||
"Page.captureScreenshot",
|
||||
{
|
||||
format: "png",
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: true,
|
||||
clip: {
|
||||
x: target.captureX,
|
||||
y: target.captureY,
|
||||
width: target.captureWidthPx,
|
||||
height: target.captureHeightPx,
|
||||
scale: target.rasterScale
|
||||
const result = (await withTimeout(
|
||||
webContents.debugger.sendCommand(
|
||||
"Page.captureScreenshot",
|
||||
{
|
||||
format: "png",
|
||||
fromSurface: true,
|
||||
captureBeyondViewport: true,
|
||||
clip: {
|
||||
x: target.captureX,
|
||||
y: target.captureY,
|
||||
width: target.captureWidthPx,
|
||||
height: target.captureHeightPx,
|
||||
scale: target.rasterScale
|
||||
}
|
||||
}
|
||||
}
|
||||
),
|
||||
timeouts.screenshotMs,
|
||||
`桌面 DOCX 第 ${target.ordinal}/${plan.targets.length} 个媒体(${target.kind})PNG 捕获超过 ${timeouts.screenshotMs}ms`,
|
||||
signal
|
||||
)) as CaptureScreenshotResult;
|
||||
captures.push({
|
||||
id: target.id,
|
||||
|
||||
@@ -18,6 +18,25 @@ export interface ElectronDocxMediaEngineOptions {
|
||||
renderUrl: string;
|
||||
}
|
||||
|
||||
export async function withActiveDocxCaptureSurface<T>(
|
||||
window: Pick<
|
||||
BrowserWindow,
|
||||
"showInactive" | "hide" | "isDestroyed"
|
||||
>,
|
||||
operation: () => Promise<T>,
|
||||
settleMs = 50
|
||||
) {
|
||||
window.showInactive();
|
||||
await new Promise((resolve) => setTimeout(resolve, settleMs));
|
||||
try {
|
||||
return await operation();
|
||||
} finally {
|
||||
if (!window.isDestroyed()) {
|
||||
window.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class ElectronDocxMediaEngine
|
||||
implements DocxMediaCaptureAdapter, DocxThemeStyleCaptureAdapter
|
||||
{
|
||||
@@ -98,10 +117,14 @@ export class ElectronDocxMediaEngine
|
||||
};
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
try {
|
||||
return await captureDocxMediaWithElectronWebContents(
|
||||
window.webContents,
|
||||
request,
|
||||
signal
|
||||
return await withActiveDocxCaptureSurface(
|
||||
window,
|
||||
() =>
|
||||
captureDocxMediaWithElectronWebContents(
|
||||
window.webContents,
|
||||
request,
|
||||
signal
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
signal?.removeEventListener("abort", abort);
|
||||
|
||||
@@ -41,6 +41,40 @@ describe("Desktop DOCX IPC 契约", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("仅透传格式受控的桌面媒体超时详情", () => {
|
||||
expect(
|
||||
toDesktopDocxExportFailure(
|
||||
new DocxExportServiceError(
|
||||
500,
|
||||
"DOCX_GENERATION_FAILED",
|
||||
"DOCX 导出失败",
|
||||
false,
|
||||
{ cause: new Error("桌面 DOCX 媒体布局超过 30000ms") }
|
||||
)
|
||||
)
|
||||
).toMatchObject({
|
||||
message: "DOCX 导出失败:桌面 DOCX 媒体布局超过 30000ms"
|
||||
});
|
||||
expect(
|
||||
toDesktopDocxExportFailure(
|
||||
new DocxExportServiceError(
|
||||
500,
|
||||
"DOCX_GENERATION_FAILED",
|
||||
"DOCX 导出失败",
|
||||
false,
|
||||
{
|
||||
cause: new Error(
|
||||
"桌面 DOCX 第 2/7 个媒体(echarts)PNG 捕获超过 20000ms"
|
||||
)
|
||||
}
|
||||
)
|
||||
)
|
||||
).toMatchObject({
|
||||
message:
|
||||
"DOCX 导出失败:桌面 DOCX 第 2/7 个媒体(echarts)PNG 捕获超过 20000ms"
|
||||
});
|
||||
});
|
||||
|
||||
it("保留 Front Matter 解析错误", () => {
|
||||
expect(
|
||||
toDesktopDocxExportFailure(
|
||||
|
||||
@@ -104,4 +104,52 @@ describe("Electron DOCX 媒体捕获", () => {
|
||||
);
|
||||
expect(detach).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("媒体布局永久不返回时报告局部超时", async () => {
|
||||
const webContents = {
|
||||
executeJavaScript: vi.fn(() => new Promise(() => undefined)),
|
||||
debugger: {
|
||||
isAttached: vi.fn(() => false)
|
||||
}
|
||||
} as unknown as WebContents;
|
||||
|
||||
await expect(
|
||||
captureDocxMediaWithElectronWebContents(
|
||||
webContents,
|
||||
request,
|
||||
undefined,
|
||||
{ renderMs: 10, screenshotMs: 10 }
|
||||
)
|
||||
).rejects.toThrow("桌面 DOCX 媒体布局超过 10ms");
|
||||
});
|
||||
|
||||
it("单个 PNG 永久不返回时报告媒体编号和类型", async () => {
|
||||
let attached = false;
|
||||
const detach = vi.fn(() => {
|
||||
attached = false;
|
||||
});
|
||||
const webContents = {
|
||||
executeJavaScript: vi.fn(async () => plan),
|
||||
debugger: {
|
||||
isAttached: vi.fn(() => attached),
|
||||
attach: vi.fn(() => {
|
||||
attached = true;
|
||||
}),
|
||||
detach,
|
||||
sendCommand: vi.fn(() => new Promise(() => undefined))
|
||||
}
|
||||
} as unknown as WebContents;
|
||||
|
||||
await expect(
|
||||
captureDocxMediaWithElectronWebContents(
|
||||
webContents,
|
||||
request,
|
||||
undefined,
|
||||
{ renderMs: 10, screenshotMs: 10 }
|
||||
)
|
||||
).rejects.toThrow(
|
||||
"桌面 DOCX 第 1/1 个媒体(image)PNG 捕获超过 10ms"
|
||||
);
|
||||
expect(detach).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { withActiveDocxCaptureSurface } from "../src/electron-docx-media-engine.js";
|
||||
|
||||
function createWindow() {
|
||||
let destroyed = false;
|
||||
return {
|
||||
showInactive: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
isDestroyed: vi.fn(() => destroyed),
|
||||
destroy: () => {
|
||||
destroyed = true;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
describe("Electron DOCX 捕获合成表面", () => {
|
||||
it("截图期间无焦点显示窗口并在完成后隐藏", async () => {
|
||||
const window = createWindow();
|
||||
const operation = vi.fn(async () => "captured");
|
||||
|
||||
await expect(
|
||||
withActiveDocxCaptureSurface(window, operation, 0)
|
||||
).resolves.toBe("captured");
|
||||
|
||||
expect(window.showInactive).toHaveBeenCalledOnce();
|
||||
expect(operation).toHaveBeenCalledOnce();
|
||||
expect(window.hide).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("窗口在失败期间销毁时不再隐藏", async () => {
|
||||
const window = createWindow();
|
||||
|
||||
await expect(
|
||||
withActiveDocxCaptureSurface(
|
||||
window,
|
||||
async () => {
|
||||
window.destroy();
|
||||
throw new Error("capture failed");
|
||||
},
|
||||
0
|
||||
)
|
||||
).rejects.toThrow("capture failed");
|
||||
|
||||
expect(window.showInactive).toHaveBeenCalledOnce();
|
||||
expect(window.hide).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user