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() | |
|---|---|---|
| macOS | the content view | plus the titlebar and window toolbar |
| iOS | the app’s root view | plus the status bar |
| Linux | the content area | plus the GTK HeaderBar (client-side decorations) |
| Android | the activity’s decor content | plus 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, Qt | the window, already including in-window chrome; see below | same image |
| HarmonyOS | the window root node | same 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
| backend | API |
|---|---|
| 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 |
| Qt | QWidget::grab() |
| Windows (XAML) | PrintWindow with PW_RENDERFULLCONTENT, BitBlt from the screen as a fallback |
| Android | View.draw(Canvas) into a Bitmap, Bitmap.compress(PNG) |
| HarmonyOS (ArkUI) | OH_ArkUI_GetNodeSnapshot + the native image packer |
| web-dom | unsupported |
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,
dayreads the xvfb root window with ImageMagick’simport. - 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 --waitandday launchsethide_error_dialogs=1andimmersive_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_dialogsincrates/day-cli/src/mobile.rs). Android checks again afterscreencap, 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: falseon 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_DRIVERbrowser captures the page.
day drive follows the same precedence, so the same screen frames the same way whichever entry
point took the picture.