feat: 将发布预检移入内置浏览器

This commit is contained in:
2026-08-12 16:33:50 +08:00
parent be9bc84474
commit 926056a59b
16 changed files with 840 additions and 9 deletions

View File

@@ -32,6 +32,7 @@ export interface AgentBrowserWebContentsPort {
isDestroyed(): boolean;
isDevToolsOpened(): boolean;
reload(): void;
executeJavaScript(code: string): Promise<unknown>;
denyWindowOpen(): void;
on(event: string, listener: PortListener): void;
removeListener(event: string, listener: PortListener): void;
@@ -49,4 +50,5 @@ export interface AgentBrowserAdapter {
unmount(view: AgentBrowserViewPort): void;
destroy(view: AgentBrowserViewPort): void;
resetPartition(partition: string): Promise<void>;
restrictPartitionToOrigin(partition: string, origin: string): () => void;
}

View File

@@ -67,6 +67,7 @@ function wrapWebContents(contents: WebContents): AgentBrowserWebContentsPort {
isDestroyed: () => contents.isDestroyed(),
isDevToolsOpened: () => contents.isDevToolsOpened(),
reload: () => contents.reload(),
executeJavaScript: (code) => contents.executeJavaScript(code),
denyWindowOpen: () => {
contents.setWindowOpenHandler(() => ({ action: 'deny' }));
},
@@ -151,6 +152,31 @@ export class ElectronAgentBrowserAdapter implements AgentBrowserAdapter {
await browserSession.clearCache();
}
restrictPartitionToOrigin(partition: string, origin: string): () => void {
const browserSession = session.fromPartition(partition);
const listener = (
details: Electron.OnBeforeRequestListenerDetails,
callback: (response: Electron.CallbackResponse) => void,
) => {
try {
const requestUrl = new URL(details.url);
const localDocumentScheme = requestUrl.protocol === 'about:'
|| requestUrl.protocol === 'data:'
|| requestUrl.protocol === 'blob:';
callback({
cancel: !localDocumentScheme
&& !(requestUrl.protocol === 'http:' && requestUrl.origin === origin),
});
} catch {
callback({ cancel: true });
}
};
browserSession.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, listener);
return () => {
browserSession.webRequest.onBeforeRequest(null);
};
}
private requireNativeView(view: AgentBrowserViewPort): WebContentsView {
const nativeView = this.nativeViews.get(view);
if (!nativeView) throw new Error('Unknown Agent Browser view.');

View File

@@ -27,6 +27,53 @@ const DEFAULT_CDP_TIMEOUT_MS = 10_000;
const MAX_CDP_TIMEOUT_MS = 30_000;
const OPEN_TIMEOUT_MS = 30_000;
const RENDERER_PRIME_URL = 'about:blank';
const PUBLISH_PREFLIGHT_TIMEOUT_MS = 30_000;
const PUBLISH_PREFLIGHT_SETTLE_MS = 500;
const PUBLISH_PREFLIGHT_VIEWPORTS = [
{ width: 1280, height: 720 },
{ width: 390, height: 844 },
] as const;
const PUBLISH_PREFLIGHT_INSPECTION = `(() => {
const body = document.body;
if (!body) return { readyState: document.readyState, visible: false };
const candidates = Array.from(body.querySelectorAll('*')).slice(0, 2000);
const visible = candidates.some((element) => {
const style = getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
const rect = element.getBoundingClientRect();
if (rect.width <= 1 || rect.height <= 1) return false;
if (element instanceof HTMLCanvasElement) {
const scale = Math.min(rect.width / Math.max(element.width, 1), rect.height / Math.max(element.height, 1));
return element.width > 1 && element.height > 1 && scale >= 0.15;
}
if (element instanceof HTMLImageElement) return element.complete && element.naturalWidth > 1;
if (element instanceof HTMLVideoElement) return element.readyState >= 2;
if (element instanceof SVGElement) return true;
return Boolean(element.textContent?.trim())
|| style.backgroundImage !== 'none';
});
return {
readyState: document.readyState,
visible: visible || Boolean(body.innerText?.trim()),
viewport: { width: window.innerWidth, height: window.innerHeight },
};
})()`;
const SAFE_PREFLIGHT_MESSAGES = {
PREVIEW_REQUIRED: '请先在 Makelore 内置浏览器中打开当前项目预览。',
PUBLISH_PREFLIGHT_LOAD_FAILED: '作品主页无法打开。',
PUBLISH_PREFLIGHT_RUNTIME_ERROR: '作品打开时发生了运行错误。',
PUBLISH_PREFLIGHT_BLANK: '作品打开后没有可见内容。',
PUBLISH_PREFLIGHT_TIMEOUT: '作品打开检查超时。',
} as const;
class PublishPreflightFault extends Error {
constructor(
readonly code: keyof typeof SAFE_PREFLIGHT_MESSAGES,
) {
super(SAFE_PREFLIGHT_MESSAGES[code]);
this.name = 'PublishPreflightFault';
}
}
const ENABLED_DOMAINS = ['Runtime.enable', 'Log.enable', 'Network.enable', 'Page.enable'] as const;
const STREAM_ISSUING_METHODS = new Set([
'Fetch.takeResponseBodyAsStream',
@@ -136,6 +183,25 @@ export class AgentBrowserModule {
this.cdpGuard = options.cdpGuard ?? new AgentBrowserCdpGuard();
}
async preflightCurrentProject(projectPath: string): Promise<{ ok: true }> {
this.assertAvailable();
let record: BrowserRecord;
try {
record = this.requireRecord(projectPath);
if (record.state !== 'attached' || !record.view.webContents.debugger.isAttached()) {
throw new Error('preview-not-attached');
}
} catch {
throw new PublishPreflightFault('PREVIEW_REQUIRED');
}
const targetUrl = normalizeLoopbackPreviewUrl(record.url);
const deadline = Date.now() + PUBLISH_PREFLIGHT_TIMEOUT_MS;
for (const viewport of PUBLISH_PREFLIGHT_VIEWPORTS) {
await this.preflightStaticViewport(targetUrl, viewport, deadline);
}
return { ok: true };
}
async getSnapshot(projectPath?: string): Promise<AgentBrowserSnapshot> {
if (!this.record) return this.closedSnapshot();
if (projectPath) this.assertProject(this.record, projectPath);
@@ -420,6 +486,135 @@ export class AgentBrowserModule {
this.payloadStore.clear();
}
private async preflightStaticViewport(
targetUrl: string,
viewport: { width: number; height: number },
deadline: number,
): Promise<void> {
const partition = `niancode-publish-preflight:${randomUUID()}`;
const previewOrigin = new URL(targetUrl).origin;
let view: AgentBrowserViewPort | null = null;
let releaseOriginRestriction: (() => void) | null = null;
let runtimeError = false;
const onDebuggerMessage: PortListener = (_event, methodValue, paramsValue) => {
if (typeof methodValue !== 'string') return;
const params = isRecord(paramsValue) ? paramsValue : {};
if (methodValue === 'Runtime.exceptionThrown') {
runtimeError = true;
} else if (methodValue === 'Network.loadingFailed') {
const errorText = typeof params.errorText === 'string' ? params.errorText : '';
const canceled = params.canceled === true || errorText === 'net::ERR_ABORTED';
if (!canceled) runtimeError = true;
} else if (methodValue === 'Runtime.consoleAPICalled' && params.type === 'error') {
runtimeError = true;
} else if (methodValue === 'Log.entryAdded') {
const entry = isRecord(params.entry) ? params.entry : {};
if (entry.level === 'error') runtimeError = true;
} else if (methodValue === 'Network.responseReceived') {
const response = isRecord(params.response) ? params.response : {};
if (typeof response.status === 'number' && response.status >= 400) runtimeError = true;
} else if (methodValue === 'Network.requestWillBeSent') {
const request = isRecord(params.request) ? params.request : {};
if (typeof request.url !== 'string') return;
try {
const requestUrl = new URL(request.url);
if (
(requestUrl.protocol === 'http:' || requestUrl.protocol === 'https:')
&& requestUrl.origin !== previewOrigin
) runtimeError = true;
} catch {
runtimeError = true;
}
}
};
const onDidFailLoad: PortListener = (
_event, errorCodeValue, errorDescriptionValue, _url, isMainFrameValue,
) => {
if (
isMainFrameValue !== false
&& errorCodeValue !== -3
&& errorDescriptionValue !== 'ERR_ABORTED'
) runtimeError = true;
};
const onRendererFault: PortListener = () => {
runtimeError = true;
};
try {
view = this.adapter.createView(partition);
releaseOriginRestriction = this.adapter.restrictPartitionToOrigin(partition, previewOrigin);
view.webContents.debugger.on('message', onDebuggerMessage);
view.webContents.on('did-fail-load', onDidFailLoad);
view.webContents.on('render-process-gone', onRendererFault);
view.webContents.on('unresponsive', onRendererFault);
view.webContents.on('destroyed', onRendererFault);
view.webContents.denyWindowOpen();
view.setBounds({ x: 0, y: 0, ...viewport });
view.setVisible(false);
await beforePublishPreflightDeadline(view.webContents.loadURL(RENDERER_PRIME_URL), deadline);
view.webContents.debugger.attach(CDP_PROTOCOL_VERSION);
await beforePublishPreflightDeadline(view.webContents.debugger.sendCommand('Runtime.enable'), deadline);
await beforePublishPreflightDeadline(view.webContents.debugger.sendCommand('Log.enable'), deadline);
await beforePublishPreflightDeadline(view.webContents.debugger.sendCommand('Network.enable'), deadline);
await beforePublishPreflightDeadline(view.webContents.debugger.sendCommand('Page.enable'), deadline);
await beforePublishPreflightDeadline(
view.webContents.debugger.sendCommand('Emulation.setDeviceMetricsOverride', {
width: viewport.width,
height: viewport.height,
deviceScaleFactor: 1,
mobile: viewport.width < 600,
screenWidth: viewport.width,
screenHeight: viewport.height,
scale: 1,
}),
deadline,
);
await beforePublishPreflightDeadline(view.webContents.loadURL(targetUrl), deadline);
await beforePublishPreflightDeadline(delay(PUBLISH_PREFLIGHT_SETTLE_MS), deadline);
const inspected = await beforePublishPreflightDeadline(
view.webContents.executeJavaScript(PUBLISH_PREFLIGHT_INSPECTION),
deadline,
);
if (new URL(view.webContents.getURL()).origin !== previewOrigin) {
throw new PublishPreflightFault('PUBLISH_PREFLIGHT_LOAD_FAILED');
}
if (runtimeError) {
throw new PublishPreflightFault('PUBLISH_PREFLIGHT_RUNTIME_ERROR');
}
const inspectedViewport = isRecord(inspected) && isRecord(inspected.viewport)
? inspected.viewport
: {};
if (
!isRecord(inspected)
|| inspected.readyState !== 'complete'
|| inspected.visible !== true
|| !viewportMatches(inspectedViewport.width, viewport.width)
|| !viewportMatches(inspectedViewport.height, viewport.height)
) {
throw new PublishPreflightFault('PUBLISH_PREFLIGHT_BLANK');
}
} catch (error) {
if (error instanceof PublishPreflightFault) throw error;
throw new PublishPreflightFault(
isTimeoutError(error) ? 'PUBLISH_PREFLIGHT_TIMEOUT' : 'PUBLISH_PREFLIGHT_LOAD_FAILED',
);
} finally {
bestEffortCleanup(() => view?.webContents.debugger.removeListener('message', onDebuggerMessage));
bestEffortCleanup(() => view?.webContents.removeListener('did-fail-load', onDidFailLoad));
bestEffortCleanup(() => view?.webContents.removeListener('render-process-gone', onRendererFault));
bestEffortCleanup(() => view?.webContents.removeListener('unresponsive', onRendererFault));
bestEffortCleanup(() => view?.webContents.removeListener('destroyed', onRendererFault));
bestEffortCleanup(() => {
if (view?.webContents.debugger.isAttached()) view.webContents.debugger.detach();
});
bestEffortCleanup(() => {
if (view) this.adapter.destroy(view);
});
bestEffortCleanup(() => releaseOriginRestriction?.());
await bestEffortPartitionReset(this.adapter, partition);
}
}
private async attachDebugger(record: BrowserRecord, reattach: boolean): Promise<void> {
if (record !== this.record || record.view.webContents.isDestroyed()) {
throw new AgentBrowserFault(
@@ -1199,6 +1394,89 @@ function normalizeUrl(value: string): string {
return url.toString();
}
async function beforePublishPreflightDeadline<T>(
operation: Promise<T>,
deadline: number,
): Promise<T> {
const remaining = deadline - Date.now();
if (remaining <= 0) throw new Error('PUBLISH_PREFLIGHT_TIMEOUT');
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
operation,
new Promise<T>((_resolve, reject) => {
timer = setTimeout(() => reject(new Error('PUBLISH_PREFLIGHT_TIMEOUT')), remaining);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
function delay(milliseconds: number): Promise<void> {
return new Promise((resolvePromise) => {
setTimeout(resolvePromise, milliseconds);
});
}
function isTimeoutError(error: unknown): boolean {
return error instanceof Error && error.message === 'PUBLISH_PREFLIGHT_TIMEOUT';
}
function viewportMatches(actual: unknown, expected: number): boolean {
return typeof actual === 'number'
&& Number.isFinite(actual)
&& Math.abs(actual - expected) <= Math.max(2, expected * 0.05);
}
function bestEffortCleanup(cleanup: () => void): void {
try {
cleanup();
} catch {
// Every subsequent cleanup step must still run for the temporary renderer.
}
}
async function bestEffortPartitionReset(
adapter: AgentBrowserAdapter,
partition: string,
): Promise<void> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
adapter.resetPartition(partition).catch(() => undefined),
new Promise<void>((resolvePromise) => {
timer = setTimeout(resolvePromise, 1_000);
}),
]);
} finally {
if (timer) clearTimeout(timer);
}
}
function normalizeLoopbackPreviewUrl(value: string): string {
let url: URL;
try {
url = new URL(value);
} catch {
throw new PublishPreflightFault('PREVIEW_REQUIRED');
}
const hostname = url.hostname.toLowerCase();
const loopback = hostname === 'localhost'
|| hostname === '127.0.0.1'
|| hostname === '[::1]';
if (
!loopback
|| url.protocol !== 'http:'
|| !url.port
|| url.username
|| url.password
) {
throw new PublishPreflightFault('PREVIEW_REQUIRED');
}
return url.toString();
}
function normalizeBounds(bounds: AgentBrowserBounds): AgentBrowserBounds {
const normalized = {
x: Math.trunc(bounds.x),

View File

@@ -15,6 +15,7 @@ import type {
export type WorksSubmissionBindingStore = ReturnType<typeof createWorksSubmissionBindingStore>;
export interface AgentBrowserService {
preflightCurrentProject(projectPath: string): Promise<{ ok: true }>;
getSnapshot(projectPath?: string): Promise<AgentBrowserSnapshot> | AgentBrowserSnapshot;
open(input: {
projectId: string;

View File

@@ -125,6 +125,30 @@ function sendPublishSourceFailure(
sendJson(res, 200, { success: false, status, code, error });
}
const SAFE_PUBLISH_PREFLIGHT_CODES = new Set([
'PREVIEW_REQUIRED',
'PUBLISH_PREFLIGHT_LOAD_FAILED',
'PUBLISH_PREFLIGHT_RUNTIME_ERROR',
'PUBLISH_PREFLIGHT_BLANK',
'PUBLISH_PREFLIGHT_TIMEOUT',
]);
const SAFE_PUBLISH_PREFLIGHT_MESSAGES: Record<string, string> = {
PREVIEW_REQUIRED: '请先在 Makelore 内置浏览器中打开当前项目预览。',
PUBLISH_PREFLIGHT_LOAD_FAILED: '作品主页无法打开。',
PUBLISH_PREFLIGHT_RUNTIME_ERROR: '作品打开时发生了运行错误。',
PUBLISH_PREFLIGHT_BLANK: '作品打开后没有可见内容。',
PUBLISH_PREFLIGHT_TIMEOUT: '作品打开检查超时。',
};
function isSafePublishPreflightError(
error: unknown,
): error is Error & { code: string } {
return error instanceof Error
&& 'code' in error
&& typeof (error as { code?: unknown }).code === 'string'
&& SAFE_PUBLISH_PREFLIGHT_CODES.has((error as { code: string }).code);
}
async function sendPublishSourceUpstreamError(
res: ServerResponse,
response: Response,
@@ -826,6 +850,16 @@ async function handlePublishProjectSource(
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'makelore-publish-'));
const archivePath = join(temporaryDirectory, 'project.zip');
try {
if (!ctx.agentBrowser) {
sendPublishSourceFailure(
res,
400,
'PREVIEW_REQUIRED',
'请先在 Makelore 内置浏览器中打开当前项目预览。',
);
return;
}
await ctx.agentBrowser.preflightCurrentProject(localProject.path);
const packageSummary = await createStaticProjectPackage({
projectPath: localProject.path,
archivePath,
@@ -1115,9 +1149,13 @@ export async function handleWorksRoutes(
const isPackageError = error instanceof ProjectPackageError;
sendPublishSourceFailure(
res,
isPackageError ? 400 : 503,
isPackageError ? error.code : 'WORKS_SQUARE_UNAVAILABLE',
isPackageError ? error.message : '发布服务暂时不可用,请稍后重试。',
isPackageError || isSafePublishPreflightError(error) ? 400 : 503,
isPackageError || isSafePublishPreflightError(error) ? error.code : 'WORKS_SQUARE_UNAVAILABLE',
isPackageError
? error.message
: isSafePublishPreflightError(error)
? SAFE_PUBLISH_PREFLIGHT_MESSAGES[error.code]
: '发布服务暂时不可用,请稍后重试。',
);
} else if (isProjectStatus) {
sendPublishSourceFailure(

View File

@@ -753,3 +753,26 @@ if (gotTheLock) {
// Export for testing
export { mainWindow, opencodeManager, opencodeProjectStore };
export async function runLocalPreviewPreflightE2E(url: string): Promise<{ ok: true }> {
if (!isE2EMode || !agentBrowser) throw new Error('E2E local preview preflight is unavailable');
const projectPath = join(app.getPath('temp'), 'makelore-local-preview-preflight-e2e');
await agentBrowser.open({
projectId: 'local-preview-preflight-e2e',
projectPath,
url,
visible: false,
});
try {
return await agentBrowser.preflightCurrentProject(projectPath);
} finally {
await agentBrowser.close(projectPath).catch(() => undefined);
await agentBrowser.resetProfile(projectPath).catch(() => undefined);
}
}
if (isE2EMode) {
(globalThis as typeof globalThis & {
__niancodeRunLocalPreviewPreflightE2E?: typeof runLocalPreviewPreflightE2E;
}).__niancodeRunLocalPreviewPreflightE2E = runLocalPreviewPreflightE2E;
}