feat: browser-side annotation screenshots for web preview
Capture the preview/browser iframe DOM with snapDOM (html-to-image fallback) so web annotation screenshots match the visible viewport, without a headless Chromium dependency. - Preserve document scroll via viewport crop and re-bake nested scroll (e.g. the Starlight sidebar) deterministically on the clone - Pin position:fixed elements to their measured viewport rect so headers and sidebars land correctly in the crop - Extract preview capture/proxy helpers into lib/preview/screenshot-capture.ts to slim down ContextPanel - Guard the external preview proxy against SSRF to private, loopback and reserved/link-local addresses (incl. cloud metadata) - Fully validate preview bridge messages before formatting/use - Warn on the empty browser tab that pages run with full access, so users browse untrusted sites knowingly
This commit is contained in:
@@ -98,7 +98,7 @@
|
|||||||
},
|
},
|
||||||
"packages/desktop": {
|
"packages/desktop": {
|
||||||
"name": "@openchamber/desktop",
|
"name": "@openchamber/desktop",
|
||||||
"version": "1.11.6",
|
"version": "1.11.7",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tauri-apps/cli": "^2",
|
"@tauri-apps/cli": "^2",
|
||||||
"@types/node": "^24.3.1",
|
"@types/node": "^24.3.1",
|
||||||
@@ -107,7 +107,7 @@
|
|||||||
},
|
},
|
||||||
"packages/electron": {
|
"packages/electron": {
|
||||||
"name": "@openchamber/electron",
|
"name": "@openchamber/electron",
|
||||||
"version": "1.11.6",
|
"version": "1.11.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/web": "workspace:*",
|
"@openchamber/web": "workspace:*",
|
||||||
"electron-context-menu": "^4.1.2",
|
"electron-context-menu": "^4.1.2",
|
||||||
@@ -122,7 +122,7 @@
|
|||||||
},
|
},
|
||||||
"packages/ui": {
|
"packages/ui": {
|
||||||
"name": "@openchamber/ui",
|
"name": "@openchamber/ui",
|
||||||
"version": "1.11.6",
|
"version": "1.11.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.4.0",
|
"@base-ui/react": "^1.4.0",
|
||||||
"@codemirror/autocomplete": "^6.20.0",
|
"@codemirror/autocomplete": "^6.20.0",
|
||||||
@@ -159,6 +159,7 @@
|
|||||||
"@tanstack/react-virtual": "^3.13.18",
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@xenova/transformers": "^2.17.2",
|
"@xenova/transformers": "^2.17.2",
|
||||||
|
"@zumer/snapdom": "^2.12.0",
|
||||||
"beautiful-mermaid": "^1.1.3",
|
"beautiful-mermaid": "^1.1.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -222,7 +223,7 @@
|
|||||||
},
|
},
|
||||||
"packages/vscode": {
|
"packages/vscode": {
|
||||||
"name": "openchamber",
|
"name": "openchamber",
|
||||||
"version": "1.11.6",
|
"version": "1.11.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@openchamber/ui": "workspace:*",
|
"@openchamber/ui": "workspace:*",
|
||||||
"@opencode-ai/sdk": "^1.15.10",
|
"@opencode-ai/sdk": "^1.15.10",
|
||||||
@@ -245,7 +246,7 @@
|
|||||||
},
|
},
|
||||||
"packages/web": {
|
"packages/web": {
|
||||||
"name": "@openchamber/web",
|
"name": "@openchamber/web",
|
||||||
"version": "1.11.6",
|
"version": "1.11.7",
|
||||||
"bin": {
|
"bin": {
|
||||||
"openchamber": "./bin/cli.js",
|
"openchamber": "./bin/cli.js",
|
||||||
},
|
},
|
||||||
@@ -1445,6 +1446,8 @@
|
|||||||
|
|
||||||
"@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="],
|
"@yarnpkg/lockfile": ["@yarnpkg/lockfile@1.1.0", "", {}, "sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ=="],
|
||||||
|
|
||||||
|
"@zumer/snapdom": ["@zumer/snapdom@2.12.0", "", {}, "sha512-TdLGu+1RkKI3JKfMFvn1gRPD/rl8hrAeN6aFjjd0w7S39nulbd94ChxSlfH7nSCm8kzuQkeWFxIsij4+Mk1RDg=="],
|
||||||
|
|
||||||
"abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="],
|
"abbrev": ["abbrev@1.1.1", "", {}, "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q=="],
|
||||||
|
|
||||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
"lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js"
|
"lint": "eslint \"./src/**/*.{ts,tsx}\" --config ../../eslint.config.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@base-ui/react": "^1.4.0",
|
||||||
"@codemirror/autocomplete": "^6.20.0",
|
"@codemirror/autocomplete": "^6.20.0",
|
||||||
"@codemirror/commands": "^6.10.1",
|
"@codemirror/commands": "^6.10.1",
|
||||||
"@codemirror/lang-cpp": "^6.0.3",
|
"@codemirror/lang-cpp": "^6.0.3",
|
||||||
@@ -41,11 +42,11 @@
|
|||||||
"@lezer/highlight": "^1.2.3",
|
"@lezer/highlight": "^1.2.3",
|
||||||
"@opencode-ai/sdk": "^1.15.10",
|
"@opencode-ai/sdk": "^1.15.10",
|
||||||
"@pierre/diffs": "1.1.0-beta.13",
|
"@pierre/diffs": "1.1.0-beta.13",
|
||||||
"@base-ui/react": "^1.4.0",
|
|
||||||
"@simplewebauthn/browser": "13.3.0",
|
"@simplewebauthn/browser": "13.3.0",
|
||||||
"@tanstack/react-virtual": "^3.13.18",
|
"@tanstack/react-virtual": "^3.13.18",
|
||||||
"@types/react-syntax-highlighter": "^15.5.13",
|
"@types/react-syntax-highlighter": "^15.5.13",
|
||||||
"@xenova/transformers": "^2.17.2",
|
"@xenova/transformers": "^2.17.2",
|
||||||
|
"@zumer/snapdom": "^2.12.0",
|
||||||
"beautiful-mermaid": "^1.1.3",
|
"beautiful-mermaid": "^1.1.3",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -61,8 +62,6 @@
|
|||||||
"katex": "^0.16.21",
|
"katex": "^0.16.21",
|
||||||
"marked": "^17.0.3",
|
"marked": "^17.0.3",
|
||||||
"morphdom": "^2.7.7",
|
"morphdom": "^2.7.7",
|
||||||
"rehype-katex": "^7.0.1",
|
|
||||||
"remark-math": "^6.0.0",
|
|
||||||
"motion": "^12.23.24",
|
"motion": "^12.23.24",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"prismjs": "^1.30.0",
|
"prismjs": "^1.30.0",
|
||||||
@@ -70,6 +69,8 @@
|
|||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
"react-syntax-highlighter": "^15.6.6",
|
"react-syntax-highlighter": "^15.6.6",
|
||||||
|
"rehype-katex": "^7.0.1",
|
||||||
|
"remark-math": "^6.0.0",
|
||||||
"remend": "^1.2.1",
|
"remend": "^1.2.1",
|
||||||
"simple-git": "^3.28.0",
|
"simple-git": "^3.28.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
|
|||||||
@@ -22,6 +22,16 @@ import { toast } from '@/components/ui';
|
|||||||
import { Icon } from "@/components/icon/Icon";
|
import { Icon } from "@/components/icon/Icon";
|
||||||
import { OpenChamberLogo } from "@/components/ui/OpenChamberLogo";
|
import { OpenChamberLogo } from "@/components/ui/OpenChamberLogo";
|
||||||
import { invokeDesktopCommand } from '@/lib/desktopNative';
|
import { invokeDesktopCommand } from '@/lib/desktopNative';
|
||||||
|
import {
|
||||||
|
type PreviewElementMetadata,
|
||||||
|
isPreviewElementMetadata,
|
||||||
|
formatPreviewAnnotationMarkdown,
|
||||||
|
renderPreviewScreenshot,
|
||||||
|
desktopAnnotationToFile,
|
||||||
|
getCachedProxyTarget,
|
||||||
|
getBrowserProxyTargetKey,
|
||||||
|
previewProxyTargetCache,
|
||||||
|
} from '@/lib/preview/screenshot-capture';
|
||||||
|
|
||||||
const CONTEXT_PANEL_MIN_WIDTH = 380;
|
const CONTEXT_PANEL_MIN_WIDTH = 380;
|
||||||
const CONTEXT_PANEL_MAX_WIDTH = 1400;
|
const CONTEXT_PANEL_MAX_WIDTH = 1400;
|
||||||
@@ -59,18 +69,6 @@ type PreviewBridgeMessage = {
|
|||||||
navigation?: unknown;
|
navigation?: unknown;
|
||||||
};
|
};
|
||||||
|
|
||||||
type PreviewElementMetadata = {
|
|
||||||
frame: 'top';
|
|
||||||
tag: string;
|
|
||||||
text: string;
|
|
||||||
selector: string;
|
|
||||||
path: string;
|
|
||||||
bounds: { x: number; y: number; width: number; height: number };
|
|
||||||
center: { x: number; y: number };
|
|
||||||
attributes: Record<string, string>;
|
|
||||||
computedStyle: Record<string, string>;
|
|
||||||
ancestry: Array<{ tag: string; id?: string; className?: string; selectorPart: string }>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PREVIEW_CONSOLE_EVENT_LIMIT = 200;
|
const PREVIEW_CONSOLE_EVENT_LIMIT = 200;
|
||||||
|
|
||||||
@@ -81,117 +79,6 @@ const getPreviewConsoleFilterMatch = (event: PreviewConsoleEvent, filter: Previe
|
|||||||
return event.level === 'log' || event.level === 'info' || event.level === 'debug';
|
return event.level === 'log' || event.level === 'info' || event.level === 'debug';
|
||||||
};
|
};
|
||||||
|
|
||||||
const isPreviewElementMetadata = (value: unknown): value is PreviewElementMetadata => {
|
|
||||||
if (!value || typeof value !== 'object') return false;
|
|
||||||
const record = value as Partial<PreviewElementMetadata>;
|
|
||||||
const bounds = record.bounds;
|
|
||||||
return typeof record.tag === 'string'
|
|
||||||
&& typeof record.selector === 'string'
|
|
||||||
&& typeof record.path === 'string'
|
|
||||||
&& Boolean(bounds)
|
|
||||||
&& typeof bounds?.x === 'number'
|
|
||||||
&& typeof bounds?.y === 'number'
|
|
||||||
&& typeof bounds?.width === 'number'
|
|
||||||
&& typeof bounds?.height === 'number';
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatPreviewAnnotationMarkdown = ({
|
|
||||||
pageUrl,
|
|
||||||
viewport,
|
|
||||||
devicePixelRatio,
|
|
||||||
target,
|
|
||||||
screenshotAttached,
|
|
||||||
intro,
|
|
||||||
}: {
|
|
||||||
pageUrl: string;
|
|
||||||
viewport: { width: number; height: number };
|
|
||||||
devicePixelRatio: number;
|
|
||||||
target: PreviewElementMetadata;
|
|
||||||
screenshotAttached: boolean;
|
|
||||||
intro: string;
|
|
||||||
}): string => {
|
|
||||||
const text = target.text.trim();
|
|
||||||
const attributes = Object.entries(target.attributes)
|
|
||||||
.map(([key, value]) => `${key}="${value}"`)
|
|
||||||
.join(' ');
|
|
||||||
const styles = target.computedStyle;
|
|
||||||
const bounds = target.bounds;
|
|
||||||
const center = target.center;
|
|
||||||
const introLabel = intro.replace(/[.:]+$/g, '');
|
|
||||||
const ancestry = target.ancestry
|
|
||||||
.map((entry) => entry.selectorPart)
|
|
||||||
.join(' > ');
|
|
||||||
|
|
||||||
return [
|
|
||||||
`${introLabel}:`,
|
|
||||||
`Page: ${pageUrl || 'preview'}`,
|
|
||||||
`Viewport: ${viewport.width}x${viewport.height}, DPR ${devicePixelRatio}`,
|
|
||||||
`Screenshot: ${screenshotAttached ? 'attached' : 'not attached'}`,
|
|
||||||
`Element: ${target.tag}`,
|
|
||||||
text ? `Text: ${text}` : null,
|
|
||||||
`- Selector: ${target.selector}`,
|
|
||||||
`- Path: ${target.path}`,
|
|
||||||
ancestry ? `- Ancestry: ${ancestry}` : null,
|
|
||||||
attributes ? `- Attributes: ${attributes}` : null,
|
|
||||||
`- Bounds: x=${Math.round(bounds.x)}, y=${Math.round(bounds.y)}, width=${Math.round(bounds.width)}, height=${Math.round(bounds.height)}`,
|
|
||||||
`- Center: x=${Math.round(center.x)}, y=${Math.round(center.y)}`,
|
|
||||||
`Styles: display=${styles.display}; position=${styles.position}; font=${styles.fontWeight} ${styles.fontSize} / ${styles.lineHeight} ${styles.fontFamily}; color=${styles.color}; background=${styles.backgroundColor}; z-index=${styles.zIndex}`,
|
|
||||||
].filter((line): line is string => typeof line === 'string').join('\n');
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderPreviewScreenshot = async (
|
|
||||||
iframe: HTMLIFrameElement,
|
|
||||||
target: PreviewElementMetadata,
|
|
||||||
): Promise<File | null> => {
|
|
||||||
const tauri = typeof window !== 'undefined'
|
|
||||||
? (window as unknown as { __TAURI__?: { core?: { invoke?: <T>(cmd: string, args?: Record<string, unknown>) => Promise<T> } } }).__TAURI__
|
|
||||||
: undefined;
|
|
||||||
if (typeof tauri?.core?.invoke === 'function') {
|
|
||||||
try {
|
|
||||||
const rect = iframe.getBoundingClientRect();
|
|
||||||
const capture = await tauri.core.invoke<{ mime: string; base64: string; width: number; height: number }>('desktop_capture_page_rect', {
|
|
||||||
x: rect.left,
|
|
||||||
y: rect.top,
|
|
||||||
width: rect.width,
|
|
||||||
height: rect.height,
|
|
||||||
});
|
|
||||||
const image = new Image();
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
image.onload = () => resolve();
|
|
||||||
image.onerror = () => reject(new Error('Failed to load desktop preview screenshot'));
|
|
||||||
image.src = `data:${capture.mime};base64,${capture.base64}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const width = Math.max(1, image.naturalWidth || capture.width || Math.floor(rect.width));
|
|
||||||
const height = Math.max(1, image.naturalHeight || capture.height || Math.floor(rect.height));
|
|
||||||
const maxOutputWidth = 1200;
|
|
||||||
const outputScale = Math.min(1, maxOutputWidth / width);
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = Math.floor(width * outputScale);
|
|
||||||
canvas.height = Math.floor(height * outputScale);
|
|
||||||
const context = canvas.getContext('2d');
|
|
||||||
if (!context) return null;
|
|
||||||
|
|
||||||
context.scale(outputScale, outputScale);
|
|
||||||
context.drawImage(image, 0, 0, width, height);
|
|
||||||
const xScale = width / Math.max(1, rect.width);
|
|
||||||
const yScale = height / Math.max(1, rect.height);
|
|
||||||
context.fillStyle = 'rgba(37, 99, 235, 0.28)';
|
|
||||||
context.strokeStyle = 'rgb(37, 99, 235)';
|
|
||||||
context.lineWidth = Math.max(2, 2 * xScale);
|
|
||||||
context.fillRect(target.bounds.x * xScale, target.bounds.y * yScale, target.bounds.width * xScale, target.bounds.height * yScale);
|
|
||||||
context.strokeRect(target.bounds.x * xScale, target.bounds.y * yScale, target.bounds.width * xScale, target.bounds.height * yScale);
|
|
||||||
|
|
||||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.82));
|
|
||||||
if (!blob) return null;
|
|
||||||
return new File([blob], `preview-annotation-${Date.now()}.jpg`, { type: 'image/jpeg' });
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('[preview] failed to capture annotation screenshot:', error);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeDirectoryKey = (value: string): string => {
|
const normalizeDirectoryKey = (value: string): string => {
|
||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
@@ -498,51 +385,18 @@ const normalizeBrowserUrl = (value: string): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const desktopAnnotationToFile = async (
|
const runIframeScript = async <T,>(iframe: HTMLIFrameElement, script: string): Promise<T> => {
|
||||||
base64: string,
|
const frameWindow = iframe.contentWindow;
|
||||||
screenshotWidth: number,
|
if (!frameWindow) {
|
||||||
screenshotHeight: number,
|
throw new Error('Iframe window is not available');
|
||||||
cssWidth: number,
|
|
||||||
cssHeight: number,
|
|
||||||
target: PreviewElementMetadata,
|
|
||||||
): Promise<File | null> => {
|
|
||||||
if (!base64) return null;
|
|
||||||
try {
|
|
||||||
const image = new Image();
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
|
||||||
image.onload = () => resolve();
|
|
||||||
image.onerror = () => reject(new Error('Failed to load desktop browser screenshot'));
|
|
||||||
image.src = `data:image/jpeg;base64,${base64}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const width = Math.max(1, image.naturalWidth || screenshotWidth);
|
|
||||||
const height = Math.max(1, image.naturalHeight || screenshotHeight);
|
|
||||||
const maxOutputWidth = 1200;
|
|
||||||
const outputScale = Math.min(1, maxOutputWidth / width);
|
|
||||||
const canvas = document.createElement('canvas');
|
|
||||||
canvas.width = Math.floor(width * outputScale);
|
|
||||||
canvas.height = Math.floor(height * outputScale);
|
|
||||||
const context = canvas.getContext('2d');
|
|
||||||
if (!context) return null;
|
|
||||||
|
|
||||||
context.scale(outputScale, outputScale);
|
|
||||||
context.drawImage(image, 0, 0, width, height);
|
|
||||||
const xScale = width / Math.max(1, cssWidth || width);
|
|
||||||
const yScale = height / Math.max(1, cssHeight || height);
|
|
||||||
context.fillStyle = 'rgba(37, 99, 235, 0.14)';
|
|
||||||
context.strokeStyle = 'rgb(37, 99, 235)';
|
|
||||||
context.lineWidth = Math.max(2, 2 * xScale);
|
|
||||||
context.fillRect(target.bounds.x * xScale, target.bounds.y * yScale, target.bounds.width * xScale, target.bounds.height * yScale);
|
|
||||||
context.strokeRect(target.bounds.x * xScale, target.bounds.y * yScale, target.bounds.width * xScale, target.bounds.height * yScale);
|
|
||||||
|
|
||||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.82));
|
|
||||||
if (!blob) return null;
|
|
||||||
return new File([blob], `browser-annotation-${Date.now()}.jpg`, { type: 'image/jpeg' });
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const evaluate = (frameWindow as Window & { eval: (code: string) => unknown }).eval;
|
||||||
|
const result = evaluate.call(frameWindow, script) as unknown;
|
||||||
|
return await Promise.resolve(result) as T;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
const buildEmbeddedSessionChatURL = (sessionID: string, directory: string | null, readOnly: boolean): string => {
|
const buildEmbeddedSessionChatURL = (sessionID: string, directory: string | null, readOnly: boolean): string => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') {
|
||||||
return '';
|
return '';
|
||||||
@@ -585,27 +439,6 @@ type PreviewProxyState =
|
|||||||
| { status: 'ready'; proxyBasePath: string; expiresAt: number }
|
| { status: 'ready'; proxyBasePath: string; expiresAt: number }
|
||||||
| { status: 'error'; message: string };
|
| { status: 'error'; message: string };
|
||||||
|
|
||||||
// Module-scoped, in-memory cache of registered proxy targets keyed by the
|
|
||||||
// fully-qualified upstream URL. Survives PreviewPane unmount/remount and tab
|
|
||||||
// switches, but intentionally does NOT survive a full page reload: the server
|
|
||||||
// holds the target map in memory and the auth cookie is HttpOnly + scoped to
|
|
||||||
// the proxy id, so a stale persisted entry would 404 after a server restart.
|
|
||||||
// Entries are evicted on registration error (refetched) or when the upstream
|
|
||||||
// returns 403 (cookie expired) / 404 (target unknown) at iframe load time.
|
|
||||||
type CachedProxyTarget = { proxyBasePath: string; expiresAt: number };
|
|
||||||
const previewProxyTargetCache = new Map<string, CachedProxyTarget>();
|
|
||||||
const PREVIEW_PROXY_CACHE_SAFETY_MS = 30_000;
|
|
||||||
|
|
||||||
const getCachedProxyTarget = (url: string): CachedProxyTarget | null => {
|
|
||||||
const entry = previewProxyTargetCache.get(url);
|
|
||||||
if (!entry) return null;
|
|
||||||
if (entry.expiresAt - Date.now() <= PREVIEW_PROXY_CACHE_SAFETY_MS) {
|
|
||||||
previewProxyTargetCache.delete(url);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return entry;
|
|
||||||
};
|
|
||||||
|
|
||||||
const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
const PreviewPane: React.FC<PreviewPaneProps> = ({ rawUrl, onNavigate }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { currentTheme } = useThemeSystem();
|
const { currentTheme } = useThemeSystem();
|
||||||
@@ -1343,6 +1176,459 @@ type DesktopBrowserPaneProps = {
|
|||||||
tabID: string;
|
tabID: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isElectronBrowserRuntime = (): boolean => {
|
||||||
|
return typeof window !== 'undefined' && Boolean(window.__OPENCHAMBER_ELECTRON__);
|
||||||
|
};
|
||||||
|
|
||||||
|
const IframeBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, directory, tabID }) => {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const iframeRef = React.useRef<HTMLIFrameElement | null>(null);
|
||||||
|
const setContextPanelTabTargetPath = useUIStore((state) => state.setContextPanelTabTargetPath);
|
||||||
|
const normalized = normalizeBrowserUrl(initialUrl);
|
||||||
|
const startUrl = normalized !== 'about:blank' ? normalized : '';
|
||||||
|
const [urlInput, setUrlInput] = React.useState(startUrl);
|
||||||
|
const [currentUrl, setCurrentUrl] = React.useState(startUrl);
|
||||||
|
const [history, setHistory] = React.useState<string[]>(() => startUrl ? [startUrl] : []);
|
||||||
|
const [historyIndex, setHistoryIndex] = React.useState(() => startUrl ? 0 : -1);
|
||||||
|
const [reloadNonce, bumpReload] = React.useReducer((value: number) => value + 1, 0);
|
||||||
|
const [isLoading, setIsLoading] = React.useState(Boolean(startUrl));
|
||||||
|
const [isInspecting, setIsInspecting] = React.useState(false);
|
||||||
|
const [hoverTarget, setHoverTarget] = React.useState<PreviewElementMetadata | null>(null);
|
||||||
|
const [proxyState, setProxyState] = React.useState<PreviewProxyState>({ status: 'idle' });
|
||||||
|
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||||
|
const newSessionDraftOpen = useSessionUIStore((state) => state.newSessionDraft?.open);
|
||||||
|
const addInlineCommentDraft = useInlineCommentDraftStore((state) => state.addDraft);
|
||||||
|
const addAttachedFile = useInputStore((state) => state.addAttachedFile);
|
||||||
|
|
||||||
|
const persistUrl = React.useCallback((url: string) => {
|
||||||
|
if (!url || url === 'about:blank' || !directory || !tabID) return;
|
||||||
|
setContextPanelTabTargetPath(directory, tabID, url);
|
||||||
|
}, [directory, tabID, setContextPanelTabTargetPath]);
|
||||||
|
|
||||||
|
const applyUrl = React.useCallback((url: string, options?: { replaceHistory?: boolean }) => {
|
||||||
|
const normalizedUrl = normalizeBrowserUrl(url);
|
||||||
|
const nextUrl = normalizedUrl !== 'about:blank' ? normalizedUrl : '';
|
||||||
|
setCurrentUrl(nextUrl);
|
||||||
|
setUrlInput(nextUrl);
|
||||||
|
setIsLoading(Boolean(nextUrl));
|
||||||
|
persistUrl(nextUrl);
|
||||||
|
|
||||||
|
setHistory((current) => {
|
||||||
|
if (!nextUrl) {
|
||||||
|
setHistoryIndex(-1);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options?.replaceHistory) {
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kept = historyIndex >= 0 ? current.slice(0, historyIndex + 1) : [];
|
||||||
|
const previous = kept[kept.length - 1];
|
||||||
|
if (previous === nextUrl) {
|
||||||
|
setHistoryIndex(kept.length - 1);
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextHistory = [...kept, nextUrl];
|
||||||
|
setHistoryIndex(nextHistory.length - 1);
|
||||||
|
return nextHistory;
|
||||||
|
});
|
||||||
|
}, [historyIndex, persistUrl]);
|
||||||
|
|
||||||
|
const goToHistory = React.useCallback((nextIndex: number) => {
|
||||||
|
const nextUrl = history[nextIndex];
|
||||||
|
if (!nextUrl) return;
|
||||||
|
setHistoryIndex(nextIndex);
|
||||||
|
setCurrentUrl(nextUrl);
|
||||||
|
setUrlInput(nextUrl);
|
||||||
|
setIsLoading(true);
|
||||||
|
persistUrl(nextUrl);
|
||||||
|
}, [history, persistUrl]);
|
||||||
|
|
||||||
|
const handleReload = React.useCallback(() => {
|
||||||
|
if (!currentUrl) return;
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
iframeRef.current?.contentWindow?.location.reload();
|
||||||
|
} catch {
|
||||||
|
bumpReload();
|
||||||
|
}
|
||||||
|
}, [currentUrl]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!currentUrl) {
|
||||||
|
setProxyState({ status: 'idle' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyTargetKey = getBrowserProxyTargetKey(currentUrl);
|
||||||
|
const cached = getCachedProxyTarget(proxyTargetKey);
|
||||||
|
if (cached) {
|
||||||
|
setProxyState({ status: 'ready', proxyBasePath: cached.proxyBasePath, expiresAt: cached.expiresAt });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setProxyState({ status: 'loading' });
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/preview/targets', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ url: currentUrl, allowExternal: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorBody = await response.json().catch(() => ({}));
|
||||||
|
const message = typeof errorBody?.error === 'string'
|
||||||
|
? errorBody.error
|
||||||
|
: `HTTP ${response.status}`;
|
||||||
|
if (!cancelled) {
|
||||||
|
setProxyState({ status: 'error', message });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await response.json() as { proxyBasePath?: unknown; expiresAt?: unknown };
|
||||||
|
const proxyBasePath = typeof body.proxyBasePath === 'string' ? body.proxyBasePath : '';
|
||||||
|
const expiresAt = typeof body.expiresAt === 'number' ? body.expiresAt : 0;
|
||||||
|
if (!proxyBasePath) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setProxyState({ status: 'error', message: t('contextPanel.preview.proxyError') });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewProxyTargetCache.set(proxyTargetKey, { proxyBasePath, expiresAt });
|
||||||
|
if (!cancelled) {
|
||||||
|
setProxyState({ status: 'ready', proxyBasePath, expiresAt });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!cancelled) {
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
setProxyState({ status: 'error', message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [currentUrl, t]);
|
||||||
|
|
||||||
|
const proxySrc = React.useMemo(() => {
|
||||||
|
if (!currentUrl || proxyState.status !== 'ready') return '';
|
||||||
|
try {
|
||||||
|
const parsed = new URL(currentUrl);
|
||||||
|
const path = parsed.pathname || '/';
|
||||||
|
return `${proxyState.proxyBasePath}${path}${parsed.search}${parsed.hash}`;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}, [currentUrl, proxyState]);
|
||||||
|
|
||||||
|
const iframeSrc = proxySrc || (proxyState.status === 'error' ? currentUrl : '');
|
||||||
|
|
||||||
|
const getCurrentUrlFromFrameUrl = React.useCallback((frameUrl: string): string => {
|
||||||
|
if (!frameUrl || !currentUrl || proxyState.status !== 'ready') return '';
|
||||||
|
try {
|
||||||
|
const parsedFrameUrl = new URL(frameUrl, window.location.origin);
|
||||||
|
const proxyBasePath = proxyState.proxyBasePath.endsWith('/')
|
||||||
|
? proxyState.proxyBasePath.slice(0, -1)
|
||||||
|
: proxyState.proxyBasePath;
|
||||||
|
if (parsedFrameUrl.origin !== window.location.origin || !parsedFrameUrl.pathname.startsWith(proxyBasePath)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const rest = parsedFrameUrl.pathname.slice(proxyBasePath.length) || '/';
|
||||||
|
const upstreamOrigin = new URL(currentUrl).origin;
|
||||||
|
return new URL(`${rest}${parsedFrameUrl.search}${parsedFrameUrl.hash}`, upstreamOrigin).toString();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}, [currentUrl, proxyState]);
|
||||||
|
|
||||||
|
const getUpstreamUrlFromLocalFrameUrl = React.useCallback((frameUrl: string): string => {
|
||||||
|
if (!frameUrl || !currentUrl || proxyState.status !== 'ready') return '';
|
||||||
|
try {
|
||||||
|
const parsedFrameUrl = new URL(frameUrl, window.location.origin);
|
||||||
|
const upstreamOrigin = new URL(currentUrl).origin;
|
||||||
|
if (parsedFrameUrl.origin !== window.location.origin || upstreamOrigin === window.location.origin) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyBasePath = proxyState.proxyBasePath.endsWith('/')
|
||||||
|
? proxyState.proxyBasePath.slice(0, -1)
|
||||||
|
: proxyState.proxyBasePath;
|
||||||
|
if (parsedFrameUrl.pathname.startsWith(proxyBasePath)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new URL(`${parsedFrameUrl.pathname}${parsedFrameUrl.search}${parsedFrameUrl.hash}`, upstreamOrigin).toString();
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
}, [currentUrl, proxyState]);
|
||||||
|
|
||||||
|
const postInspectMode = React.useCallback((enabled: boolean) => {
|
||||||
|
const frameWindow = iframeRef.current?.contentWindow;
|
||||||
|
if (!frameWindow) return;
|
||||||
|
frameWindow.postMessage({
|
||||||
|
source: 'openchamber-preview-parent',
|
||||||
|
version: 1,
|
||||||
|
type: 'set-inspect-mode',
|
||||||
|
enabled,
|
||||||
|
}, window.location.origin);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const attachBrowserAnnotation = React.useCallback(async (target: PreviewElementMetadata) => {
|
||||||
|
const sessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : null);
|
||||||
|
if (!sessionKey) {
|
||||||
|
toast.error(t('contextPanel.preview.inspect.attachNoSession'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const iframe = iframeRef.current;
|
||||||
|
const frameWindow = iframe?.contentWindow;
|
||||||
|
const rect = iframe?.getBoundingClientRect();
|
||||||
|
const viewport = {
|
||||||
|
width: Number.isFinite(frameWindow?.innerWidth) ? frameWindow?.innerWidth ?? rect?.width ?? 0 : rect?.width ?? 0,
|
||||||
|
height: Number.isFinite(frameWindow?.innerHeight) ? frameWindow?.innerHeight ?? rect?.height ?? 0 : rect?.height ?? 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
const file = iframe ? await renderPreviewScreenshot(iframe, target) : null;
|
||||||
|
const screenshotAttached = Boolean(file);
|
||||||
|
if (file) {
|
||||||
|
await addAttachedFile(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
addInlineCommentDraft({
|
||||||
|
sessionKey,
|
||||||
|
source: 'preview-annotation',
|
||||||
|
fileLabel: currentUrl || 'browser',
|
||||||
|
startLine: 1,
|
||||||
|
endLine: 1,
|
||||||
|
code: formatPreviewAnnotationMarkdown({
|
||||||
|
pageUrl: currentUrl,
|
||||||
|
viewport,
|
||||||
|
devicePixelRatio: window.devicePixelRatio || 1,
|
||||||
|
target,
|
||||||
|
screenshotAttached,
|
||||||
|
intro: t(screenshotAttached
|
||||||
|
? 'contextPanel.preview.inspect.attachAnnotationWithScreenshot'
|
||||||
|
: 'contextPanel.preview.inspect.attachAnnotation'),
|
||||||
|
}),
|
||||||
|
language: 'markdown',
|
||||||
|
text: '',
|
||||||
|
});
|
||||||
|
toast.success(t('contextPanel.preview.inspect.attached'));
|
||||||
|
}, [addAttachedFile, addInlineCommentDraft, currentSessionId, currentUrl, newSessionDraftOpen, t]);
|
||||||
|
|
||||||
|
const cancelInspect = React.useCallback(() => {
|
||||||
|
const iframe = iframeRef.current;
|
||||||
|
setHoverTarget(null);
|
||||||
|
postInspectMode(false);
|
||||||
|
if (!iframe) return;
|
||||||
|
void runIframeScript<unknown>(iframe, DESKTOP_BROWSER_CANCEL_INSPECT_SCRIPT).catch(() => {});
|
||||||
|
}, [postInspectMode]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isInspecting) return;
|
||||||
|
const handler = (event: KeyboardEvent) => {
|
||||||
|
if (event.key !== 'Escape') return;
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
setIsInspecting(false);
|
||||||
|
cancelInspect();
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handler, true);
|
||||||
|
return () => window.removeEventListener('keydown', handler, true);
|
||||||
|
}, [cancelInspect, isInspecting]);
|
||||||
|
|
||||||
|
React.useEffect(() => () => cancelInspect(), [cancelInspect]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
const handler = (event: MessageEvent<PreviewBridgeMessage>) => {
|
||||||
|
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||||
|
const data = event.data;
|
||||||
|
if (!data || data.source !== 'openchamber-preview-bridge' || data.version !== 1) return;
|
||||||
|
|
||||||
|
if (data.type === 'ready') {
|
||||||
|
const frameUrl = typeof data.url === 'string' ? data.url : '';
|
||||||
|
const nextUrl = getCurrentUrlFromFrameUrl(frameUrl);
|
||||||
|
if (nextUrl && nextUrl !== currentUrl) {
|
||||||
|
applyUrl(nextUrl);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === 'hover') {
|
||||||
|
setHoverTarget(isPreviewElementMetadata(data.target) ? data.target : null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === 'select' && isPreviewElementMetadata(data.target)) {
|
||||||
|
setHoverTarget(null);
|
||||||
|
setIsInspecting(false);
|
||||||
|
postInspectMode(false);
|
||||||
|
void attachBrowserAnnotation(data.target);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.type === 'navigate-preview') {
|
||||||
|
const nextUrl = typeof data.url === 'string' ? data.url : '';
|
||||||
|
const upstreamUrl = getUpstreamUrlFromLocalFrameUrl(nextUrl);
|
||||||
|
if (upstreamUrl) {
|
||||||
|
applyUrl(upstreamUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (nextUrl) {
|
||||||
|
applyUrl(nextUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handler);
|
||||||
|
return () => window.removeEventListener('message', handler);
|
||||||
|
}, [applyUrl, attachBrowserAnnotation, currentUrl, getCurrentUrlFromFrameUrl, getUpstreamUrlFromLocalFrameUrl, postInspectMode]);
|
||||||
|
|
||||||
|
const handleInspect = React.useCallback(() => {
|
||||||
|
const iframe = iframeRef.current;
|
||||||
|
if (!iframe || !currentUrl) return;
|
||||||
|
|
||||||
|
if (isInspecting) {
|
||||||
|
setIsInspecting(false);
|
||||||
|
cancelInspect();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proxySrc) {
|
||||||
|
setHoverTarget(null);
|
||||||
|
setIsInspecting(true);
|
||||||
|
postInspectMode(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsInspecting(true);
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const target = await runIframeScript<unknown>(iframe, DESKTOP_BROWSER_INSPECT_SCRIPT);
|
||||||
|
setIsInspecting(false);
|
||||||
|
if (!target || !isPreviewElementMetadata(target)) return;
|
||||||
|
await attachBrowserAnnotation(target);
|
||||||
|
} catch {
|
||||||
|
setIsInspecting(false);
|
||||||
|
toast.error(t('contextPanel.browser.inspectUnavailable'));
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [attachBrowserAnnotation, cancelInspect, currentUrl, isInspecting, postInspectMode, proxySrc, t]);
|
||||||
|
|
||||||
|
const handleIframeLoad = React.useCallback(() => {
|
||||||
|
try {
|
||||||
|
const frameUrl = iframeRef.current?.contentWindow?.location.href || '';
|
||||||
|
const upstreamUrl = getUpstreamUrlFromLocalFrameUrl(frameUrl);
|
||||||
|
if (upstreamUrl) {
|
||||||
|
setIsLoading(true);
|
||||||
|
applyUrl(upstreamUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Cross-origin direct iframe fallback; regular load handling still applies.
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(false);
|
||||||
|
if (isInspecting && proxySrc) {
|
||||||
|
postInspectMode(true);
|
||||||
|
}
|
||||||
|
}, [applyUrl, getUpstreamUrlFromLocalFrameUrl, isInspecting, postInspectMode, proxySrc]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute inset-0 flex flex-col bg-background">
|
||||||
|
<div className="flex items-center gap-1 border-b border-border/40 bg-[var(--surface-background)] px-2 py-1">
|
||||||
|
<Button type="button" variant="ghost" size="sm" className="h-7 w-7 p-0" disabled={historyIndex <= 0} onClick={() => goToHistory(historyIndex - 1)}>
|
||||||
|
<Icon name="arrow-left" className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm" className="h-7 w-7 p-0" disabled={historyIndex < 0 || historyIndex >= history.length - 1} onClick={() => goToHistory(historyIndex + 1)}>
|
||||||
|
<Icon name="arrow-right" className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm" className="h-7 w-7 p-0" disabled={!currentUrl} onClick={handleReload}>
|
||||||
|
<Icon name="refresh" className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<form className="min-w-0 flex-1" onSubmit={(event) => { event.preventDefault(); applyUrl(urlInput); }}>
|
||||||
|
<input
|
||||||
|
value={urlInput}
|
||||||
|
onChange={(event) => setUrlInput(event.target.value)}
|
||||||
|
className="h-7 w-full rounded-md border border-border/50 bg-[var(--surface-elevated)] px-2 typography-micro text-foreground outline-none focus:border-[var(--interactive-focus-ring)]"
|
||||||
|
aria-label={t('contextPanel.browser.addressAria')}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant={isInspecting ? 'secondary' : 'ghost'}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 w-7 p-0"
|
||||||
|
disabled={!currentUrl}
|
||||||
|
onClick={handleInspect}
|
||||||
|
title={t('contextPanel.preview.inspect.toggle')}
|
||||||
|
aria-label={t('contextPanel.preview.inspect.toggle')}
|
||||||
|
>
|
||||||
|
<Icon name="cursor" className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" size="sm" className="h-7 w-7 p-0" disabled={!currentUrl} onClick={() => void openExternalUrl(currentUrl)}>
|
||||||
|
<Icon name="external-link" className="h-3.5 w-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="relative min-h-0 flex-1 bg-background">
|
||||||
|
{iframeSrc ? (
|
||||||
|
<div className="absolute inset-0">
|
||||||
|
<iframe
|
||||||
|
key={`${iframeSrc}:${reloadNonce}`}
|
||||||
|
ref={iframeRef}
|
||||||
|
src={iframeSrc}
|
||||||
|
title={t('contextPanel.browser.empty')}
|
||||||
|
className="absolute inset-0 h-full w-full border-0 bg-background"
|
||||||
|
allow="clipboard-read; clipboard-write; fullscreen"
|
||||||
|
allowFullScreen
|
||||||
|
onLoad={handleIframeLoad}
|
||||||
|
/>
|
||||||
|
{isInspecting && hoverTarget ? (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute rounded-sm border-2 border-[var(--interactive-focus-ring)] bg-[var(--interactive-focus-ring)]/35"
|
||||||
|
style={{
|
||||||
|
left: hoverTarget.bounds.x,
|
||||||
|
top: hoverTarget.bounds.y,
|
||||||
|
width: hoverTarget.bounds.width,
|
||||||
|
height: hoverTarget.bounds.height,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute -top-6 left-0 max-w-64 truncate rounded bg-[var(--surface-elevated)] px-2 py-0.5 typography-micro text-foreground shadow">
|
||||||
|
{hoverTarget.tag}{hoverTarget.text ? ` · ${hoverTarget.text}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center gap-6 bg-background p-6 text-center">
|
||||||
|
<OpenChamberLogo width={140} height={140} className="opacity-20" />
|
||||||
|
<span className="typography-ui-header text-muted-foreground">{t('contextPanel.browser.empty')}</span>
|
||||||
|
<span className="max-w-sm typography-micro text-muted-foreground">{t('contextPanel.browser.emptyHint')}</span>
|
||||||
|
<span className="max-w-md typography-micro leading-relaxed text-status-warning/70">{t('contextPanel.browser.trustNotice')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-background/70 typography-micro text-muted-foreground">
|
||||||
|
{t('common.loading')}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const DesktopBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, directory, tabID }) => {
|
const DesktopBrowserPane: React.FC<DesktopBrowserPaneProps> = ({ initialUrl, directory, tabID }) => {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const webviewRef = React.useRef<WebviewElement | null>(null);
|
const webviewRef = React.useRef<WebviewElement | null>(null);
|
||||||
@@ -1932,6 +2218,7 @@ export const ContextPanel: React.FC = () => {
|
|||||||
() => tabs.filter((tab) => tab.mode === 'browser'),
|
() => tabs.filter((tab) => tab.mode === 'browser'),
|
||||||
[tabs],
|
[tabs],
|
||||||
);
|
);
|
||||||
|
const BrowserPane = isElectronBrowserRuntime() ? DesktopBrowserPane : IframeBrowserPane;
|
||||||
const hasFileTabs = React.useMemo(
|
const hasFileTabs = React.useMemo(
|
||||||
() => tabs.some((tab) => tab.mode === 'file'),
|
() => tabs.some((tab) => tab.mode === 'file'),
|
||||||
[tabs],
|
[tabs],
|
||||||
@@ -2095,10 +2382,10 @@ export const ContextPanel: React.FC = () => {
|
|||||||
key={tab.id}
|
key={tab.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute inset-0',
|
'absolute inset-0',
|
||||||
activeTab?.mode !== 'browser' && 'hidden'
|
activeTab?.id !== tab.id && 'hidden'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<DesktopBrowserPane initialUrl={tab.targetPath ?? ''} directory={directoryKey} tabID={tab.id} />
|
<BrowserPane initialUrl={tab.targetPath ?? ''} directory={directoryKey} tabID={tab.id} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' ? activeNonChatContent : null}
|
{activeTab?.mode !== 'chat' && !isFileTabActive && activeTab?.mode !== 'browser' ? activeNonChatContent : null}
|
||||||
|
|||||||
@@ -1960,7 +1960,7 @@ export const Header: React.FC<HeaderProps> = ({
|
|||||||
onClick={toggleBottomTerminal}
|
onClick={toggleBottomTerminal}
|
||||||
Icon={'terminal-box'}
|
Icon={'terminal-box'}
|
||||||
/>
|
/>
|
||||||
{hasElectronDesktopIPC ? (
|
{!isMobile ? (
|
||||||
<HeaderIconActionButton
|
<HeaderIconActionButton
|
||||||
title={t('contextPanel.browser.open')}
|
title={t('contextPanel.browser.open')}
|
||||||
ariaLabel={t('contextPanel.browser.open')}
|
ariaLabel={t('contextPanel.browser.open')}
|
||||||
|
|||||||
@@ -842,6 +842,8 @@ export const dict = {
|
|||||||
'contextPanel.browser.addressAria': 'Browser address',
|
'contextPanel.browser.addressAria': 'Browser address',
|
||||||
'contextPanel.browser.empty': 'Web browser',
|
'contextPanel.browser.empty': 'Web browser',
|
||||||
'contextPanel.browser.emptyHint': 'Enter an address above to start browsing the web',
|
'contextPanel.browser.emptyHint': 'Enter an address above to start browsing the web',
|
||||||
|
'contextPanel.browser.inspectUnavailable': 'This page cannot be inspected from the browser panel.',
|
||||||
|
'contextPanel.browser.trustNotice': 'Pages opened here run with full access to OpenChamber — needed for inspect and screenshots. Only open sites you trust: a malicious page could read your data or act on your behalf.',
|
||||||
'contextPanel.tab.closeTabAria': 'Close {label} tab',
|
'contextPanel.tab.closeTabAria': 'Close {label} tab',
|
||||||
'contextPanel.actions.collapsePanel': 'Collapse panel',
|
'contextPanel.actions.collapsePanel': 'Collapse panel',
|
||||||
'contextPanel.actions.expandPanel': 'Expand panel',
|
'contextPanel.actions.expandPanel': 'Expand panel',
|
||||||
|
|||||||
@@ -843,6 +843,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"contextPanel.browser.addressAria": "Dirección del navegador",
|
"contextPanel.browser.addressAria": "Dirección del navegador",
|
||||||
"contextPanel.browser.empty": "Navegador web",
|
"contextPanel.browser.empty": "Navegador web",
|
||||||
"contextPanel.browser.emptyHint": "Ingrese una dirección arriba para comenzar a navegar",
|
"contextPanel.browser.emptyHint": "Ingrese una dirección arriba para comenzar a navegar",
|
||||||
|
"contextPanel.browser.inspectUnavailable": "Esta página no se puede inspeccionar desde el panel del navegador.",
|
||||||
|
"contextPanel.browser.trustNotice": "Las páginas que abras aquí se ejecutan con acceso completo a OpenChamber: necesario para la inspección y las capturas. Abre solo sitios de confianza: una página maliciosa podría leer tus datos o actuar en tu nombre.",
|
||||||
"contextPanel.tab.closeTabAria": "Cerrar pestaña {label}",
|
"contextPanel.tab.closeTabAria": "Cerrar pestaña {label}",
|
||||||
"contextPanel.actions.collapsePanel": "Colapsar panel",
|
"contextPanel.actions.collapsePanel": "Colapsar panel",
|
||||||
"contextPanel.actions.expandPanel": "Expandir panel",
|
"contextPanel.actions.expandPanel": "Expandir panel",
|
||||||
|
|||||||
@@ -843,6 +843,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'contextPanel.browser.addressAria': '브라우저 주소',
|
'contextPanel.browser.addressAria': '브라우저 주소',
|
||||||
'contextPanel.browser.empty': '웹 브라우저',
|
'contextPanel.browser.empty': '웹 브라우저',
|
||||||
'contextPanel.browser.emptyHint': '위에 주소를 입력하여 탐색을 시작하세요',
|
'contextPanel.browser.emptyHint': '위에 주소를 입력하여 탐색을 시작하세요',
|
||||||
|
'contextPanel.browser.inspectUnavailable': '브라우저 패널에서 이 페이지를 검사할 수 없습니다.',
|
||||||
|
'contextPanel.browser.trustNotice': '여기서 여는 페이지는 OpenChamber에 대한 전체 액세스 권한으로 실행됩니다 — 검사와 스크린샷에 필요합니다. 신뢰하는 사이트만 여세요: 악성 페이지가 데이터를 읽거나 사용자를 대신해 동작할 수 있습니다.',
|
||||||
'contextPanel.preview.actions.reload': '미리보기 새로고침',
|
'contextPanel.preview.actions.reload': '미리보기 새로고침',
|
||||||
'contextPanel.preview.actions.openExternal': '브라우저에서 열기',
|
'contextPanel.preview.actions.openExternal': '브라우저에서 열기',
|
||||||
'contextPanel.preview.actions.retry': '다시 시도',
|
'contextPanel.preview.actions.retry': '다시 시도',
|
||||||
|
|||||||
@@ -1094,6 +1094,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'contextPanel.browser.addressAria': 'Adres przeglądarki',
|
'contextPanel.browser.addressAria': 'Adres przeglądarki',
|
||||||
'contextPanel.browser.empty': 'Przeglądarka internetowa',
|
'contextPanel.browser.empty': 'Przeglądarka internetowa',
|
||||||
'contextPanel.browser.emptyHint': 'Wprowadź adres powyżej, aby rozpocząć przeglądanie',
|
'contextPanel.browser.emptyHint': 'Wprowadź adres powyżej, aby rozpocząć przeglądanie',
|
||||||
|
'contextPanel.browser.inspectUnavailable': 'Nie można sprawdzić tej strony z panelu przeglądarki.',
|
||||||
|
'contextPanel.browser.trustNotice': 'Strony otwierane tutaj działają z pełnym dostępem do OpenChamber — jest to wymagane do inspekcji i zrzutów ekranu. Otwieraj tylko zaufane witryny: złośliwa strona może odczytać Twoje dane lub działać w Twoim imieniu.',
|
||||||
'contextPanel.preview.actions.openExternal': 'Otwórz w przeglądarce',
|
'contextPanel.preview.actions.openExternal': 'Otwórz w przeglądarce',
|
||||||
'contextPanel.preview.actions.reload': 'Odśwież podgląd',
|
'contextPanel.preview.actions.reload': 'Odśwież podgląd',
|
||||||
'contextPanel.preview.actions.retry': 'Ponów',
|
'contextPanel.preview.actions.retry': 'Ponów',
|
||||||
|
|||||||
@@ -843,6 +843,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"contextPanel.browser.addressAria": "Endereço do navegador",
|
"contextPanel.browser.addressAria": "Endereço do navegador",
|
||||||
"contextPanel.browser.empty": "Navegador web",
|
"contextPanel.browser.empty": "Navegador web",
|
||||||
"contextPanel.browser.emptyHint": "Digite um endereço acima para começar a navegar",
|
"contextPanel.browser.emptyHint": "Digite um endereço acima para começar a navegar",
|
||||||
|
"contextPanel.browser.inspectUnavailable": "Esta página não pode ser inspecionada pelo painel do navegador.",
|
||||||
|
"contextPanel.browser.trustNotice": "As páginas abertas aqui são executadas com acesso total ao OpenChamber — necessário para inspeção e capturas de tela. Abra apenas sites confiáveis: uma página maliciosa pode ler seus dados ou agir em seu nome.",
|
||||||
"contextPanel.tab.closeTabAria": "Fechar aba {label}",
|
"contextPanel.tab.closeTabAria": "Fechar aba {label}",
|
||||||
"contextPanel.actions.collapsePanel": "Recolher painel",
|
"contextPanel.actions.collapsePanel": "Recolher painel",
|
||||||
"contextPanel.actions.expandPanel": "Expandir painel",
|
"contextPanel.actions.expandPanel": "Expandir painel",
|
||||||
|
|||||||
@@ -843,6 +843,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
"contextPanel.browser.addressAria": "Адреса браузера",
|
"contextPanel.browser.addressAria": "Адреса браузера",
|
||||||
"contextPanel.browser.empty": "Веб-браузер",
|
"contextPanel.browser.empty": "Веб-браузер",
|
||||||
"contextPanel.browser.emptyHint": "Введіть адресу вище, щоб почати перегляд",
|
"contextPanel.browser.emptyHint": "Введіть адресу вище, щоб почати перегляд",
|
||||||
|
"contextPanel.browser.inspectUnavailable": "Цю сторінку неможливо інспектувати з панелі браузера.",
|
||||||
|
"contextPanel.browser.trustNotice": "Сторінки, відкриті тут, працюють із повним доступом до OpenChamber — це потрібно для inspect і скріншотів. Відкривайте лише сайти, яким довіряєте: шкідлива сторінка може прочитати ваші дані чи діяти від вашого імені.",
|
||||||
"contextPanel.tab.closeTabAria": "Закрити вкладку {label}",
|
"contextPanel.tab.closeTabAria": "Закрити вкладку {label}",
|
||||||
"contextPanel.actions.collapsePanel": "Згорнути панель",
|
"contextPanel.actions.collapsePanel": "Згорнути панель",
|
||||||
"contextPanel.actions.expandPanel": "Розгорнути панель",
|
"contextPanel.actions.expandPanel": "Розгорнути панель",
|
||||||
|
|||||||
@@ -843,6 +843,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'contextPanel.browser.addressAria': '浏览器地址',
|
'contextPanel.browser.addressAria': '浏览器地址',
|
||||||
'contextPanel.browser.empty': '网页浏览器',
|
'contextPanel.browser.empty': '网页浏览器',
|
||||||
'contextPanel.browser.emptyHint': '在上方输入网址开始浏览',
|
'contextPanel.browser.emptyHint': '在上方输入网址开始浏览',
|
||||||
|
'contextPanel.browser.inspectUnavailable': '无法从浏览器面板检查此页面。',
|
||||||
|
'contextPanel.browser.trustNotice': '在此打开的页面以对 OpenChamber 的完全访问权限运行 — 检查和截图需要此权限。仅打开你信任的站点:恶意页面可能读取你的数据或以你的身份执行操作。',
|
||||||
'contextPanel.tab.closeTabAria': '关闭 {label} 标签',
|
'contextPanel.tab.closeTabAria': '关闭 {label} 标签',
|
||||||
'contextPanel.actions.collapsePanel': '折叠面板',
|
'contextPanel.actions.collapsePanel': '折叠面板',
|
||||||
'contextPanel.actions.expandPanel': '展开面板',
|
'contextPanel.actions.expandPanel': '展开面板',
|
||||||
|
|||||||
@@ -842,6 +842,8 @@ export const dict: Record<I18nKey, string> = {
|
|||||||
'contextPanel.browser.addressAria': '瀏覽器網址',
|
'contextPanel.browser.addressAria': '瀏覽器網址',
|
||||||
'contextPanel.browser.empty': '網頁瀏覽器',
|
'contextPanel.browser.empty': '網頁瀏覽器',
|
||||||
'contextPanel.browser.emptyHint': '在上方輸入網址開始瀏覽',
|
'contextPanel.browser.emptyHint': '在上方輸入網址開始瀏覽',
|
||||||
|
'contextPanel.browser.inspectUnavailable': '無法從瀏覽器面板檢查此頁面。',
|
||||||
|
'contextPanel.browser.trustNotice': '在此開啟的頁面以對 OpenChamber 的完整存取權限執行 — 檢查與截圖需要此權限。僅開啟你信任的網站:惡意頁面可能讀取你的資料或以你的身分執行操作。',
|
||||||
'contextPanel.tab.closeTabAria': '關閉 {label} 分頁',
|
'contextPanel.tab.closeTabAria': '關閉 {label} 分頁',
|
||||||
'contextPanel.actions.collapsePanel': '摺疊面板',
|
'contextPanel.actions.collapsePanel': '摺疊面板',
|
||||||
'contextPanel.actions.expandPanel': '展開面板',
|
'contextPanel.actions.expandPanel': '展開面板',
|
||||||
|
|||||||
@@ -0,0 +1,960 @@
|
|||||||
|
import { snapdom } from '@zumer/snapdom';
|
||||||
|
import { getFontEmbedCSS, toJpeg } from 'html-to-image';
|
||||||
|
|
||||||
|
export type PreviewElementMetadata = {
|
||||||
|
frame: 'top';
|
||||||
|
tag: string;
|
||||||
|
text: string;
|
||||||
|
selector: string;
|
||||||
|
path: string;
|
||||||
|
bounds: { x: number; y: number; width: number; height: number };
|
||||||
|
center: { x: number; y: number };
|
||||||
|
attributes: Record<string, string>;
|
||||||
|
computedStyle: Record<string, string>;
|
||||||
|
ancestry: Array<{ tag: string; id?: string; className?: string; selectorPart: string }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const isXYRecord = (value: unknown): value is { x: number; y: number } => {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
const record = value as { x?: unknown; y?: unknown };
|
||||||
|
return typeof record.x === 'number' && typeof record.y === 'number';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isStringRecord = (value: unknown): value is Record<string, string> => {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
return Object.values(value as Record<string, unknown>).every((entry) => typeof entry === 'string');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Bridge messages arrive via postMessage from a (possibly untrusted) proxied page,
|
||||||
|
// so validate the full shape every downstream consumer touches — not just bounds.
|
||||||
|
// formatPreviewAnnotationMarkdown dereferences text/attributes/center/computedStyle/
|
||||||
|
// ancestry, so a partially-valid payload would otherwise throw at format time.
|
||||||
|
export const isPreviewElementMetadata = (value: unknown): value is PreviewElementMetadata => {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
const record = value as Partial<PreviewElementMetadata>;
|
||||||
|
const bounds = record.bounds;
|
||||||
|
return typeof record.tag === 'string'
|
||||||
|
&& typeof record.text === 'string'
|
||||||
|
&& typeof record.selector === 'string'
|
||||||
|
&& typeof record.path === 'string'
|
||||||
|
&& Boolean(bounds)
|
||||||
|
&& typeof bounds?.x === 'number'
|
||||||
|
&& typeof bounds?.y === 'number'
|
||||||
|
&& typeof bounds?.width === 'number'
|
||||||
|
&& typeof bounds?.height === 'number'
|
||||||
|
&& isXYRecord(record.center)
|
||||||
|
&& isStringRecord(record.attributes)
|
||||||
|
&& isStringRecord(record.computedStyle)
|
||||||
|
&& Array.isArray(record.ancestry);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const formatPreviewAnnotationMarkdown = ({
|
||||||
|
pageUrl,
|
||||||
|
viewport,
|
||||||
|
devicePixelRatio,
|
||||||
|
target,
|
||||||
|
screenshotAttached,
|
||||||
|
intro,
|
||||||
|
}: {
|
||||||
|
pageUrl: string;
|
||||||
|
viewport: { width: number; height: number };
|
||||||
|
devicePixelRatio: number;
|
||||||
|
target: PreviewElementMetadata;
|
||||||
|
screenshotAttached: boolean;
|
||||||
|
intro: string;
|
||||||
|
}): string => {
|
||||||
|
const text = target.text.trim();
|
||||||
|
const attributes = Object.entries(target.attributes)
|
||||||
|
.map(([key, value]) => `${key}="${value}"`)
|
||||||
|
.join(' ');
|
||||||
|
const styles = target.computedStyle;
|
||||||
|
const bounds = target.bounds;
|
||||||
|
const center = target.center;
|
||||||
|
const introLabel = intro.replace(/[.:]+$/g, '');
|
||||||
|
const ancestry = target.ancestry
|
||||||
|
.map((entry) => entry.selectorPart)
|
||||||
|
.join(' > ');
|
||||||
|
|
||||||
|
return [
|
||||||
|
`${introLabel}:`,
|
||||||
|
`Page: ${pageUrl || 'preview'}`,
|
||||||
|
`Viewport: ${viewport.width}x${viewport.height}, DPR ${devicePixelRatio}`,
|
||||||
|
`Screenshot: ${screenshotAttached ? 'attached' : 'not attached'}`,
|
||||||
|
`Element: ${target.tag}`,
|
||||||
|
text ? `Text: ${text}` : null,
|
||||||
|
`- Selector: ${target.selector}`,
|
||||||
|
`- Path: ${target.path}`,
|
||||||
|
ancestry ? `- Ancestry: ${ancestry}` : null,
|
||||||
|
attributes ? `- Attributes: ${attributes}` : null,
|
||||||
|
`- Bounds: x=${Math.round(bounds.x)}, y=${Math.round(bounds.y)}, width=${Math.round(bounds.width)}, height=${Math.round(bounds.height)}`,
|
||||||
|
`- Center: x=${Math.round(center.x)}, y=${Math.round(center.y)}`,
|
||||||
|
`Styles: display=${styles.display}; position=${styles.position}; font=${styles.fontWeight} ${styles.fontSize} / ${styles.lineHeight} ${styles.fontFamily}; color=${styles.color}; background=${styles.backgroundColor}; z-index=${styles.zIndex}`,
|
||||||
|
].filter((line): line is string => typeof line === 'string').join('\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
export const renderPreviewScreenshot = async (
|
||||||
|
iframe: HTMLIFrameElement,
|
||||||
|
target: PreviewElementMetadata,
|
||||||
|
): Promise<File | null> => {
|
||||||
|
const tauri = typeof window !== 'undefined'
|
||||||
|
? (window as unknown as { __TAURI__?: { core?: { invoke?: <T>(cmd: string, args?: Record<string, unknown>) => Promise<T> } } }).__TAURI__
|
||||||
|
: undefined;
|
||||||
|
if (typeof tauri?.core?.invoke === 'function') {
|
||||||
|
try {
|
||||||
|
const rect = iframe.getBoundingClientRect();
|
||||||
|
const capture = await tauri.core.invoke<{ mime: string; base64: string; width: number; height: number }>('desktop_capture_page_rect', {
|
||||||
|
x: rect.left,
|
||||||
|
y: rect.top,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
});
|
||||||
|
const image = new Image();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
image.onload = () => resolve();
|
||||||
|
image.onerror = () => reject(new Error('Failed to load desktop preview screenshot'));
|
||||||
|
image.src = `data:${capture.mime};base64,${capture.base64}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const width = Math.max(1, image.naturalWidth || capture.width || Math.floor(rect.width));
|
||||||
|
const height = Math.max(1, image.naturalHeight || capture.height || Math.floor(rect.height));
|
||||||
|
const maxOutputWidth = 1200;
|
||||||
|
const outputScale = Math.min(1, maxOutputWidth / width);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.floor(width * outputScale);
|
||||||
|
canvas.height = Math.floor(height * outputScale);
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) return null;
|
||||||
|
|
||||||
|
context.scale(outputScale, outputScale);
|
||||||
|
context.drawImage(image, 0, 0, width, height);
|
||||||
|
const xScale = width / Math.max(1, rect.width);
|
||||||
|
const yScale = height / Math.max(1, rect.height);
|
||||||
|
context.fillStyle = 'rgba(37, 99, 235, 0.28)';
|
||||||
|
context.strokeStyle = 'rgb(37, 99, 235)';
|
||||||
|
context.lineWidth = Math.max(2, 2 * xScale);
|
||||||
|
context.fillRect(target.bounds.x * xScale, target.bounds.y * yScale, target.bounds.width * xScale, target.bounds.height * yScale);
|
||||||
|
context.strokeRect(target.bounds.x * xScale, target.bounds.y * yScale, target.bounds.width * xScale, target.bounds.height * yScale);
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.82));
|
||||||
|
if (!blob) return null;
|
||||||
|
return new File([blob], `preview-annotation-${Date.now()}.jpg`, { type: 'image/jpeg' });
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[preview] failed to capture annotation screenshot:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return await captureIframeDomScreenshot(iframe, target);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const desktopAnnotationToFile = async (
|
||||||
|
base64: string,
|
||||||
|
screenshotWidth: number,
|
||||||
|
screenshotHeight: number,
|
||||||
|
cssWidth: number,
|
||||||
|
cssHeight: number,
|
||||||
|
target: PreviewElementMetadata,
|
||||||
|
): Promise<File | null> => {
|
||||||
|
if (!base64) return null;
|
||||||
|
try {
|
||||||
|
const image = new Image();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
image.onload = () => resolve();
|
||||||
|
image.onerror = () => reject(new Error('Failed to load desktop browser screenshot'));
|
||||||
|
image.src = `data:image/jpeg;base64,${base64}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const width = Math.max(1, image.naturalWidth || screenshotWidth);
|
||||||
|
const height = Math.max(1, image.naturalHeight || screenshotHeight);
|
||||||
|
const maxOutputWidth = 1200;
|
||||||
|
const outputScale = Math.min(1, maxOutputWidth / width);
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = Math.floor(width * outputScale);
|
||||||
|
canvas.height = Math.floor(height * outputScale);
|
||||||
|
const context = canvas.getContext('2d');
|
||||||
|
if (!context) return null;
|
||||||
|
|
||||||
|
context.scale(outputScale, outputScale);
|
||||||
|
context.drawImage(image, 0, 0, width, height);
|
||||||
|
const xScale = width / Math.max(1, cssWidth || width);
|
||||||
|
const yScale = height / Math.max(1, cssHeight || height);
|
||||||
|
context.fillStyle = 'rgba(37, 99, 235, 0.14)';
|
||||||
|
context.strokeStyle = 'rgb(37, 99, 235)';
|
||||||
|
context.lineWidth = Math.max(2, 2 * xScale);
|
||||||
|
context.fillRect(target.bounds.x * xScale, target.bounds.y * yScale, target.bounds.width * xScale, target.bounds.height * yScale);
|
||||||
|
context.strokeRect(target.bounds.x * xScale, target.bounds.y * yScale, target.bounds.width * xScale, target.bounds.height * yScale);
|
||||||
|
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.82));
|
||||||
|
if (!blob) return null;
|
||||||
|
return new File([blob], `browser-annotation-${Date.now()}.jpg`, { type: 'image/jpeg' });
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const TRANSPARENT_IMAGE_PLACEHOLDER = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=';
|
||||||
|
|
||||||
|
// Module-scoped, in-memory cache of registered proxy targets keyed by the
|
||||||
|
// fully-qualified upstream URL. Survives PreviewPane unmount/remount and tab
|
||||||
|
// switches, but intentionally does NOT survive a full page reload: the server
|
||||||
|
// holds the target map in memory and the auth cookie is HttpOnly + scoped to
|
||||||
|
// the proxy id, so a stale persisted entry would 404 after a server restart.
|
||||||
|
// Entries are evicted on registration error (refetched) or when the upstream
|
||||||
|
// returns 403 (cookie expired) / 404 (target unknown) at iframe load time.
|
||||||
|
export type CachedProxyTarget = { proxyBasePath: string; expiresAt: number };
|
||||||
|
export const previewProxyTargetCache = new Map<string, CachedProxyTarget>();
|
||||||
|
const previewProxyTargetRequests = new Map<string, Promise<CachedProxyTarget | null>>();
|
||||||
|
const PREVIEW_PROXY_CACHE_SAFETY_MS = 30_000;
|
||||||
|
|
||||||
|
export const getCachedProxyTarget = (url: string): CachedProxyTarget | null => {
|
||||||
|
const entry = previewProxyTargetCache.get(url);
|
||||||
|
if (!entry) return null;
|
||||||
|
if (entry.expiresAt - Date.now() <= PREVIEW_PROXY_CACHE_SAFETY_MS) {
|
||||||
|
previewProxyTargetCache.delete(url);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return entry;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getBrowserProxyTargetKey = (url: string): string => {
|
||||||
|
try {
|
||||||
|
return new URL(url).origin;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function getCaptureBackgroundColor(document: Document): string {
|
||||||
|
const fallback = '#ffffff';
|
||||||
|
const view = document.defaultView ?? window;
|
||||||
|
try {
|
||||||
|
const bodyColor = document.body ? view.getComputedStyle(document.body).backgroundColor : '';
|
||||||
|
if (bodyColor && bodyColor !== 'rgba(0, 0, 0, 0)' && bodyColor !== 'transparent') return bodyColor;
|
||||||
|
|
||||||
|
const rootColor = view.getComputedStyle(document.documentElement).backgroundColor;
|
||||||
|
if (rootColor && rootColor !== 'rgba(0, 0, 0, 0)' && rootColor !== 'transparent') return rootColor;
|
||||||
|
} catch {
|
||||||
|
// Ignore style access failures and use a stable background.
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blobToDataUrl = (blob: Blob): Promise<string> => new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => resolve(typeof reader.result === 'string' ? reader.result : TRANSPARENT_IMAGE_PLACEHOLDER);
|
||||||
|
reader.onerror = () => reject(reader.error ?? new Error('Failed to read image blob'));
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
|
||||||
|
const canvasToJpegBase64 = async (canvas: HTMLCanvasElement, quality = 0.82): Promise<string> => {
|
||||||
|
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', quality));
|
||||||
|
if (!blob) return '';
|
||||||
|
return (await blobToDataUrl(blob)).split(',', 2)[1] || '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const isPreviewCaptureDebugEnabled = (): boolean => {
|
||||||
|
try {
|
||||||
|
return Boolean((window as unknown as { __previewCaptureDebug?: boolean }).__previewCaptureDebug);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const previewCaptureDebug = (...args: unknown[]): void => {
|
||||||
|
if (!isPreviewCaptureDebugEnabled()) return;
|
||||||
|
console.info('[preview-capture]', ...args);
|
||||||
|
};
|
||||||
|
|
||||||
|
type ScrolledElementInfo = {
|
||||||
|
selector: string;
|
||||||
|
scrollTop: number;
|
||||||
|
scrollLeft: number;
|
||||||
|
clientWidth: number;
|
||||||
|
clientHeight: number;
|
||||||
|
scrollWidth: number;
|
||||||
|
scrollHeight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const describeScrolledElements = (doc: Document, limit = 8): ScrolledElementInfo[] => {
|
||||||
|
const found: ScrolledElementInfo[] = [];
|
||||||
|
try {
|
||||||
|
const all = doc.querySelectorAll<HTMLElement>('*');
|
||||||
|
for (const el of all) {
|
||||||
|
const scrollTop = el.scrollTop || 0;
|
||||||
|
const scrollLeft = el.scrollLeft || 0;
|
||||||
|
if (scrollTop <= 0 && scrollLeft <= 0) continue;
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
const id = el.id ? `#${el.id}` : '';
|
||||||
|
const cls = typeof el.className === 'string' && el.className
|
||||||
|
? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}`
|
||||||
|
: '';
|
||||||
|
found.push({
|
||||||
|
selector: `${tag}${id}${cls}`,
|
||||||
|
scrollTop,
|
||||||
|
scrollLeft,
|
||||||
|
clientWidth: el.clientWidth,
|
||||||
|
clientHeight: el.clientHeight,
|
||||||
|
scrollWidth: el.scrollWidth,
|
||||||
|
scrollHeight: el.scrollHeight,
|
||||||
|
});
|
||||||
|
if (found.length >= limit) break;
|
||||||
|
}
|
||||||
|
} catch { /* best-effort diagnostics */ }
|
||||||
|
return found;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FIXED_PIN_ATTR = 'data-oc-fixed-pin';
|
||||||
|
|
||||||
|
// snapDOM repositions `position: sticky` (freezeSticky) but leaves `position: fixed`
|
||||||
|
// alone. In the full-document SVG foreignObject a fixed element resolves against the
|
||||||
|
// document box, not the viewport — so `top`/`bottom` anchors and sizes are wrong
|
||||||
|
// (e.g. a `top:nav; bottom:0` sidebar stretches to the full doc height) and cropping
|
||||||
|
// shifts/clips it. We can't fix this by mutating the LIVE element: changing its
|
||||||
|
// position/height resets the scrollTop of any overflow container (the sidebar jumps
|
||||||
|
// to the top during capture). Instead we only *tag* fixed elements here — a plain
|
||||||
|
// attribute write that never resets scroll — recording their measured viewport rect
|
||||||
|
// in document coordinates. The actual repositioning happens on snapDOM's CLONE via
|
||||||
|
// the afterClone plugin below, leaving the live DOM (and its scroll) untouched.
|
||||||
|
const tagFixedElementsForClonePinning = (doc: Document, scrollX: number, scrollY: number): (() => void) => {
|
||||||
|
if (scrollX <= 0 && scrollY <= 0) return () => { /* nothing scrolled */ };
|
||||||
|
const view = doc.defaultView;
|
||||||
|
if (!view) return () => { /* no view */ };
|
||||||
|
const tagged: HTMLElement[] = [];
|
||||||
|
const debugInfo: Array<Record<string, number | string>> = [];
|
||||||
|
try {
|
||||||
|
for (const el of doc.querySelectorAll<HTMLElement>('*')) {
|
||||||
|
if (view.getComputedStyle(el).position !== 'fixed') continue;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
if (!(rect.width > 0 && rect.height > 0)) continue;
|
||||||
|
el.setAttribute(FIXED_PIN_ATTR, JSON.stringify({
|
||||||
|
top: rect.top + scrollY,
|
||||||
|
left: rect.left + scrollX,
|
||||||
|
width: rect.width,
|
||||||
|
height: rect.height,
|
||||||
|
}));
|
||||||
|
tagged.push(el);
|
||||||
|
const tag = el.tagName.toLowerCase();
|
||||||
|
const cls = typeof el.className === 'string' && el.className
|
||||||
|
? `.${el.className.trim().split(/\s+/).slice(0, 2).join('.')}`
|
||||||
|
: '';
|
||||||
|
debugInfo.push({ selector: `${tag}${cls}`, top: Math.round(rect.top), left: Math.round(rect.left), width: Math.round(rect.width), height: Math.round(rect.height) });
|
||||||
|
}
|
||||||
|
} catch { /* best-effort: leave fixed elements untagged */ }
|
||||||
|
previewCaptureDebug('tagged fixed elements', debugInfo);
|
||||||
|
return () => {
|
||||||
|
for (const el of tagged) {
|
||||||
|
try { el.removeAttribute(FIXED_PIN_ATTR); } catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Runs inside snapDOM after the clone is built (and after prepareClone has baked
|
||||||
|
// nested scroll via translate). We re-anchor tagged fixed elements on the CLONE to
|
||||||
|
// their measured viewport rect in document coordinates, so cropping at the scroll
|
||||||
|
// offset lands them in the right place at the right size — without ever touching the
|
||||||
|
// live DOM. snapDOM's own scroll-translate wrapper on the clone is preserved, so the
|
||||||
|
// sidebar's internal scroll position stays baked in.
|
||||||
|
const snapdomFixedPinPlugin = {
|
||||||
|
name: 'oc-fixed-pin',
|
||||||
|
afterClone(context: { clone?: Element | null }): void {
|
||||||
|
const clone = context?.clone;
|
||||||
|
if (!clone || typeof (clone as Element).querySelectorAll !== 'function') return;
|
||||||
|
for (const el of (clone as Element).querySelectorAll<HTMLElement>(`[${FIXED_PIN_ATTR}]`)) {
|
||||||
|
let spec: { top: number; left: number; width: number; height: number };
|
||||||
|
try { spec = JSON.parse(el.getAttribute(FIXED_PIN_ATTR) || ''); } catch { continue; }
|
||||||
|
el.style.setProperty('position', 'absolute', 'important');
|
||||||
|
el.style.setProperty('top', `${spec.top}px`, 'important');
|
||||||
|
el.style.setProperty('left', `${spec.left}px`, 'important');
|
||||||
|
el.style.setProperty('right', 'auto', 'important');
|
||||||
|
el.style.setProperty('bottom', 'auto', 'important');
|
||||||
|
el.style.setProperty('width', `${spec.width}px`, 'important');
|
||||||
|
el.style.setProperty('height', `${spec.height}px`, 'important');
|
||||||
|
el.removeAttribute(FIXED_PIN_ATTR);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const NESTED_SCROLL_ATTR = 'data-oc-scroll-pin';
|
||||||
|
|
||||||
|
// Preparing the capture (asset inlining, layout reflows) resets the scrollTop of
|
||||||
|
// overflow containers like the fixed Starlight `.sidebar-pane`. snapDOM bakes nested
|
||||||
|
// scroll into the clone using the LIVE scrollTop *at clone time* — which by then has
|
||||||
|
// been reset to 0, so the sidebar renders from the top. We can't reliably keep the
|
||||||
|
// live scroll pinned through async asset inlining, so instead we snapshot each
|
||||||
|
// container's scroll up front (reliable values) and tag it with a data attribute.
|
||||||
|
// snapdomNestedScrollPlugin.afterClone then re-bakes the scroll on the CLONE from
|
||||||
|
// these snapshot values, independent of whatever the live scrollTop was. We skip the
|
||||||
|
// root/body — document scroll is handled by the viewport crop, not by baking.
|
||||||
|
const captureNestedScrollState = (doc: Document): { reapply: () => void; cleanup: () => void; snapshot: ScrolledElementInfo[] } => {
|
||||||
|
const entries: Array<{ el: HTMLElement; top: number; left: number; tagged: boolean }> = [];
|
||||||
|
const snapshot = describeScrolledElements(doc, 64);
|
||||||
|
try {
|
||||||
|
const root = doc.documentElement;
|
||||||
|
const body = doc.body;
|
||||||
|
for (const el of doc.querySelectorAll<HTMLElement>('*')) {
|
||||||
|
const top = el.scrollTop || 0;
|
||||||
|
const left = el.scrollLeft || 0;
|
||||||
|
if (top <= 0 && left <= 0) continue;
|
||||||
|
const tagged = el !== root && el !== body;
|
||||||
|
if (tagged) el.setAttribute(NESTED_SCROLL_ATTR, JSON.stringify({ top, left }));
|
||||||
|
entries.push({ el, top, left, tagged });
|
||||||
|
}
|
||||||
|
} catch { /* best-effort: no nested scroll preservation */ }
|
||||||
|
const reapply = () => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
try {
|
||||||
|
void entry.el.scrollHeight;
|
||||||
|
if (entry.el.scrollTop !== entry.top) entry.el.scrollTop = entry.top;
|
||||||
|
if (entry.el.scrollLeft !== entry.left) entry.el.scrollLeft = entry.left;
|
||||||
|
} catch { /* best-effort restore */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const cleanup = () => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.tagged) continue;
|
||||||
|
try { entry.el.removeAttribute(NESTED_SCROLL_ATTR); } catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return { reapply, cleanup, snapshot };
|
||||||
|
};
|
||||||
|
|
||||||
|
// Re-bake nested scroll on the clone from the reliable snapshot values (see above).
|
||||||
|
// snapDOM wraps a scrolled element's children in a single inner div with
|
||||||
|
// `transform: translate(...)` + `will-change: transform`. We override that transform
|
||||||
|
// when present, or create the wrapper ourselves if snapDOM saw scrollTop 0 at clone
|
||||||
|
// time. Runs after the fixed-pin pass so sidebar gets both correct box and scroll.
|
||||||
|
const snapdomNestedScrollPlugin = {
|
||||||
|
name: 'oc-nested-scroll',
|
||||||
|
afterClone(context: { clone?: Element | null }): void {
|
||||||
|
const clone = context?.clone;
|
||||||
|
if (!clone || typeof (clone as Element).querySelectorAll !== 'function') return;
|
||||||
|
const ownerDoc = (clone as Element).ownerDocument;
|
||||||
|
if (!ownerDoc) return;
|
||||||
|
for (const el of (clone as Element).querySelectorAll<HTMLElement>(`[${NESTED_SCROLL_ATTR}]`)) {
|
||||||
|
let spec: { top: number; left: number };
|
||||||
|
try { spec = JSON.parse(el.getAttribute(NESTED_SCROLL_ATTR) || ''); } catch { el.removeAttribute(NESTED_SCROLL_ATTR); continue; }
|
||||||
|
const transform = `translate(${-spec.left}px, ${-spec.top}px)`;
|
||||||
|
const existingWrapper = el.children.length === 1 && el.firstElementChild instanceof HTMLElement && el.firstElementChild.style.willChange === 'transform'
|
||||||
|
? el.firstElementChild
|
||||||
|
: null;
|
||||||
|
if (existingWrapper) {
|
||||||
|
existingWrapper.style.transform = transform;
|
||||||
|
} else {
|
||||||
|
el.style.overflow = 'hidden';
|
||||||
|
const inner = ownerDoc.createElement('div');
|
||||||
|
inner.style.transform = transform;
|
||||||
|
inner.style.willChange = 'transform';
|
||||||
|
inner.style.display = 'inline-block';
|
||||||
|
inner.style.width = '100%';
|
||||||
|
while (el.firstChild) inner.appendChild(el.firstChild);
|
||||||
|
el.appendChild(inner);
|
||||||
|
}
|
||||||
|
el.removeAttribute(NESTED_SCROLL_ATTR);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchUrlAsDataUrl = async (url: string, credentials: RequestCredentials): Promise<string | null> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { credentials });
|
||||||
|
if (!response.ok) return null;
|
||||||
|
return await blobToDataUrl(await response.blob());
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getExternalResourceProxyUrl = async (url: URL): Promise<string> => {
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return '';
|
||||||
|
|
||||||
|
const targetKey = url.origin;
|
||||||
|
const cached = getCachedProxyTarget(targetKey);
|
||||||
|
if (cached) {
|
||||||
|
return `${cached.proxyBasePath}${url.pathname}${url.search}${url.hash}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingRequest = previewProxyTargetRequests.get(targetKey);
|
||||||
|
const request = existingRequest ?? (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/preview/targets', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({ url: url.toString(), allowExternal: true }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
previewProxyTargetCache.delete(targetKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = await response.json() as { proxyBasePath?: unknown; expiresAt?: unknown };
|
||||||
|
const proxyBasePath = typeof body.proxyBasePath === 'string' ? body.proxyBasePath : '';
|
||||||
|
const expiresAt = typeof body.expiresAt === 'number' ? body.expiresAt : 0;
|
||||||
|
if (!proxyBasePath) {
|
||||||
|
previewProxyTargetCache.delete(targetKey);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = { proxyBasePath, expiresAt };
|
||||||
|
previewProxyTargetCache.set(targetKey, target);
|
||||||
|
return target;
|
||||||
|
} catch {
|
||||||
|
previewProxyTargetCache.delete(targetKey);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
previewProxyTargetRequests.delete(targetKey);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
if (!existingRequest) {
|
||||||
|
previewProxyTargetRequests.set(targetKey, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
const target = await request;
|
||||||
|
return target ? `${target.proxyBasePath}${url.pathname}${url.search}${url.hash}` : '';
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchFrameResourceAsDataUrl = async (rawUrl: string, document: Document): Promise<string> => {
|
||||||
|
if (!rawUrl || rawUrl.startsWith('data:')) return rawUrl;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(rawUrl, document.baseURI);
|
||||||
|
if (url.origin === window.location.origin || (url.protocol !== 'http:' && url.protocol !== 'https:')) {
|
||||||
|
return await fetchUrlAsDataUrl(url.toString(), 'include') ?? TRANSPARENT_IMAGE_PLACEHOLDER;
|
||||||
|
}
|
||||||
|
|
||||||
|
const proxyUrl = await getExternalResourceProxyUrl(url);
|
||||||
|
const proxied = proxyUrl ? await fetchUrlAsDataUrl(proxyUrl, 'include') : null;
|
||||||
|
if (proxied) return proxied;
|
||||||
|
|
||||||
|
return await fetchUrlAsDataUrl(url.toString(), 'omit') ?? TRANSPARENT_IMAGE_PLACEHOLDER;
|
||||||
|
} catch {
|
||||||
|
return TRANSPARENT_IMAGE_PLACEHOLDER;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inlineCssImageUrls = async (value: string, document: Document): Promise<string> => {
|
||||||
|
if (!value || value === 'none' || !value.includes('url(')) return value;
|
||||||
|
|
||||||
|
const matches = Array.from(value.matchAll(/url\((['"]?)(.*?)\1\)/g));
|
||||||
|
let nextValue = value;
|
||||||
|
for (const match of matches) {
|
||||||
|
const rawUrl = match[2] || '';
|
||||||
|
if (!rawUrl || rawUrl.startsWith('data:')) continue;
|
||||||
|
const dataUrl = await fetchFrameResourceAsDataUrl(rawUrl, document);
|
||||||
|
nextValue = nextValue.replace(match[0], `url("${dataUrl}")`);
|
||||||
|
}
|
||||||
|
return nextValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitForImage = (image: HTMLImageElement): Promise<void> => {
|
||||||
|
if (image.complete) return Promise.resolve();
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
image.addEventListener('load', () => resolve(), { once: true });
|
||||||
|
image.addEventListener('error', () => resolve(), { once: true });
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const getElementStyleRestore = (element: HTMLElement): (() => void) => {
|
||||||
|
const cssText = element.style.cssText;
|
||||||
|
return () => { element.style.cssText = cssText; };
|
||||||
|
};
|
||||||
|
|
||||||
|
const getLineHeight = (style: CSSStyleDeclaration): number => {
|
||||||
|
const lineHeight = Number.parseFloat(style.lineHeight);
|
||||||
|
if (Number.isFinite(lineHeight) && lineHeight > 0) return lineHeight;
|
||||||
|
const fontSize = Number.parseFloat(style.fontSize);
|
||||||
|
return Number.isFinite(fontSize) && fontSize > 0 ? fontSize * 1.2 : 16;
|
||||||
|
};
|
||||||
|
|
||||||
|
const preserveSingleLineTextElements = (
|
||||||
|
document: Document,
|
||||||
|
viewportWidth: number,
|
||||||
|
viewportHeight: number,
|
||||||
|
): (() => void) => {
|
||||||
|
const restoreCallbacks: Array<() => void> = [];
|
||||||
|
const view = document.defaultView ?? window;
|
||||||
|
const controlsSelector = 'button, a, summary, label, [role="button"], [role="link"], [role="menuitem"], [role="tab"], nav *, header *';
|
||||||
|
const elements = Array.from(document.querySelectorAll<HTMLElement>(controlsSelector));
|
||||||
|
|
||||||
|
for (const element of elements) {
|
||||||
|
const text = element.textContent?.replace(/\s+/g, ' ').trim() ?? '';
|
||||||
|
if (!text || !text.includes(' ')) continue;
|
||||||
|
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
if (rect.width <= 0 || rect.height <= 0) continue;
|
||||||
|
if (rect.right < 0 || rect.bottom < 0 || rect.left > viewportWidth || rect.top > viewportHeight) continue;
|
||||||
|
|
||||||
|
const style = view.getComputedStyle(element);
|
||||||
|
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) continue;
|
||||||
|
|
||||||
|
const textWrap = style.getPropertyValue('text-wrap');
|
||||||
|
const textWrapMode = style.getPropertyValue('text-wrap-mode');
|
||||||
|
const alreadyNoWrap = style.whiteSpace.includes('nowrap') || textWrap === 'nowrap' || textWrapMode === 'nowrap';
|
||||||
|
const isSingleLine = rect.height <= getLineHeight(style) * 1.7;
|
||||||
|
if (!alreadyNoWrap && (!isSingleLine || rect.width > viewportWidth * 0.72)) continue;
|
||||||
|
|
||||||
|
restoreCallbacks.push(getElementStyleRestore(element));
|
||||||
|
element.style.whiteSpace = 'nowrap';
|
||||||
|
element.style.overflowWrap = 'normal';
|
||||||
|
element.style.wordBreak = 'normal';
|
||||||
|
element.style.setProperty('text-wrap', 'nowrap');
|
||||||
|
element.style.setProperty('text-wrap-mode', 'nowrap');
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (let index = restoreCallbacks.length - 1; index >= 0; index -= 1) {
|
||||||
|
restoreCallbacks[index]?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const freezeViewportPositionedElements = (
|
||||||
|
document: Document,
|
||||||
|
viewportWidth: number,
|
||||||
|
viewportHeight: number,
|
||||||
|
frozenElements?: WeakSet<HTMLElement>,
|
||||||
|
): (() => void) => {
|
||||||
|
const restoreCallbacks: Array<() => void> = [];
|
||||||
|
const view = document.defaultView ?? window;
|
||||||
|
const scrollX = view.scrollX || document.documentElement.scrollLeft || document.body?.scrollLeft || 0;
|
||||||
|
const scrollY = view.scrollY || document.documentElement.scrollTop || document.body?.scrollTop || 0;
|
||||||
|
const candidates = Array.from(document.querySelectorAll<HTMLElement>('*'))
|
||||||
|
.filter((element) => {
|
||||||
|
const style = view.getComputedStyle(element);
|
||||||
|
if (style.position !== 'fixed') return false;
|
||||||
|
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return rect.width > 0
|
||||||
|
&& rect.height > 0
|
||||||
|
&& rect.right >= 0
|
||||||
|
&& rect.bottom >= 0
|
||||||
|
&& rect.left <= viewportWidth
|
||||||
|
&& rect.top <= viewportHeight;
|
||||||
|
})
|
||||||
|
.filter((element, index, elements) => {
|
||||||
|
return !elements.some((candidate, candidateIndex) => candidateIndex < index && candidate.contains(element));
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const element of candidates) {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
const computed = view.getComputedStyle(element);
|
||||||
|
const borderBoxWidth = Math.ceil(rect.width) + 8;
|
||||||
|
const borderBoxHeight = Math.ceil(rect.height) + 2;
|
||||||
|
frozenElements?.add(element);
|
||||||
|
restoreCallbacks.push(getElementStyleRestore(element));
|
||||||
|
element.style.position = 'absolute';
|
||||||
|
element.style.top = `${rect.top + scrollY}px`;
|
||||||
|
element.style.left = `${rect.left + scrollX}px`;
|
||||||
|
element.style.right = 'auto';
|
||||||
|
element.style.bottom = 'auto';
|
||||||
|
element.style.width = `${borderBoxWidth}px`;
|
||||||
|
element.style.minWidth = `${borderBoxWidth}px`;
|
||||||
|
element.style.height = `${borderBoxHeight}px`;
|
||||||
|
element.style.minHeight = `${borderBoxHeight}px`;
|
||||||
|
element.style.margin = '0';
|
||||||
|
element.style.boxSizing = 'border-box';
|
||||||
|
element.style.transform = 'none';
|
||||||
|
if (computed.zIndex !== 'auto') element.style.zIndex = computed.zIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (let index = restoreCallbacks.length - 1; index >= 0; index -= 1) {
|
||||||
|
restoreCallbacks[index]?.();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const inlineIframeCaptureAssets = async (
|
||||||
|
document: Document,
|
||||||
|
viewportWidth: number,
|
||||||
|
viewportHeight: number,
|
||||||
|
options: { applyLayoutWorkarounds?: boolean } = {},
|
||||||
|
): Promise<() => void> => {
|
||||||
|
const restoreCallbacks: Array<() => void> = [];
|
||||||
|
const view = document.defaultView ?? window;
|
||||||
|
const isVisibleInViewport = (element: Element): boolean => {
|
||||||
|
if (element === document.documentElement || element === document.body) return true;
|
||||||
|
try {
|
||||||
|
const rect = element.getBoundingClientRect();
|
||||||
|
return rect.width > 0
|
||||||
|
&& rect.height > 0
|
||||||
|
&& rect.right >= 0
|
||||||
|
&& rect.bottom >= 0
|
||||||
|
&& rect.left <= viewportWidth
|
||||||
|
&& rect.top <= viewportHeight;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.applyLayoutWorkarounds) {
|
||||||
|
const frozenElements = new WeakSet<HTMLElement>();
|
||||||
|
restoreCallbacks.push(freezeViewportPositionedElements(document, viewportWidth, viewportHeight, frozenElements));
|
||||||
|
restoreCallbacks.push(preserveSingleLineTextElements(document, viewportWidth, viewportHeight));
|
||||||
|
}
|
||||||
|
|
||||||
|
const imageSourceUrls = new Map<HTMLImageElement, string>();
|
||||||
|
for (const image of Array.from(document.images)) {
|
||||||
|
imageSourceUrls.set(image, image.currentSrc || image.src || image.getAttribute('src') || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
const pictures = Array.from(document.querySelectorAll('picture'));
|
||||||
|
for (const picture of pictures) {
|
||||||
|
const sources = Array.from(picture.querySelectorAll('source'));
|
||||||
|
if (sources.length === 0) continue;
|
||||||
|
const previous = sources.map((source) => ({ source, srcset: source.getAttribute('srcset'), sizes: source.getAttribute('sizes') }));
|
||||||
|
restoreCallbacks.push(() => {
|
||||||
|
for (const item of previous) {
|
||||||
|
if (item.srcset === null) item.source.removeAttribute('srcset');
|
||||||
|
else item.source.setAttribute('srcset', item.srcset);
|
||||||
|
if (item.sizes === null) item.source.removeAttribute('sizes');
|
||||||
|
else item.source.setAttribute('sizes', item.sizes);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
for (const source of sources) {
|
||||||
|
source.removeAttribute('srcset');
|
||||||
|
source.removeAttribute('sizes');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const images = Array.from(document.images).filter((image) => isVisibleInViewport(image));
|
||||||
|
await Promise.all(images.map(async (image) => {
|
||||||
|
const sourceUrl = imageSourceUrls.get(image) || image.currentSrc || image.src || image.getAttribute('src') || '';
|
||||||
|
if (!sourceUrl) return;
|
||||||
|
await waitForImage(image);
|
||||||
|
const dataUrl = await fetchFrameResourceAsDataUrl(sourceUrl, document);
|
||||||
|
const previous = {
|
||||||
|
src: image.getAttribute('src'),
|
||||||
|
srcset: image.getAttribute('srcset'),
|
||||||
|
sizes: image.getAttribute('sizes'),
|
||||||
|
};
|
||||||
|
restoreCallbacks.push(() => {
|
||||||
|
if (previous.src === null) image.removeAttribute('src');
|
||||||
|
else image.setAttribute('src', previous.src);
|
||||||
|
if (previous.srcset === null) image.removeAttribute('srcset');
|
||||||
|
else image.setAttribute('srcset', previous.srcset);
|
||||||
|
if (previous.sizes === null) image.removeAttribute('sizes');
|
||||||
|
else image.setAttribute('sizes', previous.sizes);
|
||||||
|
});
|
||||||
|
image.removeAttribute('srcset');
|
||||||
|
image.removeAttribute('sizes');
|
||||||
|
image.setAttribute('src', dataUrl || TRANSPARENT_IMAGE_PLACEHOLDER);
|
||||||
|
await waitForImage(image);
|
||||||
|
}));
|
||||||
|
|
||||||
|
const elements = Array.from(document.querySelectorAll<HTMLElement>('*')).filter(isVisibleInViewport);
|
||||||
|
await Promise.all(elements.map(async (element) => {
|
||||||
|
const backgroundImage = view.getComputedStyle(element).backgroundImage;
|
||||||
|
if (!backgroundImage || backgroundImage === 'none' || !backgroundImage.includes('url(')) return;
|
||||||
|
const nextBackgroundImage = await inlineCssImageUrls(backgroundImage, document);
|
||||||
|
if (nextBackgroundImage === backgroundImage) return;
|
||||||
|
const previous = element.style.backgroundImage;
|
||||||
|
restoreCallbacks.push(() => { element.style.backgroundImage = previous; });
|
||||||
|
element.style.backgroundImage = nextBackgroundImage;
|
||||||
|
}));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
for (let index = restoreCallbacks.length - 1; index >= 0; index -= 1) {
|
||||||
|
try { restoreCallbacks[index]?.(); } catch { /* best-effort restore */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
async function captureIframeSnapdomScreenshot(
|
||||||
|
iframe: HTMLIFrameElement,
|
||||||
|
target: PreviewElementMetadata,
|
||||||
|
): Promise<File | null> {
|
||||||
|
try {
|
||||||
|
const frameWindow = iframe.contentWindow;
|
||||||
|
const document = iframe.contentDocument ?? frameWindow?.document;
|
||||||
|
const root = document?.documentElement;
|
||||||
|
if (!frameWindow || !document || !root) return null;
|
||||||
|
|
||||||
|
const iframeRect = iframe.getBoundingClientRect();
|
||||||
|
const viewportWidth = Math.max(1, Math.ceil(frameWindow.innerWidth || iframe.clientWidth || iframeRect.width));
|
||||||
|
const viewportHeight = Math.max(1, Math.ceil(frameWindow.innerHeight || iframe.clientHeight || iframeRect.height));
|
||||||
|
const body = document.body;
|
||||||
|
const scrollingElement = document.scrollingElement instanceof HTMLElement ? document.scrollingElement : null;
|
||||||
|
const windowScrollX = frameWindow.scrollX || 0;
|
||||||
|
const windowScrollY = frameWindow.scrollY || 0;
|
||||||
|
const pageScrollX = frameWindow.pageXOffset || 0;
|
||||||
|
const pageScrollY = frameWindow.pageYOffset || 0;
|
||||||
|
const visualViewportScrollX = frameWindow.visualViewport?.pageLeft || frameWindow.visualViewport?.offsetLeft || 0;
|
||||||
|
const visualViewportScrollY = frameWindow.visualViewport?.pageTop || frameWindow.visualViewport?.offsetTop || 0;
|
||||||
|
const rootScrollX = root.scrollLeft || 0;
|
||||||
|
const rootScrollY = root.scrollTop || 0;
|
||||||
|
const bodyScrollX = body?.scrollLeft || 0;
|
||||||
|
const bodyScrollY = body?.scrollTop || 0;
|
||||||
|
const scrollingElementScrollX = scrollingElement?.scrollLeft || 0;
|
||||||
|
const scrollingElementScrollY = scrollingElement?.scrollTop || 0;
|
||||||
|
const scrollX = Math.max(windowScrollX, pageScrollX, visualViewportScrollX, rootScrollX, bodyScrollX, scrollingElementScrollX);
|
||||||
|
const scrollY = Math.max(windowScrollY, pageScrollY, visualViewportScrollY, rootScrollY, bodyScrollY, scrollingElementScrollY);
|
||||||
|
previewCaptureDebug('scroll sources', {
|
||||||
|
windowScrollX, windowScrollY,
|
||||||
|
pageScrollX, pageScrollY,
|
||||||
|
visualViewportScrollX, visualViewportScrollY,
|
||||||
|
rootScrollX, rootScrollY,
|
||||||
|
bodyScrollX, bodyScrollY,
|
||||||
|
scrollingElementScrollX, scrollingElementScrollY,
|
||||||
|
scrollingElementTag: scrollingElement?.tagName?.toLowerCase() ?? null,
|
||||||
|
resolvedScrollX: scrollX, resolvedScrollY: scrollY,
|
||||||
|
nestedScrolledElements: describeScrolledElements(document),
|
||||||
|
});
|
||||||
|
const captureWidth = Math.max(viewportWidth, root.scrollWidth || 0, body?.scrollWidth || 0, Math.ceil(root.getBoundingClientRect().width || 0));
|
||||||
|
const captureHeight = Math.max(viewportHeight, root.scrollHeight || 0, body?.scrollHeight || 0, Math.ceil(root.getBoundingClientRect().height || 0));
|
||||||
|
const pixelRatio = Math.min(2, Math.max(1, window.devicePixelRatio || 1));
|
||||||
|
const previousRootScrollBehavior = root.style.scrollBehavior;
|
||||||
|
const previousBodyScrollBehavior = body?.style.scrollBehavior ?? '';
|
||||||
|
root.style.scrollBehavior = 'auto';
|
||||||
|
if (body) body.style.scrollBehavior = 'auto';
|
||||||
|
|
||||||
|
// Snapshot nested scroll positions before any mutation resets them.
|
||||||
|
const nestedScroll = captureNestedScrollState(document);
|
||||||
|
previewCaptureDebug('nested scroll snapshot', nestedScroll.snapshot);
|
||||||
|
|
||||||
|
let restoreAssets = () => { /* no-op until capture preparation mutates DOM */ };
|
||||||
|
let restoreFixedElements = () => { /* no-op until fixed elements are tagged */ };
|
||||||
|
try {
|
||||||
|
await document.fonts?.ready.catch(() => undefined);
|
||||||
|
restoreAssets = await inlineIframeCaptureAssets(document, viewportWidth, viewportHeight, { applyLayoutWorkarounds: false });
|
||||||
|
frameWindow.scrollTo(scrollX, scrollY);
|
||||||
|
// Tag-only (no style mutation), so the sidebar's scroll is never disturbed; the
|
||||||
|
// pinning happens on the clone via snapdomFixedPinPlugin.afterClone.
|
||||||
|
restoreFixedElements = tagFixedElementsForClonePinning(document, scrollX, scrollY);
|
||||||
|
// Defensive: undo any nested-scroll drift from asset inlining before capture.
|
||||||
|
nestedScroll.reapply();
|
||||||
|
|
||||||
|
const snapdomOptions = {
|
||||||
|
backgroundColor: getCaptureBackgroundColor(document),
|
||||||
|
cache: 'disabled' as const,
|
||||||
|
dpr: pixelRatio,
|
||||||
|
embedFonts: true,
|
||||||
|
fast: false,
|
||||||
|
height: captureHeight,
|
||||||
|
outerShadows: true,
|
||||||
|
outerTransforms: true,
|
||||||
|
placeholders: true,
|
||||||
|
plugins: [snapdomFixedPinPlugin, snapdomNestedScrollPlugin],
|
||||||
|
quality: 0.82,
|
||||||
|
width: captureWidth,
|
||||||
|
};
|
||||||
|
const capture = await snapdom(root, snapdomOptions);
|
||||||
|
const fullCanvas = await capture.toCanvas();
|
||||||
|
if (!fullCanvas.width || !fullCanvas.height) return null;
|
||||||
|
|
||||||
|
const xScale = fullCanvas.width / Math.max(1, captureWidth);
|
||||||
|
const yScale = fullCanvas.height / Math.max(1, captureHeight);
|
||||||
|
const sourceWidth = Math.min(fullCanvas.width, Math.max(1, Math.round(viewportWidth * xScale)));
|
||||||
|
const sourceHeight = Math.min(fullCanvas.height, Math.max(1, Math.round(viewportHeight * yScale)));
|
||||||
|
const maxSourceX = Math.max(0, fullCanvas.width - sourceWidth);
|
||||||
|
const maxSourceY = Math.max(0, fullCanvas.height - sourceHeight);
|
||||||
|
// snapDOM bakes scroll into nested overflow containers via translate(), but
|
||||||
|
// NOT into document-level scroll (documentElement/body): wrapping <html>'s
|
||||||
|
// children in a translate <div> is invalid and renders no offset. So the
|
||||||
|
// document scroll is never baked, and we always crop at the scroll offset.
|
||||||
|
// (An earlier heuristic scanned the raw SVG for a matching translate and
|
||||||
|
// cropped from 0 when found — but a nested scroller at the same offset could
|
||||||
|
// false-match and re-introduce top-of-page screenshots, so it's gone.)
|
||||||
|
const sourceX = Math.min(maxSourceX, Math.max(0, Math.round(scrollX * xScale)));
|
||||||
|
const sourceY = Math.min(maxSourceY, Math.max(0, Math.round(scrollY * yScale)));
|
||||||
|
previewCaptureDebug('capture geometry', {
|
||||||
|
viewportWidth, viewportHeight,
|
||||||
|
captureWidth, captureHeight,
|
||||||
|
canvasWidth: fullCanvas.width, canvasHeight: fullCanvas.height,
|
||||||
|
xScale, yScale,
|
||||||
|
sourceX, sourceY, sourceWidth, sourceHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
const viewportCanvas = document.createElement('canvas');
|
||||||
|
viewportCanvas.width = sourceWidth;
|
||||||
|
viewportCanvas.height = sourceHeight;
|
||||||
|
const context = viewportCanvas.getContext('2d');
|
||||||
|
if (!context) return null;
|
||||||
|
|
||||||
|
context.drawImage(fullCanvas, sourceX, sourceY, sourceWidth, sourceHeight, 0, 0, sourceWidth, sourceHeight);
|
||||||
|
const base64 = await canvasToJpegBase64(viewportCanvas, 0.82);
|
||||||
|
if (!base64) return null;
|
||||||
|
|
||||||
|
return await desktopAnnotationToFile(base64, viewportWidth, viewportHeight, viewportWidth, viewportHeight, target);
|
||||||
|
} finally {
|
||||||
|
restoreFixedElements();
|
||||||
|
nestedScroll.cleanup();
|
||||||
|
restoreAssets();
|
||||||
|
frameWindow.scrollTo(scrollX, scrollY);
|
||||||
|
nestedScroll.reapply();
|
||||||
|
root.style.scrollBehavior = previousRootScrollBehavior;
|
||||||
|
if (body) body.style.scrollBehavior = previousBodyScrollBehavior;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[preview] failed to capture iframe DOM screenshot with snapDOM:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureIframeDomScreenshot(
|
||||||
|
iframe: HTMLIFrameElement,
|
||||||
|
target: PreviewElementMetadata,
|
||||||
|
): Promise<File | null> {
|
||||||
|
const snapdomScreenshot = await captureIframeSnapdomScreenshot(iframe, target);
|
||||||
|
if (snapdomScreenshot) return snapdomScreenshot;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const frameWindow = iframe.contentWindow;
|
||||||
|
const document = iframe.contentDocument ?? frameWindow?.document;
|
||||||
|
const root = document?.documentElement;
|
||||||
|
if (!frameWindow || !document || !root) return null;
|
||||||
|
|
||||||
|
const iframeRect = iframe.getBoundingClientRect();
|
||||||
|
const viewportWidth = Math.max(1, Math.ceil(frameWindow.innerWidth || iframe.clientWidth || iframeRect.width));
|
||||||
|
const viewportHeight = Math.max(1, Math.ceil(frameWindow.innerHeight || iframe.clientHeight || iframeRect.height));
|
||||||
|
const scrollX = frameWindow.scrollX || document.documentElement.scrollLeft || document.body?.scrollLeft || 0;
|
||||||
|
const scrollY = frameWindow.scrollY || document.documentElement.scrollTop || document.body?.scrollTop || 0;
|
||||||
|
const body = document.body;
|
||||||
|
const captureHeight = Math.max(viewportHeight, root.scrollHeight || 0, body?.scrollHeight || 0);
|
||||||
|
const pixelRatio = Math.min(2, Math.max(1, window.devicePixelRatio || 1));
|
||||||
|
const previousRootScrollBehavior = root.style.scrollBehavior;
|
||||||
|
const previousBodyScrollBehavior = body?.style.scrollBehavior ?? '';
|
||||||
|
root.style.scrollBehavior = 'auto';
|
||||||
|
if (body) body.style.scrollBehavior = 'auto';
|
||||||
|
|
||||||
|
let dataUrl = '';
|
||||||
|
let restoreAssets = () => { /* no-op until capture preparation mutates DOM */ };
|
||||||
|
try {
|
||||||
|
await document.fonts?.ready.catch(() => undefined);
|
||||||
|
restoreAssets = await inlineIframeCaptureAssets(document, viewportWidth, viewportHeight, { applyLayoutWorkarounds: true });
|
||||||
|
frameWindow.scrollTo(scrollX, scrollY);
|
||||||
|
const fontEmbedCSS = await getFontEmbedCSS(root).catch(() => '');
|
||||||
|
|
||||||
|
dataUrl = await toJpeg(root, {
|
||||||
|
quality: 0.82,
|
||||||
|
pixelRatio,
|
||||||
|
width: viewportWidth,
|
||||||
|
height: viewportHeight,
|
||||||
|
backgroundColor: getCaptureBackgroundColor(document),
|
||||||
|
imagePlaceholder: TRANSPARENT_IMAGE_PLACEHOLDER,
|
||||||
|
fontEmbedCSS: fontEmbedCSS || undefined,
|
||||||
|
style: {
|
||||||
|
transform: `translate(${-scrollX}px, ${-scrollY}px)`,
|
||||||
|
transformOrigin: 'top left',
|
||||||
|
minWidth: `${viewportWidth}px`,
|
||||||
|
minHeight: `${captureHeight}px`,
|
||||||
|
},
|
||||||
|
cacheBust: true,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
restoreAssets();
|
||||||
|
frameWindow.scrollTo(scrollX, scrollY);
|
||||||
|
root.style.scrollBehavior = previousRootScrollBehavior;
|
||||||
|
if (body) body.style.scrollBehavior = previousBodyScrollBehavior;
|
||||||
|
}
|
||||||
|
|
||||||
|
const base64 = dataUrl.split(',', 2)[1] || '';
|
||||||
|
if (!base64) return null;
|
||||||
|
|
||||||
|
return await desktopAnnotationToFile(base64, viewportWidth, viewportHeight, viewportWidth, viewportHeight, target);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[preview] failed to capture iframe DOM screenshot:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,7 +107,7 @@ export const classifyPreviewResourceError = ({ tagName, url }) => {
|
|||||||
return 'report';
|
return 'report';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const classifyPreviewNavigation = ({ url, currentUrl }) => {
|
export const classifyPreviewNavigation = ({ url, currentUrl, targetOrigin }) => {
|
||||||
let parsed;
|
let parsed;
|
||||||
try {
|
try {
|
||||||
parsed = new URL(String(url || ''), currentUrl || 'http://localhost/');
|
parsed = new URL(String(url || ''), currentUrl || 'http://localhost/');
|
||||||
@@ -136,10 +136,22 @@ export const classifyPreviewNavigation = ({ url, currentUrl }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const path = parsed.pathname || '/';
|
const path = parsed.pathname || '/';
|
||||||
|
const proxyMatch = current?.pathname?.match(/^(\/api\/preview\/proxy\/[a-f0-9]{16,64})(?:\/|$)/i);
|
||||||
if (parsed.origin === current?.origin && path.startsWith('/api/preview/proxy/')) {
|
if (parsed.origin === current?.origin && path.startsWith('/api/preview/proxy/')) {
|
||||||
return { action: 'allow', url: parsed.toString() };
|
return { action: 'allow', url: parsed.toString() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (proxyMatch && parsed.origin === current?.origin && path.startsWith('/') && !path.startsWith(proxyMatch[1])) {
|
||||||
|
try {
|
||||||
|
if (targetOrigin) {
|
||||||
|
const upstreamUrl = new URL(`${parsed.pathname}${parsed.search}${parsed.hash}`, targetOrigin);
|
||||||
|
return { action: 'proxy', url: upstreamUrl.toString() };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// fall through to the default policy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const host = parsed.hostname;
|
const host = parsed.hostname;
|
||||||
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1' || host === '[::1]';
|
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1' || host === '[::1]';
|
||||||
if (isLoopback || (parsed.origin === current?.origin && path.startsWith('/'))) {
|
if (isLoopback || (parsed.origin === current?.origin && path.startsWith('/'))) {
|
||||||
@@ -149,7 +161,7 @@ export const classifyPreviewNavigation = ({ url, currentUrl }) => {
|
|||||||
return { action: 'external', url: parsed.toString() };
|
return { action: 'external', url: parsed.toString() };
|
||||||
};
|
};
|
||||||
|
|
||||||
const PREVIEW_BRIDGE_SCRIPT = String.raw`(() => {
|
const PREVIEW_BRIDGE_SCRIPT = String.raw`(() => {
|
||||||
if (window.__openchamberPreviewBridgeInstalled) return;
|
if (window.__openchamberPreviewBridgeInstalled) return;
|
||||||
window.__openchamberPreviewBridgeInstalled = true;
|
window.__openchamberPreviewBridgeInstalled = true;
|
||||||
|
|
||||||
@@ -157,6 +169,7 @@ const PREVIEW_BRIDGE_SCRIPT = String.raw`(() => {
|
|||||||
const VERSION = 1;
|
const VERSION = 1;
|
||||||
const MAX_TEXT = 500;
|
const MAX_TEXT = 500;
|
||||||
const MAX_ARG = 1000;
|
const MAX_ARG = 1000;
|
||||||
|
const TARGET_ORIGIN = typeof window.__openchamberPreviewTargetOrigin === 'string' ? window.__openchamberPreviewTargetOrigin : '';
|
||||||
let inspectMode = false;
|
let inspectMode = false;
|
||||||
let lastHoverKey = '';
|
let lastHoverKey = '';
|
||||||
let pendingHover = null;
|
let pendingHover = null;
|
||||||
@@ -368,12 +381,19 @@ const PREVIEW_BRIDGE_SCRIPT = String.raw`(() => {
|
|||||||
const parsed = new URL(value, window.location.href);
|
const parsed = new URL(value, window.location.href);
|
||||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return { action: 'allow', url: parsed.toString() };
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return { action: 'allow', url: parsed.toString() };
|
||||||
const current = new URL(window.location.href);
|
const current = new URL(window.location.href);
|
||||||
|
const proxyMatch = current.pathname.match(/^(\/api\/preview\/proxy\/[a-f0-9]{16,64})(?:\/|$)/i);
|
||||||
if (parsed.origin === current.origin && parsed.pathname === current.pathname && parsed.search === current.search && parsed.hash) {
|
if (parsed.origin === current.origin && parsed.pathname === current.pathname && parsed.search === current.search && parsed.hash) {
|
||||||
return { action: 'allow', url: parsed.toString() };
|
return { action: 'allow', url: parsed.toString() };
|
||||||
}
|
}
|
||||||
if (parsed.origin === current.origin && parsed.pathname.startsWith('/api/preview/proxy/')) {
|
if (parsed.origin === current.origin && parsed.pathname.startsWith('/api/preview/proxy/')) {
|
||||||
return { action: 'allow', url: parsed.toString() };
|
return { action: 'allow', url: parsed.toString() };
|
||||||
}
|
}
|
||||||
|
if (proxyMatch && parsed.origin === current.origin && parsed.pathname.startsWith('/') && parsed.pathname.indexOf(proxyMatch[1]) !== 0 && TARGET_ORIGIN) {
|
||||||
|
try {
|
||||||
|
const upstreamUrl = new URL(parsed.pathname + parsed.search + parsed.hash, TARGET_ORIGIN);
|
||||||
|
return { action: 'proxy', url: upstreamUrl.toString() };
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
const host = parsed.hostname;
|
const host = parsed.hostname;
|
||||||
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1' || host === '[::1]';
|
const isLoopback = host === 'localhost' || host === '127.0.0.1' || host === '0.0.0.0' || host === '::1' || host === '[::1]';
|
||||||
if (isLoopback || (parsed.origin === current.origin && parsed.pathname.startsWith('/'))) {
|
if (isLoopback || (parsed.origin === current.origin && parsed.pathname.startsWith('/'))) {
|
||||||
@@ -819,7 +839,44 @@ const buildCookie = ({
|
|||||||
return chunks.join('; ');
|
return chunks.join('; ');
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeLoopbackUrl = (rawUrl) => {
|
// SSRF guard for the `allowExternal` path: refuse to proxy private, loopback and
|
||||||
|
// reserved addresses (incl. cloud-metadata 169.254.169.254). Operates on the
|
||||||
|
// WHATWG-normalized hostname, so decimal/hex/octal IPv4 forms are already canonical
|
||||||
|
// dotted-decimal here. NOTE: this blocks IP *literals* only — a hostname that
|
||||||
|
// resolves to a private IP (DNS rebinding) is not caught and would need
|
||||||
|
// resolve-time IP pinning. Loopback for local preview goes through the non-external
|
||||||
|
// path (allowExternal=false), which is unaffected.
|
||||||
|
const isBlockedExternalHost = (hostname) => {
|
||||||
|
if (!hostname) return true;
|
||||||
|
let host = hostname.toLowerCase();
|
||||||
|
if (host.startsWith('[') && host.endsWith(']')) host = host.slice(1, -1);
|
||||||
|
|
||||||
|
if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return true;
|
||||||
|
|
||||||
|
const v4 = host.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
||||||
|
if (v4) {
|
||||||
|
const a = Number(v4[1]);
|
||||||
|
const b = Number(v4[2]);
|
||||||
|
if (a === 0 || a === 127 || a === 10) return true; // this-host / loopback / private
|
||||||
|
if (a === 169 && b === 254) return true; // link-local incl. cloud metadata
|
||||||
|
if (a === 172 && b >= 16 && b <= 31) return true; // private
|
||||||
|
if (a === 192 && b === 168) return true; // private
|
||||||
|
if (a === 100 && b >= 64 && b <= 127) return true; // carrier-grade NAT
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (host.includes(':')) {
|
||||||
|
if (host === '::1' || host === '::') return true; // loopback / unspecified
|
||||||
|
if (host.startsWith('fe80')) return true; // link-local
|
||||||
|
if (host.startsWith('fc') || host.startsWith('fd')) return true; // unique local fc00::/7
|
||||||
|
if (host.includes('::ffff:')) return true; // IPv4-mapped (dotted or hex form)
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const normalizeProxyTargetUrl = (rawUrl, { allowExternal = false } = {}) => {
|
||||||
let url;
|
let url;
|
||||||
try {
|
try {
|
||||||
url = new URL(rawUrl);
|
url = new URL(rawUrl);
|
||||||
@@ -832,8 +889,12 @@ const normalizeLoopbackUrl = (rawUrl) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const hostname = url.hostname;
|
const hostname = url.hostname;
|
||||||
if (!LOOPBACK_HOSTS.has(hostname)) {
|
if (!allowExternal) {
|
||||||
return { ok: false, error: 'Only loopback hosts are supported' };
|
if (!LOOPBACK_HOSTS.has(hostname)) {
|
||||||
|
return { ok: false, error: 'Only loopback hosts are supported' };
|
||||||
|
}
|
||||||
|
} else if (isBlockedExternalHost(hostname)) {
|
||||||
|
return { ok: false, error: 'Refusing to proxy private or reserved addresses' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const port = url.port ? Number.parseInt(url.port, 10) : (url.protocol === 'https:' ? 443 : 80);
|
const port = url.port ? Number.parseInt(url.port, 10) : (url.protocol === 'https:' ? 443 : 80);
|
||||||
@@ -843,7 +904,7 @@ const normalizeLoopbackUrl = (rawUrl) => {
|
|||||||
|
|
||||||
// Normalize common loopback hostnames to IPv4 to avoid environments where
|
// Normalize common loopback hostnames to IPv4 to avoid environments where
|
||||||
// `localhost` resolves to ::1 but the dev server only binds IPv4.
|
// `localhost` resolves to ::1 but the dev server only binds IPv4.
|
||||||
if (hostname === '0.0.0.0' || hostname === 'localhost' || hostname === '::1' || hostname === '[::1]') {
|
if (LOOPBACK_HOSTS.has(hostname) && (hostname === '0.0.0.0' || hostname === 'localhost' || hostname === '::1' || hostname === '[::1]')) {
|
||||||
url.hostname = '127.0.0.1';
|
url.hostname = '127.0.0.1';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -851,6 +912,8 @@ const normalizeLoopbackUrl = (rawUrl) => {
|
|||||||
return { ok: true, origin: url.origin };
|
return { ok: true, origin: url.origin };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeLoopbackUrl = (rawUrl) => normalizeProxyTargetUrl(rawUrl, { allowExternal: false });
|
||||||
|
|
||||||
export const rewritePreviewBody = ({ bodyText, proxyBasePath, targetOrigin, kind }) => {
|
export const rewritePreviewBody = ({ bodyText, proxyBasePath, targetOrigin, kind }) => {
|
||||||
if (typeof bodyText !== 'string' || bodyText.length === 0) {
|
if (typeof bodyText !== 'string' || bodyText.length === 0) {
|
||||||
return bodyText;
|
return bodyText;
|
||||||
@@ -858,9 +921,11 @@ export const rewritePreviewBody = ({ bodyText, proxyBasePath, targetOrigin, kind
|
|||||||
|
|
||||||
const prefix = proxyBasePath.endsWith('/') ? proxyBasePath.slice(0, -1) : proxyBasePath;
|
const prefix = proxyBasePath.endsWith('/') ? proxyBasePath.slice(0, -1) : proxyBasePath;
|
||||||
const target = targetOrigin ? new URL(targetOrigin) : null;
|
const target = targetOrigin ? new URL(targetOrigin) : null;
|
||||||
const isSameLoopbackTarget = (url) => {
|
const isSameTargetOrigin = (url) => {
|
||||||
if (!target) return false;
|
if (!target) return false;
|
||||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
||||||
|
if (url.origin === target.origin) return true;
|
||||||
|
|
||||||
const host = url.hostname;
|
const host = url.hostname;
|
||||||
if (host !== 'localhost' && host !== '127.0.0.1' && host !== '0.0.0.0' && host !== '::1' && host !== '[::1]') {
|
if (host !== 'localhost' && host !== '127.0.0.1' && host !== '0.0.0.0' && host !== '::1' && host !== '[::1]') {
|
||||||
return false;
|
return false;
|
||||||
@@ -875,7 +940,7 @@ export const rewritePreviewBody = ({ bodyText, proxyBasePath, targetOrigin, kind
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(value);
|
const parsed = new URL(value);
|
||||||
if (isSameLoopbackTarget(parsed)) {
|
if (isSameTargetOrigin(parsed)) {
|
||||||
return `${prefix}${parsed.pathname}${parsed.search}${parsed.hash}`;
|
return `${prefix}${parsed.pathname}${parsed.search}${parsed.hash}`;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1074,12 +1139,13 @@ export const createPreviewProxyRuntime = ({
|
|||||||
}) => {
|
}) => {
|
||||||
ensureSweeper();
|
ensureSweeper();
|
||||||
|
|
||||||
const injectPreviewBridge = (bodyText) => {
|
const injectPreviewBridge = (bodyText, targetOrigin) => {
|
||||||
if (typeof bodyText !== 'string' || bodyText.includes(PREVIEW_BRIDGE_SCRIPT_ID)) {
|
if (typeof bodyText !== 'string' || bodyText.includes(PREVIEW_BRIDGE_SCRIPT_ID)) {
|
||||||
return bodyText;
|
return bodyText;
|
||||||
}
|
}
|
||||||
|
|
||||||
const script = `<script id="${PREVIEW_BRIDGE_SCRIPT_ID}">${PREVIEW_BRIDGE_SCRIPT}</script>`;
|
const targetOriginScript = `<script>window.__openchamberPreviewTargetOrigin=${JSON.stringify(targetOrigin || '')};</script>`;
|
||||||
|
const script = `${targetOriginScript}<script id="${PREVIEW_BRIDGE_SCRIPT_ID}">${PREVIEW_BRIDGE_SCRIPT}</script>`;
|
||||||
if (/<head(?:\s[^>]*)?>/i.test(bodyText)) {
|
if (/<head(?:\s[^>]*)?>/i.test(bodyText)) {
|
||||||
return bodyText.replace(/<head(\s[^>]*)?>/i, (match) => `${match}${script}`);
|
return bodyText.replace(/<head(\s[^>]*)?>/i, (match) => `${match}${script}`);
|
||||||
}
|
}
|
||||||
@@ -1128,7 +1194,10 @@ export const createPreviewProxyRuntime = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ttlMs = typeof req.body?.ttlMs === 'number' ? req.body.ttlMs : DEFAULT_TARGET_TTL_MS;
|
const ttlMs = typeof req.body?.ttlMs === 'number' ? req.body.ttlMs : DEFAULT_TARGET_TTL_MS;
|
||||||
const normalized = normalizeLoopbackUrl(rawUrl);
|
const allowExternal = req.body?.allowExternal === true;
|
||||||
|
const normalized = allowExternal
|
||||||
|
? normalizeProxyTargetUrl(rawUrl, { allowExternal: true })
|
||||||
|
: normalizeLoopbackUrl(rawUrl);
|
||||||
if (!normalized.ok) {
|
if (!normalized.ok) {
|
||||||
return res.status(400).json({ error: normalized.error });
|
return res.status(400).json({ error: normalized.error });
|
||||||
}
|
}
|
||||||
@@ -1244,7 +1313,7 @@ export const createPreviewProxyRuntime = ({
|
|||||||
targetOrigin: resolved.entry.origin,
|
targetOrigin: resolved.entry.origin,
|
||||||
kind: isHtml ? 'html' : isCss ? 'css' : 'javascript',
|
kind: isHtml ? 'html' : isCss ? 'css' : 'javascript',
|
||||||
});
|
});
|
||||||
return isHtml ? injectPreviewBridge(rewrittenBody) : rewrittenBody;
|
return isHtml ? injectPreviewBridge(rewrittenBody, resolved.entry.origin) : rewrittenBody;
|
||||||
}),
|
}),
|
||||||
error: (err, _req, res) => {
|
error: (err, _req, res) => {
|
||||||
const isDev = typeof process !== 'undefined'
|
const isDev = typeof process !== 'undefined'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { classifyPreviewNavigation, classifyPreviewResourceError, rewritePreviewBody } from './proxy-runtime.js';
|
import { classifyPreviewNavigation, classifyPreviewResourceError, normalizeProxyTargetUrl, rewritePreviewBody } from './proxy-runtime.js';
|
||||||
|
|
||||||
const rewrite = (bodyText, kind) => rewritePreviewBody({
|
const rewrite = (bodyText, kind) => rewritePreviewBody({
|
||||||
bodyText,
|
bodyText,
|
||||||
@@ -128,6 +128,17 @@ describe('preview navigation policy', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('maps app-origin root links back to the upstream origin while proxied', () => {
|
||||||
|
expect(classifyPreviewNavigation({
|
||||||
|
url: 'http://127.0.0.1:57123/support',
|
||||||
|
currentUrl,
|
||||||
|
targetOrigin: 'https://openchamber.dev',
|
||||||
|
})).toEqual({
|
||||||
|
action: 'proxy',
|
||||||
|
url: 'https://openchamber.dev/support',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('sends non-loopback http links outside the preview iframe', () => {
|
it('sends non-loopback http links outside the preview iframe', () => {
|
||||||
expect(classifyPreviewNavigation({ url: 'https://example.com/docs', currentUrl })).toEqual({
|
expect(classifyPreviewNavigation({ url: 'https://example.com/docs', currentUrl })).toEqual({
|
||||||
action: 'external',
|
action: 'external',
|
||||||
@@ -142,3 +153,37 @@ describe('preview navigation policy', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('proxy target normalization (SSRF guard)', () => {
|
||||||
|
it('allows ordinary external hosts when allowExternal is set', () => {
|
||||||
|
expect(normalizeProxyTargetUrl('https://docs.openchamber.dev/security/', { allowExternal: true }))
|
||||||
|
.toEqual({ ok: true, origin: 'https://docs.openchamber.dev' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-loopback hosts without allowExternal', () => {
|
||||||
|
expect(normalizeProxyTargetUrl('https://example.com/', {}).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses private, loopback and link-local literals on the external path', () => {
|
||||||
|
for (const url of [
|
||||||
|
'http://127.0.0.1/',
|
||||||
|
'http://10.0.0.5/',
|
||||||
|
'http://172.16.9.9/',
|
||||||
|
'http://192.168.1.1/',
|
||||||
|
'http://169.254.169.254/latest/meta-data/',
|
||||||
|
'http://100.64.0.1/',
|
||||||
|
'http://localhost/',
|
||||||
|
'http://service.local/',
|
||||||
|
'http://[::1]/',
|
||||||
|
'http://[fd00::1]/',
|
||||||
|
'http://[fe80::1]/',
|
||||||
|
'http://2130706433/', // decimal form of 127.0.0.1, normalized by WHATWG URL
|
||||||
|
]) {
|
||||||
|
expect(normalizeProxyTargetUrl(url, { allowExternal: true }).ok, url).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('still blocks private hosts even via IPv4-mapped IPv6', () => {
|
||||||
|
expect(normalizeProxyTargetUrl('http://[::ffff:127.0.0.1]/', { allowExternal: true }).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user