Skip to content

Commit 357a1ac

Browse files
committed
Merge remote-tracking branch 'origin/staging' into codex/review-pr-7488
2 parents 38e2516 + 7b4e737 commit 357a1ac

62 files changed

Lines changed: 4463 additions & 650 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ npx sim-setup
3232

3333
Open [http://localhost:3000](http://localhost:3000)
3434

35+
### Desktop: [macOS](https://sim.ai/api/desktop/update/download)
36+
37+
<a href="https://sim.ai/api/desktop/update/download" target="_blank" rel="noopener noreferrer"><img src="https://img.shields.io/badge/Download-macOS-3B3B3B?logo=apple&logoColor=white&labelColor=1A1A1A" alt="Download Sim for macOS"></a>
38+
39+
macOS 12+, Apple Silicon and Intel. See the [desktop docs](https://docs.sim.ai/desktop) to point it at a self-hosted deployment.
40+
3541
<p align="center">
3642
<img src="apps/sim/public/static/readme-platform.png" alt="The Sim platform — chat on the left, the visual workflow builder on the right" width="100%"/>
3743
</p>
@@ -46,7 +52,7 @@ Open [http://localhost:3000](http://localhost:3000)
4652

4753
## One workspace, every surface
4854

49-
<p align="center">Chat and workflows are just the start — tables, files, and knowledge all live in the same workspace.</p>
55+
<p align="center">Chat and workflows are just the start — tables, files, and knowledge all live in the same workspace, in the browser or on your desktop.</p>
5056

5157
<table>
5258
<tr>
@@ -64,7 +70,10 @@ Open [http://localhost:3000](http://localhost:3000)
6470
<img src="apps/sim/public/static/readme-knowledge.png" alt="Knowledge bases in Sim — synced docs your agents can search" width="100%"/>
6571
<p align="center"><b>Knowledge</b> — your agents' memory</p>
6672
</td>
67-
<td width="50%" valign="top"></td>
73+
<td width="50%" valign="top">
74+
<img src="apps/sim/public/static/readme-desktop.png" alt="Sim Desktop — Chat on the left, the built-in browser on the right" width="100%"/>
75+
<p align="center"><b>Desktop</b> — Sim on your Mac, browser and terminal included</p>
76+
</td>
6877
</tr>
6978
</table>
7079

apps/desktop/src/main/browser-agent/cdp.test.ts

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -501,18 +501,25 @@ describe('browser-agent screenshot capture', () => {
501501
return Promise.resolve(undefined)
502502
})
503503
const resized = {
504+
getSize: vi.fn(() => ({ width: 1024, height: 512 })),
504505
toJPEG: vi.fn(() => Buffer.from('resized')),
505506
}
507+
const cropped = {
508+
getSize: vi.fn(() => ({ width: 400, height: 200 })),
509+
resize: vi.fn(() => resized),
510+
toJPEG: vi.fn(() => Buffer.from('cropped')),
511+
}
506512
// Shared module-level mock: without this, a later fixture reads the
507513
// earlier test's decoded image.
508514
vi.mocked(nativeImage.createFromBuffer).mockReset()
509515
vi.mocked(nativeImage.createFromBuffer).mockReturnValue({
510516
isEmpty: vi.fn(() => imageSize === null),
511517
getSize: vi.fn(() => imageSize ?? { width: 0, height: 0 }),
518+
crop: vi.fn(() => cropped),
512519
resize: vi.fn(() => resized),
513520
toJPEG: vi.fn(() => Buffer.alloc(0)),
514521
} as unknown as ReturnType<typeof nativeImage.createFromBuffer>)
515-
return { contents, resized }
522+
return { contents, resized, cropped }
516523
}
517524

518525
function screenshotParams(contents: WebContents): Record<string, unknown> {
@@ -531,6 +538,23 @@ describe('browser-agent screenshot capture', () => {
531538
expect(screenshotParams(contents)).not.toHaveProperty('clip')
532539
})
533540

541+
it('crops the decoded image in memory without sending a CDP clip', async () => {
542+
const { contents, cropped } = captureFixture({ width: 4096, height: 2048 })
543+
544+
const shot = await captureScreenshot(contents, { x: 100, y: 50, width: 200, height: 100 })
545+
546+
const image = vi.mocked(nativeImage.createFromBuffer).mock.results[0].value
547+
expect(screenshotParams(contents)).not.toHaveProperty('clip')
548+
expect(image.crop).toHaveBeenCalledWith({ x: 200, y: 100, width: 400, height: 200 })
549+
expect(cropped.resize).not.toHaveBeenCalled()
550+
expect(shot).toEqual({
551+
dataUrl: `data:image/jpeg;base64,${Buffer.from('cropped').toString('base64')}`,
552+
scale: 2,
553+
viewport: { width: 2048, height: 1024 },
554+
imageSize: { width: 400, height: 200 },
555+
})
556+
})
557+
534558
/**
535559
* A 2048px CSS viewport bounded to 1024px is scale 0.5, and the capture
536560
* arrives at device resolution (4096px on a 2x display). The resize is what
@@ -597,6 +621,24 @@ describe('browser-agent screenshot capture', () => {
597621
expect(shot.imageSize).toEqual({ width: 1024, height: 512 })
598622
})
599623

624+
it('refuses element cropping without verified CSS viewport metrics', async () => {
625+
const { contents } = captureFixture({ width: 1024, height: 512 })
626+
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {
627+
if (method === 'Page.getLayoutMetrics') {
628+
return Promise.resolve({ layoutViewport: { clientWidth: 2048, clientHeight: 1024 } })
629+
}
630+
return Promise.resolve(undefined)
631+
})
632+
633+
await expect(
634+
captureScreenshot(contents, { x: 10, y: 10, width: 100, height: 50 })
635+
).rejects.toThrow(/CSS viewport/)
636+
expect(contents.debugger.sendCommand).not.toHaveBeenCalledWith(
637+
'Page.captureScreenshot',
638+
expect.anything()
639+
)
640+
})
641+
600642
it('accepts stable finite scroll offsets around the capture', async () => {
601643
const { contents } = captureFixture({ width: 1024, height: 512 })
602644
vi.mocked(contents.debugger.sendCommand).mockImplementation((method: string) => {

apps/desktop/src/main/browser-agent/cdp.ts

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -404,6 +404,13 @@ export interface ScreenshotCapture {
404404
imageSize: ScreenshotSize | null
405405
}
406406

407+
export interface ScreenshotClip {
408+
x: number
409+
y: number
410+
width: number
411+
height: number
412+
}
413+
407414
function screenshotViewportMetrics(
408415
metrics: {
409416
cssLayoutViewport?: CdpViewport
@@ -461,12 +468,13 @@ function sameScreenshotViewport(
461468
* snapshot capture refuses to scale a visible surface for the same reason.
462469
*
463470
* Bounding resolution therefore happens here instead, on the returned image.
464-
* The output keeps the dimensions the clipped capture produced, so `scale`
465-
* still maps image pixels back to CSS pixels for the coordinate tools
466-
* (cssX = imageX / scale) — including on a 2x display, where an unclipped
467-
* capture arrives at device resolution and this is what brings it back down.
471+
* Optional element crops also happen in memory. Convert output coordinates
472+
* with cssX = (clip?.x ?? 0) + imageX / scale, and the equivalent Y formula.
468473
*/
469-
export async function captureScreenshot(contents: WebContents): Promise<ScreenshotCapture> {
474+
export async function captureScreenshot(
475+
contents: WebContents,
476+
clip?: ScreenshotClip
477+
): Promise<ScreenshotCapture> {
470478
const metrics = await send<{
471479
cssLayoutViewport?: CdpViewport
472480
layoutViewport?: CdpViewport
@@ -478,6 +486,9 @@ export async function captureScreenshot(contents: WebContents): Promise<Screensh
478486
const cssWidth = metrics?.cssLayoutViewport?.clientWidth ?? 0
479487
const cssHeight = metrics?.cssLayoutViewport?.clientHeight ?? 0
480488
const cssViewport = cssWidth > 0 && cssHeight > 0 ? { width: cssWidth, height: cssHeight } : null
489+
if (clip && !cssViewport) {
490+
throw new Error('A CSS viewport is required for element screenshot cropping')
491+
}
481492
const scale =
482493
width > 0 && height > 0 ? Math.min(1, MAX_SCREENSHOT_EDGE / Math.max(width, height)) : 1
483494

@@ -499,8 +510,49 @@ export async function captureScreenshot(contents: WebContents): Promise<Screensh
499510
const image = nativeImage.createFromBuffer(Buffer.from(result.data, 'base64'))
500511
const size = image.isEmpty() ? { width: 0, height: 0 } : image.getSize()
501512
if (size.width === 0 || size.height === 0) {
513+
if (clip) throw new Error('The screenshot could not be decoded for element cropping')
502514
return { dataUrl: captured, scale, viewport: cssViewport, imageSize: null }
503515
}
516+
if (clip && cssViewport) {
517+
const xScale = size.width / cssViewport.width
518+
const yScale = size.height / cssViewport.height
519+
const cropX = Math.max(0, Math.floor(clip.x * xScale))
520+
const cropY = Math.max(0, Math.floor(clip.y * yScale))
521+
const cropRight = Math.min(size.width, Math.ceil((clip.x + clip.width) * xScale))
522+
const cropBottom = Math.min(size.height, Math.ceil((clip.y + clip.height) * yScale))
523+
if (cropRight <= cropX || cropBottom <= cropY) {
524+
throw new Error('The requested screenshot element is outside the current viewport')
525+
}
526+
const cropped = image.crop({
527+
x: cropX,
528+
y: cropY,
529+
width: cropRight - cropX,
530+
height: cropBottom - cropY,
531+
})
532+
const croppedSize = cropped.getSize()
533+
if (croppedSize.width === 0 || croppedSize.height === 0) {
534+
throw new Error('The requested screenshot element produced an empty crop')
535+
}
536+
const cropScale = Math.min(
537+
1,
538+
MAX_SCREENSHOT_EDGE / Math.max(croppedSize.width, croppedSize.height)
539+
)
540+
const output =
541+
cropScale < 1
542+
? cropped.resize({
543+
width: Math.round(croppedSize.width * cropScale),
544+
height: Math.round(croppedSize.height * cropScale),
545+
quality: 'good',
546+
})
547+
: cropped
548+
const outputSize = output.getSize()
549+
return {
550+
dataUrl: `data:image/jpeg;base64,${output.toJPEG(SCREENSHOT_QUALITY).toString('base64')}`,
551+
scale: outputSize.width / clip.width,
552+
viewport: cssViewport,
553+
imageSize: outputSize,
554+
}
555+
}
504556
if (size.width === targetWidth && size.height === targetHeight) {
505557
return { dataUrl: captured, scale, viewport: cssViewport, imageSize: size }
506558
}

0 commit comments

Comments
 (0)