Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 48 additions & 2 deletions packages/ui/demo/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,61 @@ const repoRoot = join(here, "..", "..", "..")
const sdkIife = join(repoRoot, "packages", "core", "dist", "repro.iife.js")
const indexHtml = join(here, "index.html")

// A real host-page CSP, copied from a site that hit the "Capturing…" hang.
// The load-bearing part is `img-src 'self' data:` with **no `blob:`** — an
// <img src="blob:..."> is refused under this policy, which is why the SDK
// decodes screenshots with createImageBitmap (no resource load) instead.
// Serve the same demo page under it at /csp to exercise that path.
const STRICT_CSP = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data:",
"connect-src 'self'",
].join("; ")

// Capture is entirely client-side, so a well-formed-but-fake key is enough to
// exercise the screenshot flow. Override with REPRO_DEMO_KEY to submit for
// real against a running dashboard.
const DEMO_KEY = process.env.REPRO_DEMO_KEY ?? "rp_pk_demo00000000000000000000"

function renderPage(html: string, csp: boolean): string {
const banner = csp
? `<div style="padding:10px 16px;background:#7f1d1d;color:#fff;font:600 13px system-ui">
CSP mode — <code>img-src 'self' data:</code> (no <code>blob:</code>).
Capture must still work. Before the createImageBitmap fix this hung on “Capturing…”.
</div>`
: `<div style="padding:10px 16px;background:#065f46;color:#fff;font:600 13px system-ui">
No CSP — baseline. Compare against <a href="/csp" style="color:#fff">/csp</a>.
</div>`
return html
.replace(
"</head>",
`<script>window.REPRO_DEMO_KEY = ${JSON.stringify(DEMO_KEY)}</script></head>`,
)
.replace("<body>", `<body>${banner}`)
}

Bun.serve({
port: 4000,
hostname: "localhost",
async fetch(req) {
const url = new URL(req.url)
if (url.pathname === "/" || url.pathname === "/index.html") {
const body = await readFile(indexHtml, "utf8")
return new Response(body, {
return new Response(renderPage(body, false), {
headers: { "Content-Type": "text/html; charset=utf-8" },
})
}
if (url.pathname === "/csp") {
const body = await readFile(indexHtml, "utf8")
return new Response(renderPage(body, true), {
headers: {
"Content-Type": "text/html; charset=utf-8",
"Content-Security-Policy": STRICT_CSP,
},
})
}
if (url.pathname === "/sdk.iife.js") {
try {
const body = await readFile(sdkIife)
Expand All @@ -35,4 +79,6 @@ Bun.serve({
},
})

console.info("Repro demo playground: http://localhost:4000")
console.info("Repro demo playground:")
console.info(" baseline (no CSP): http://localhost:4000/")
console.info(" strict CSP: http://localhost:4000/csp ← the reported bug's policy")
9 changes: 4 additions & 5 deletions packages/ui/src/annotation/canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { effect } from "@preact/signals"
import { h } from "preact"
import { useEffect, useRef, useState } from "preact/hooks"
import { render as renderAll } from "./render"
import { sourceHeight, sourceWidth, type ImageSource } from "../decode-image"
import { color, commit, draft, shapes, strokeW, tool, viewport } from "./store"
import { arrowTool, highlightTool, penTool, rectTool, textTool } from "@reprojs/sdk-utils"
import type { ToolHandler } from "@reprojs/sdk-utils"
Expand All @@ -18,13 +19,11 @@ const HANDLERS: Record<Tool, ToolHandler> = {
}

export interface CanvasProps {
bg: HTMLImageElement
bg: ImageSource
}

function naturalDims(bg: HTMLImageElement): { w: number; h: number } {
const imgW = (bg as unknown as { naturalWidth?: number }).naturalWidth ?? bg.width
const imgH = (bg as unknown as { naturalHeight?: number }).naturalHeight ?? bg.height
return { w: imgW, h: imgH }
function naturalDims(bg: ImageSource): { w: number; h: number } {
return { w: sourceWidth(bg), h: sourceHeight(bg) }
}

export function Canvas({ bg }: CanvasProps) {
Expand Down
7 changes: 4 additions & 3 deletions packages/ui/src/annotation/flatten.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { render } from "./render"
import { sourceHeight, sourceWidth, type ImageSource } from "../decode-image"
import { IDENTITY_TRANSFORM, type Shape } from "@reprojs/sdk-utils"

export async function flatten(bg: HTMLImageElement, shapes: Shape[]): Promise<Blob> {
const width = bg.naturalWidth ?? bg.width
const height = bg.naturalHeight ?? bg.height
export async function flatten(bg: ImageSource, shapes: Shape[]): Promise<Blob> {
const width = sourceWidth(bg)
const height = sourceHeight(bg)

const canvas = document.createElement("canvas")
canvas.width = width
Expand Down
3 changes: 2 additions & 1 deletion packages/ui/src/annotation/render.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { wrapText } from "./text-wrap"
import type { ImageSource } from "../decode-image"
import type { Shape, Transform } from "@reprojs/sdk-utils"

export function render(
ctx: CanvasRenderingContext2D,
bg: HTMLImageElement | HTMLCanvasElement,
bg: ImageSource | HTMLCanvasElement,
shapes: Shape[],
t: Transform,
): void {
Expand Down
42 changes: 42 additions & 0 deletions packages/ui/src/blob-image.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { h } from "preact"
import { useEffect, useRef } from "preact/hooks"
import { closeSource, decodeImage, sourceHeight, sourceWidth } from "./decode-image"

interface Props {
blob: Blob
alt: string
class?: string
}

// Renders a Blob as an image without going through a blob: object URL, which
// host-page CSPs routinely refuse (`img-src 'self' data:`). Draws the decoded
// bitmap into a <canvas> instead — canvas painting is not a resource load, so
// no img-src check applies. See decode-image.ts.
//
// <canvas> is a replaced element, so the object-fit / aspect-ratio rules the
// thumbnails rely on still apply.
export function BlobImage({ blob, alt, class: className }: Props) {
const ref = useRef<HTMLCanvasElement>(null)

useEffect(() => {
let cancelled = false
;(async () => {
const src = await decodeImage(blob)
if (!src) return
const canvas = ref.current
if (cancelled || !canvas) {
closeSource(src)
return
}
canvas.width = sourceWidth(src)
canvas.height = sourceHeight(src)
canvas.getContext("2d")?.drawImage(src, 0, 0)
closeSource(src)
})()
return () => {
cancelled = true
}
}, [blob])

return h("canvas", { ref, class: className, role: "img", "aria-label": alt })
}
142 changes: 142 additions & 0 deletions packages/ui/src/decode-image.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
import { afterEach, describe, expect, test } from "bun:test"
import { Window } from "happy-dom"
import { decodeImage, sourceHeight, sourceWidth } from "./decode-image"

type Mutable = Record<string, unknown>

function setupDom() {
const win = new Window()
const g = globalThis as unknown as Mutable
g.document = win.document
g.window = win
return win
}

// Keep the real URL constructor intact — happy-dom's Window needs it. Only the
// object-URL helpers get stubbed, and only for the duration of a test.
const realCreateObjectURL = URL.createObjectURL
const realRevokeObjectURL = URL.revokeObjectURL
const realImage = (globalThis as unknown as Mutable).Image
const realCreateImageBitmap = (globalThis as unknown as Mutable).createImageBitmap

function stubObjectUrl(create: () => string, revoke: () => void) {
URL.createObjectURL = create as unknown as typeof URL.createObjectURL
URL.revokeObjectURL = revoke as unknown as typeof URL.revokeObjectURL
}

afterEach(() => {
const g = globalThis as unknown as Mutable
g.createImageBitmap = realCreateImageBitmap
g.Image = realImage
URL.createObjectURL = realCreateObjectURL
URL.revokeObjectURL = realRevokeObjectURL
})

const PNG = new Blob([new Uint8Array([137, 80, 78, 71])], { type: "image/png" })

describe("decodeImage", () => {
// The reason this module exists: host pages commonly ship
// `img-src 'self' data:` with no `blob:`. Routing the screenshot through
// URL.createObjectURL + <img> gets refused by CSP, fires `error` instead of
// `load`, and used to strand the reporter on "Capturing…" forever.
// createImageBitmap decodes the Blob directly with no resource fetch, so
// img-src never applies.
test("decodes via createImageBitmap without minting a blob: URL", async () => {
setupDom()
const g = globalThis as unknown as Mutable
const bitmap = { width: 800, height: 600, close: () => {} }
g.createImageBitmap = async () => bitmap

let objectUrls = 0
stubObjectUrl(
() => {
objectUrls++
return "blob:nope"
},
() => {},
)

const out = await decodeImage(PNG)

expect(out).toBe(bitmap as unknown as ImageBitmap)
expect(objectUrls).toBe(0)
})

test("returns null instead of hanging when the image cannot be decoded", async () => {
setupDom()
const g = globalThis as unknown as Mutable
g.createImageBitmap = undefined
// Simulate the CSP refusal: assigning src fires `error`, never `load`.
g.Image = class {
onload: (() => void) | null = null
listeners: Record<string, Array<() => void>> = {}
addEventListener(type: string, cb: () => void) {
;(this.listeners[type] ??= []).push(cb)
}
set src(_v: string) {
queueMicrotask(() => this.listeners.error?.forEach((cb) => cb()))
}
}

const out = await decodeImage(PNG)

expect(out).toBeNull()
})

test("falls back to an <img> when createImageBitmap is unavailable", async () => {
setupDom()
const g = globalThis as unknown as Mutable
g.createImageBitmap = undefined
let revoked = 0
stubObjectUrl(
() => "blob:ok",
() => {
revoked++
},
)
const img: Mutable = {}
g.Image = class {
naturalWidth = 320
naturalHeight = 240
listeners: Record<string, Array<() => void>> = {}
constructor() {
Object.assign(img, this)
}
addEventListener(type: string, cb: () => void) {
;(this.listeners[type] ??= []).push(cb)
}
set src(_v: string) {
queueMicrotask(() => this.listeners.load?.forEach((cb) => cb()))
}
}

const out = await decodeImage(PNG)

if (!out) throw new Error("expected decodeImage to fall back to an <img>")
expect(sourceWidth(out)).toBe(320)
expect(sourceHeight(out)).toBe(240)
// The object URL must not leak once the image has loaded.
expect(revoked).toBe(1)
})
})

describe("sourceWidth / sourceHeight", () => {
test("reads intrinsic dimensions from an ImageBitmap", () => {
const bitmap = { width: 1280, height: 720 } as unknown as ImageBitmap
expect(sourceWidth(bitmap)).toBe(1280)
expect(sourceHeight(bitmap)).toBe(720)
})

test("prefers naturalWidth/naturalHeight on an HTMLImageElement", () => {
// width/height are layout attributes and can disagree with the real
// pixel dimensions; the annotation canvas needs the intrinsic ones.
const img = {
naturalWidth: 1280,
naturalHeight: 720,
width: 100,
height: 50,
} as unknown as HTMLImageElement
expect(sourceWidth(img)).toBe(1280)
expect(sourceHeight(img)).toBe(720)
})
})
81 changes: 81 additions & 0 deletions packages/ui/src/decode-image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Turning a captured Blob into something drawable must not depend on the host
// page's Content-Security-Policy.
//
// The obvious route — URL.createObjectURL(blob) assigned to an <img> — is a
// resource load, so it is checked against `img-src`. Real host pages routinely
// ship `img-src 'self' data:` with no `blob:` (Pantheon/WordPress defaults,
// most CSP generators). There the load is refused, `error` fires instead of
// `load`, and the reporter is left with no screenshot.
//
// createImageBitmap decodes the Blob in-process with no fetch, so `img-src`
// never applies. It is the primary path; the <img> route survives only as a
// fallback for engines without it, and always resolves (null on failure)
// rather than hanging.

export type ImageSource = ImageBitmap | HTMLImageElement

// How long the <img> fallback waits before giving up. Only reachable on
// engines without createImageBitmap; a refused load normally fires `error`
// immediately, but a policy that neither loads nor errors must not strand the
// caller.
const FALLBACK_TIMEOUT_MS = 10_000

export function sourceWidth(src: ImageSource): number {
return "naturalWidth" in src ? src.naturalWidth : src.width
}

export function sourceHeight(src: ImageSource): number {
return "naturalHeight" in src ? src.naturalHeight : src.height
}

// Release an ImageBitmap's backing memory. Large frames otherwise sit in GPU
// memory until GC runs. No-op for <img>.
export function closeSource(src: ImageSource | null): void {
if (src && "close" in src && typeof src.close === "function") src.close()
}

export async function decodeImage(blob: Blob): Promise<ImageSource | null> {
const create = (
globalThis as {
createImageBitmap?: (b: Blob) => Promise<ImageBitmap>
}
).createImageBitmap
if (typeof create === "function") {
try {
return await create.call(globalThis, blob)
} catch {
// Corrupt/undecodable data, or an engine that rejects this blob type.
// Try the <img> route before giving up.
}
}
return await decodeViaObjectUrl(blob)
}

async function decodeViaObjectUrl(blob: Blob): Promise<HTMLImageElement | null> {
let created: string
try {
created = URL.createObjectURL(blob)
} catch {
return null
}
const img = new Image()
let timer: ReturnType<typeof setTimeout> | undefined
try {
// `once` listeners are mutually exclusive, and the timeout only decides the
// race — whichever settles first wins and the rest are inert.
const loaded = await Promise.race([
new Promise<boolean>((resolve) => {
img.addEventListener("load", () => resolve(true), { once: true })
img.addEventListener("error", () => resolve(false), { once: true })
img.src = created
}),
new Promise<boolean>((resolve) => {
timer = setTimeout(() => resolve(false), FALLBACK_TIMEOUT_MS)
}),
])
return loaded ? img : null
} finally {
clearTimeout(timer)
URL.revokeObjectURL(created)
}
}
Loading
Loading