Window image: capturing the app’s own window

An app can capture its own window as a PNG:

let png: Vec<u8> = day::window_image().capture()?;

The call is synchronous and returns PNG bytes. Nothing is written to disk and no permission is requested; an app photographing its own window is not a screen recording, and none of the nine backends treats it as one.

Pair it with a save picker (files) to let the user keep the result:

button(res::str::screenshot()).action(move || {
    day::task(async move {
        let png = match day::window_image().capture() {
            Ok(bytes) => bytes,
            Err(e) => {
                eprintln!("capture failed: {e}");
                return;
            }
        };
        save_file(png)
            .suggested_name("shot.png")
            .filter("PNG", &["png"])
            .await;
    });
});

The UI snippets use generated res::str accessors from an app’s Fluent catalog; see the example messages and catalog setup.

What lands in the image

By default the capture is the window’s content, what the app itself drew. Ask for chrome() to include the frame the platform draws around it:

day::window_image().chrome().capture()?
content (default).chrome()
macOSthe content viewplus the titlebar and window toolbar
iOSthe app’s root viewplus the status bar
Linuxthe content areaplus the GTK HeaderBar (client-side decorations)
Androidthe activity’s decor contentplus the status bar (same pixel size where the app draws edge-to-edge; the bar’s own pixels appear, the frame does not grow)
Windows, Qtthe window, already including in-window chrome; see belowsame image
HarmonyOSthe window root nodesame image

Two backends cannot separate the two. On Windows the capture is a PrintWindow of the top-level HWND, and on Qt a QWidget::grab of the top-level widget: both already contain everything drawn inside the window, and neither can reach the frame the window manager draws outside it. They answer chrome() with the same image.

Nothing composited on top of the window by the system (a menu that has torn off into its own window, an IME candidate popup, a screen-recording indicator) is part of any capture.

Capability

day::window_image_support() reports whether the running backend can capture at all:

if day::window_image_support() == Support::Native { /* offer the command */ }

It answers Support::Native on eight backends and Support::Unsupported on web-dom, where a DOM cannot rasterize itself. Gate the UI on it (the Showcase’s Screenshot menu item does) rather than offering a command that can only fail.

capture() still returns Err for the ordinary runtime reasons even where support is Native: no window on screen yet, a zero-size window, a compositor that declined.

How each backend captures

backendAPI
macOS (AppKit)CGWindowListCreateImage, cropped to the content view; cacheDisplayInRect into an NSBitmapImageRep when the window server declines
iOS (UIKit)UIGraphicsImageRenderer + drawViewHierarchyInRect:afterScreenUpdates:
macOS (GTK)Window-server image cropped to the GTK widget, including Cocoa child views; GTK widget rendering when the window server declines
Linux (GTK)GtkWidgetPaintable rendered through the window’s own GskRenderer
QtQWidget::grab()
Windows (XAML)PrintWindow with PW_RENDERFULLCONTENT, BitBlt from the screen as a fallback
AndroidView.draw(Canvas) into a Bitmap, Bitmap.compress(PNG); a software canvas ignores outline clipping, so Day’s containers clip their rounded corners by hand there
HarmonyOS (ArkUI)OH_ArkUI_GetNodeSnapshot + the native image packer
web-domunsupported

AppKit and UIKit in-process PNG captures use sRGB. UIKit explicitly selects an sRGB renderer with standard dynamic range, so a wide-gamut display does not change the bytes that sample_pixel compares. This converts the rendered colors; it does not relabel P3 bytes. External simctl screenshots keep their own color profile.

Two of these were the second thing tried:

AppKit prefers the window server. cacheDisplayInRect renders the view hierarchy the app drew and nothing else, so macOS’s composited materials (a Liquid Glass sidebar, vibrancy) come back blank. CGWindowListCreateImage asks the window server for the pixels the user is looking at. It has the opposite limitation: it has no image for a window that is not on screen, so the offscreen render remains the fallback.

ArkUI encodes natively. The obvious route is the ArkTS host (that is how day-arkui reaches the file picker and the browser), but @ohos.multimedia.image has no synchronous packer at all (packToData and packing are Promise/callback only), so bridging through the host would have forced window_image() to be async on every backend to satisfy this one. OH_ArkUI_GetNodeSnapshot and OH_ImagePackerNative_PackToDataFromPixelmap do the same work synchronously in-process, so the API stays sync everywhere. It costs two extra linked libraries (libpixelmap.so, libimage_packer.so), linked by the image kit’s ohos-sys bindings (day-arkui’s images module).

Relationship to dayscript screenshots

A dayscript screenshot: step (agent, DESIGN.md §14) is a separate path with a different goal, and it does not call this API directly.

  • Desktop — the in-process capture is the real capture, and it is what a walkthrough writes. The Linux CI legs keep a fallback: when the engine declines, day reads the xvfb root window with ImageMagick’s import.
  • Device and simulator — the platform’s screen capture remains the primary path (simctl io screenshot, adb exec-out screencap, hdc uitest screenCap). It photographs the whole screen, status bar and system chrome included, which is what the published mobile galleries show; an in-process capture frames the app’s view tree alone and would silently re-crop all of them. On an Android emulator the whole screen stays free of system dialogs: day devices boot --wait and day launch set hide_error_dialogs=1 and immersive_mode_confirmations=confirmed, and each capture first closes an ANR dialog, a crash dialog, or the “Viewing full screen” hint left on screen before those settings landed (clear_system_dialogs in crates/day-cli/src/mobile.rs). Android checks again after screencap, before saving any bytes. A failed/empty window probe, surviving error dialog, unsuccessful capture command, or non-PNG output refuses the device capture. Physical devices get the same checks but no automatic dismissal or settings changes. If the app-only fallback also fails, the screenshot step fails and no previous image at its path is retained. Where a mobile backend has an in-process capture it now serves as the fallback; a refusing device tool used to abandon the shot outright. Because that image is wanted only when the device tool refuses, the runner tells the engine not to render one (in_process: false on the step) and re-asks on the failure path: rendering and encoding a capture per shot only to discard it cost 819ms each on the iOS simulator, 33.6s across one walkthrough variant. The idle wait that makes a capture land on a settled frame happens either way, because the wait is the step’s purpose rather than a side effect of the encoding.
  • web-dom — the DAY_WEB_DRIVER browser captures the page.

day drive follows the same precedence, so the same screen frames the same way whichever entry point took the picture.

Screenshot render checkpoints

The dayscript engine flushes reactive work and waits for native transitions before asking Toolkit::prepare_snapshot(host, revision) for a capture checkpoint. The revision belongs to one ordered screenshot request and stays the same across bounded retries. It is not an image hash or a global “everything is idle” flag. Each asynchronous window fence ignores callbacks from superseded requests. Missing windows, render errors and timed-out checkpoints fail the step; they do not authorize a stale capture.

Toolkit / platform targetsFreshness boundary
ArkUI / HarmonyOSEvery ArkTS piece module’s settle (docs/extending.md; the web view waits for four animation frames and any CSS transition in its page, capped), then an ArkTS component snapshot with waitUntilRenderFinished: true; a tiny disposable PixelMap confirms rendering before the full device capture
GTK / Linux, macOS, WindowsSelected window’s frame clock after-paint, following a requested draw
Android / MDCregisterFrameCommitCallback; after-draw fallback for software rendering and Android before API 29
DOM / webTwo animation-frame turns; the browser screenshot operation performs final capture synchronization
Qt / Linux, macOS, WindowsSynchronous QWidget::render / grab in the capture itself
AppKit / macOSLayout, display and Core Animation flush in the window capture
UIKit / iOSIn-process capture uses afterScreenUpdates: true; external simctl capture keeps its existing transition check and capture semantics
XAML / Windows (XAML and WinUI)Shared CompositionTarget/DwmFlush checkpoint; WinUI also selects a fresh WGC frame

A host that paints outside the toolkit’s frame on every platform — an offscreen engine presented through a Day canvas, whose frames arrive after an asynchronous read-back and decode — is invisible to all of these checkpoints: they see a committed frame, not that the host’s latest frame was in it. Such a host registers a capture hold (day_spec::capture::register_hold, a fn() -> bool read on the UI thread) answering whether every frame it owes is on screen; screenshot and sample_pixel wait while any hold says no, before requesting the checkpoint, with the same 16 ms polling and the step’s deadline. A hold is a statement about frames in flight, not about page content: a continuously animating host keeps a capture waiting until the step times out, which is the failure such a script should see. The Servo adapter of day-piece-webview is the first host to register one.

Ready acknowledges a platform render checkpoint; it does not claim that a vsync callback proves physical display presentation. OnCapture delegates freshness to a synchronous native capture and does not authorize skipping waits for an unrelated external capture. Pending native checkpoints are polled between main-loop turns, with a 16 ms retry interval and the step’s normal deadline. This does not wait for perpetual animations, video, or arbitrary network requests: scripts still assert the application state they need and retain deliberate pause steps.

The reply’s optional capture_revision lets a current runner omit Harmony’s fixed screenshot delay. Older apps retain DAY_OHOS_SHOT_SETTLE_MS (4 seconds by default), with a compatibility message. Equal consecutive screenshots are valid and are never used as a stale-frame test. Both app and CLI must be rebuilt to use the new protocol. Capture framing and the existing app-only fallback remain unchanged. Runs report script elapsed time and split screenshot time between engine/checkpoints (including in-process encoding) and external capture/save work.