Skip to content

Take screenshots on the web beta, and of cloud strategies anywhere - #229

Merged
SunkenInTime merged 8 commits into
icarus-cloudfrom
web-screenshot
Sep 29, 2026
Merged

SunkenInTime merged 8 commits into
icarus-cloudfrom
web-screenshot

Conversation

@SunkenInTime

@SunkenInTime SunkenInTime commented Sep 29, 2026 •

Copy link
Copy Markdown
Owner

Gjorgji asked on Discord for record and screenshot on the web. On the web beta the Screenshot button only said "Screenshot is desktop-only for now." That gate was a line in PlatformPolicy.webBeta, set when the beta shell landed (#183). Nobody had checked whether screenshots could work in a browser. Two things did stop it:

  • It read the strategy back from the local Hive box. After a forced save, the capture looked the strategy up in strategiesBox, and cloud strategies are never there. newStrat == null → return then made the button do nothing, silently, on desktop too for every cloud strategy.
  • It wrote the PNG with dart:io, which a browser can't do.

Now the capture reads the page as it is on the canvas, and the PNG goes out through FilePicker.saveFile(bytes:). On desktop that writes the chosen file; in a browser it downloads it.

A web screenshot of a cloud page, with its cloud image painted
Taken by the real capture code in headless Edge (dart2js build), from a cloud strategy whose image is served by URL. The magenta block is that image.

What changed

  • lib/screenshot/page_screenshot.dart: captureEditorPage builds the page from the editor's live providers.
    • Everything mutable is cloned before the first await (agents, abilities, text, images, utilities, lineup graph; drawings go through JSON). An edit made while an image downloads can't reach half the picture, and the capture's screenshot-space paths never touch the editor's strokes.
    • The page name comes from wherever the strategy lives.
  • lib/screenshot/capture_images.dart: the capture renders in its own ProviderContainer, which has no live cloud page, upload queue or pending bytes. So resolveCaptureImages works out every image before rendering starts:
    • Files and in-memory bytes stay as they are.
    • Cloud URLs are fetched with downloadCloudImageBytes, now shared with the desktop media cache, including its one signed-URL refresh and Let anyone with a strategy link view it without an account #225's share token.
    • Images the editor shows as unavailable stay unavailable.
    • Every paintable image is decoded, and stays listened to (so it stays among the image cache's live images) until the capture releases it. Each download times out after 60 s.
    • An image still loading, a failed fetch, or bytes that don't decode stop the capture with a message, instead of saving a PNG with a hole in it.
  • captureImageSourcesProvider (strategy_image_source.dart) is how the capture container gets those sources. watchStrategyImageSource checks it first; everywhere else it is null, so the editor behaves exactly as before. PendingImageBytes becomes ImageBytes, since bytes fetched for a capture aren't an upload.
  • createCaptureContainer (offscreen_capture.dart) builds the capture's container with an inert, signed-out auth provider. Found in the browser run: the capture's strategy provider listens to auth, so the capture built its own real AuthProvider. That provider cleared or replaced auth on the app's one Convex client, and disposing it tore the auth down. Every screenshot showed "Cloud connection lost" and paused sync. A test records every call a capture makes to the Convex auth API and requires none; before the fix it caught clearAuth.
  • Screenshot leaves PlatformPolicy.webBeta.desktopOnly. Local-strategy screenshots no longer force a save first, because nothing reads the saved copy any more.

Reproduction

Web beta, a cloud strategy open, click the camera. Expected: a PNG downloads. Actual: "Screenshot is desktop-only for now." Desktop, a cloud strategy: nothing happens.

Verification

  • Flutter: the full suite passes. New tests:
    • test/page_screenshot_test.dart: a cloud page with a web-style image fetched from its URL renders to a 1920x1080 PNG with the image's pixels in it; loading, failed-fetch and undecodable images stop the capture; edits after the snapshot don't reach it; the toolbar spinner clears after a failure.
    • test/capture_images_test.dart: the image resolution, and that decoded images are held until released.
  • Real browser: a throwaway harness ran captureEditorPage for a cloud strategy in headless Edge. It produced a 1920x1080 PNG in 1.5 to 1.8 s with the cloud image painted (image above).
  • Web build: flutter build web with CI's flags compiles.
  • Real app: Codex drove a local web build (dev backend, Discord sign-in) in Chrome. The camera downloaded a 1920x1080 PNG with the page's agent, stroke and cloud image. That run is what surfaced the session bug above; a re-run on the fixed build is in the thread.
  • Review: Codex Astra did three static passes. It found and I fixed: shared live objects, images not proven decodable, images not pinned under cache pressure, stalled downloads, and a double listener removal.

Not covered

🤖 Generated with Claude Code

RetriggerConfidence Score: 4/5

Not safe to merge until refreshed cloud-image downloads can complete.

Findings

  1. P1 Keep image client open ▶

Summary

The PR adds screenshots for the web beta and cloud strategies. When a cloud image needs a refreshed signed URL, its download can fail because the shared HTTP client has already closed. Video export in PR #230 uses the same image-resolution path. This must be fixed before merging.

Reviews (3) · Last reviewed commit: "Abort a capture's other downloads when i..."

SunkenInTime and others added 3 commits September 29, 2026 01:08
The screenshot button read the open strategy back from the local Hive
box after a forced save. Cloud strategies are never in that box, so a
screenshot of one silently did nothing, on desktop as well as web. It
then wrote the PNG with dart:io, which a browser cannot do, which is why
the web beta hid the button behind "desktop-only".

The capture now reads the page as it is on the canvas (the editor's live
providers, drawings copied so their cached paths stay the editor's), so
local and cloud strategies capture the same way and nothing waits on a
save or a sync. The PNG goes out through FilePicker.saveFile(bytes:),
which writes the chosen file on desktop and downloads it in a browser.

Images: the capture renders in its own provider container, which has no
live cloud page, upload queue or pending bytes. Before it starts, every
image on the page is resolved to what it will paint and handed in through
captureImageSourcesProvider: files and in-memory bytes as they are, cloud
URLs fetched to bytes (downloadCloudImageBytes, shared with the desktop
media cache, including its one signed-URL refresh), and images the editor
shows as unavailable stay unavailable. An image still loading or a fetch
that fails stops the capture with a message instead of saving a picture
with the image missing. PendingImageBytes becomes ImageBytes, since bytes
fetched for a capture are not an upload.

Screenshot leaves PlatformPolicy.webBeta.desktopOnly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review follow-up. A capture awaits image downloads before it renders, and
the editor keeps editing its own objects in place meanwhile, so the page
snapshot now clones agents, abilities, text, images and utilities (and
deep-copies the lineup graph) instead of sharing them; an edit made while
a slow image downloads can no longer land in half the picture.

Fetched bytes were also never proven to be an image: a 200 with truncated
bytes, or a decode slower than the 800 ms settle, would have saved a PNG
with the picture missing. resolveCaptureImages now decodes every image
that paints into the image cache and holds it there (keepAlive) until the
capture releases it, so the capture's widgets find each picture already
decoded, and a decode failure stops the capture with the same message as
a failed fetch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A signed-out reader who opened a strategy link (#225) can take a
screenshot too. Their only access to its images is that link, so the
capture's signed-URL refresh carries the link's token, as the media cache
does.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Sep 29, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 15231b92-0b3d-404e-a246-64862cbb8785

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

SunkenInTime and others added 3 commits September 29, 2026 01:34
Review follow-up. A keepAlive handle keeps a decoded image's completer
alive, but once the last listener goes the image cache stops tracking it
as live, so with enough large images an earlier one could be evicted and
decoded again mid-capture, which is the missing-image timing the decode
step exists to prevent. Each image now stays listened to until the
capture releases it, which keeps it among the cache's live images.

A stalled image request could also hold a capture forever: each download
now times out after 60 s and fails the capture like any failed fetch.
resolveCaptureImages takes a checkpoint, run before each image, so a
caller can stop the work between images.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Review follow-up: an image whose later frame failed to decode let go of
its stream in the error handler, and release() then removed the listener
again, which throws once the stream is disposed and would have stopped
the cleanup of the images after it. Letting go is now idempotent.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Found in the browser run: each screenshot was followed by "Cloud
connection lost". A capture renders in its own provider container, and
hydrating its strategy provider builds that container's own auth
provider, since the strategy provider listens to auth. A real auth
provider configures the one Convex client the app syncs through: built
with no session it clears that client's auth, with one it replaces the
editor's token fetcher, and disposing it tears the auth down. Either way
the editor's session ends.

createCaptureContainer now builds every capture's container with an
inert signed-out auth provider (nothing a capture paints needs auth) and
the capture's image sources. A test records every call a capture makes
to the Convex auth API and requires none; before this change it saw
clearAuth.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread lib/screenshot/page_screenshot.dart
Comment thread lib/widgets/editor_toolbar.dart
Comment thread lib/screenshot/capture_images.dart
@greptile-apps

greptile-apps Bot commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Comments Outside Diff

These findings could not be posted inline.

  • P1 Shared HTTP client closes before a signed-URL retry ▶

    • Bug
      • A cloud-image capture fails when it needs a refreshed signed URL after another request completes.
    • Cause
      • http.get closes the client supplied by runWithClient after each request, although the capture intends to share it across requests.
    • Fix
      • Make requests directly through the capture-scoped client and close it when capture resolution ends.

Greptile follow-ups. A page's cloud images downloaded one after another,
so the wait was the sum of them; they now start together and decode in
order, with the same checkpoints and cleanup, and an image still loading
stops the capture before any download starts. And the screenshot guard
cleared before the save dialog closed, so a second click could capture
again and open a second dialog; the guard now holds until the save is
done. Both have tests; the second fails without the fix.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment thread lib/screenshot/capture_images.dart
Greptile follow-up: when one image failed or the capture was cancelled,
the downloads already started for the others kept running, and a retry
started them again alongside. A capture's downloads now share one HTTP
client that is closed when resolution ends, which aborts whatever is
still in flight (BrowserClient and IOClient both abort on close). A test
fails one image while another waits and requires the client closed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Comment on lines +79 to +82
imageId: http.runWithClient(
() => _guard(() => fetch(imageId, url)),
() => client,
)..ignore(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Keep image client open

When a cloud image’s signed URL has expired, the downloader requests a fresh URL. The first http.get closes the client shared here, so the retry fails even when the fresh URL is valid. The screenshot cannot be saved; video export in PR #230 uses the same image-resolution path. Keep the capture-scoped client open until all requests finish.

Artifacts

Command output from the check

  • The log includes the exact executed before command and its output, showing a successful refreshed-URL capture.

Command output from the check

  • The log includes the exact executed after command and its output, showing the closed-client failure.

Evidence from the check

  • The authored test exercises both capture implementations against the same concurrent-request and signed-URL retry scenario.

Evidence from the check

  • The parent-commit source supplies the pre-change implementation exercised by the test.

View artifacts

T-Rex Ran code and verified through T-Rex

@SunkenInTime
SunkenInTime merged commit 5424d76 into icarus-cloud Sep 29, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant