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(tr("screenshot")).action(|| 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;
}));

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:
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)
HarmonyOS (ArkUI)OH_ArkUI_GetNodeSnapshot + the native image packer
web-domunsupported

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); see day-arkui-sys’s build.rs.

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.