Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 17 additions & 1 deletion .github/workflows/build-natives.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,11 @@ jobs:
shell: cmd
run: call system-color\src\main\native\windows\build.bat

- name: Build screen-capture Windows DLLs
if: steps.natives-cache.outputs.cache-hit != 'true'
shell: cmd
run: call screen-capture\src\main\native\windows\build.bat

- name: Build energy-manager Windows DLLs
if: steps.natives-cache.outputs.cache-hit != 'true'
shell: cmd
Expand Down Expand Up @@ -132,6 +137,7 @@ jobs:
"darkmode-detector/nucleus_windows_theme.dll"
"native-ssl/nucleus_ssl.dll"
"system-color/nucleus_systemcolor.dll"
"screen-capture/nucleus_screencapture.dll"
"energy-manager/nucleus_energy_manager.dll"
"taskbar-progress/nucleus_taskbar_progress.dll"
"notification-windows/nucleus_notification_windows.dll"
Expand Down Expand Up @@ -204,6 +210,10 @@ jobs:
if: steps.natives-cache.outputs.cache-hit != 'true'
run: bash system-color/src/main/native/macos/build.sh

- name: Build screen-capture macOS dylibs
if: steps.natives-cache.outputs.cache-hit != 'true'
run: bash screen-capture/src/main/native/macos/build.sh

- name: Build energy-manager macOS dylibs
if: steps.natives-cache.outputs.cache-hit != 'true'
run: bash energy-manager/src/main/native/macos/build.sh
Expand Down Expand Up @@ -277,6 +287,7 @@ jobs:
"darkmode-detector/libnucleus_darkmode.dylib"
"native-ssl/libnucleus_ssl.dylib"
"system-color/libnucleus_systemcolor.dylib"
"screen-capture/libnucleus_screencapture.dylib"
"energy-manager/libnucleus_energy_manager.dylib"
"taskbar-progress/libnucleus_taskbar_progress.dylib"
"notification-macos/libnucleus_notification.dylib"
Expand Down Expand Up @@ -358,7 +369,7 @@ jobs:

- name: Install build dependencies
if: steps.natives-cache.outputs.cache-hit != 'true'
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev libx11-dev libglib2.0-dev libgtk-3-dev
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev libx11-dev libxrandr-dev libxfixes-dev libxcomposite-dev libglib2.0-dev libgtk-3-dev

- name: Build darkmode-detector Linux native shared library
if: steps.natives-cache.outputs.cache-hit != 'true'
Expand All @@ -376,6 +387,10 @@ jobs:
if: steps.natives-cache.outputs.cache-hit != 'true'
run: bash system-color/src/main/native/linux/build.sh

- name: Build screen-capture Linux native shared library
if: steps.natives-cache.outputs.cache-hit != 'true'
run: bash screen-capture/src/main/native/linux/build.sh

- name: Build energy-manager Linux native shared library
if: steps.natives-cache.outputs.cache-hit != 'true'
run: bash energy-manager/src/main/native/linux/build.sh
Expand Down Expand Up @@ -434,6 +449,7 @@ jobs:
"linux-hidpi/libnucleus_linux_hidpi_jni.so"
"spellcheck/libnucleus_spellcheck.so"
"system-color/libnucleus_systemcolor.so"
"screen-capture/libnucleus_screencapture.so"
"energy-manager/libnucleus_energy_manager.so"
"notification-linux/libnucleus_notification_linux.so"
"launcher-linux/libnucleus_launcher_linux.so"
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/pre-merge.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,12 @@ jobs:
"system-color/src/main/resources/nucleus/native/darwin-x64/libnucleus_systemcolor.dylib"
"system-color/src/main/resources/nucleus/native/win32-x64/nucleus_systemcolor.dll"
"system-color/src/main/resources/nucleus/native/win32-aarch64/nucleus_systemcolor.dll"
"screen-capture/src/main/resources/nucleus/native/linux-x64/libnucleus_screencapture.so"
"screen-capture/src/main/resources/nucleus/native/linux-aarch64/libnucleus_screencapture.so"
"screen-capture/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_screencapture.dylib"
"screen-capture/src/main/resources/nucleus/native/darwin-x64/libnucleus_screencapture.dylib"
"screen-capture/src/main/resources/nucleus/native/win32-x64/nucleus_screencapture.dll"
"screen-capture/src/main/resources/nucleus/native/win32-aarch64/nucleus_screencapture.dll"
"energy-manager/src/main/resources/nucleus/native/win32-x64/nucleus_energy_manager.dll"
"energy-manager/src/main/resources/nucleus/native/win32-aarch64/nucleus_energy_manager.dll"
"energy-manager/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_energy_manager.dylib"
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/publish-maven.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ jobs:
"system-color/src/main/resources/nucleus/native/darwin-x64/libnucleus_systemcolor.dylib"
"system-color/src/main/resources/nucleus/native/win32-x64/nucleus_systemcolor.dll"
"system-color/src/main/resources/nucleus/native/win32-aarch64/nucleus_systemcolor.dll"
"screen-capture/src/main/resources/nucleus/native/linux-x64/libnucleus_screencapture.so"
"screen-capture/src/main/resources/nucleus/native/linux-aarch64/libnucleus_screencapture.so"
"screen-capture/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_screencapture.dylib"
"screen-capture/src/main/resources/nucleus/native/darwin-x64/libnucleus_screencapture.dylib"
"screen-capture/src/main/resources/nucleus/native/win32-x64/nucleus_screencapture.dll"
"screen-capture/src/main/resources/nucleus/native/win32-aarch64/nucleus_screencapture.dll"
"energy-manager/src/main/resources/nucleus/native/win32-x64/nucleus_energy_manager.dll"
"energy-manager/src/main/resources/nucleus/native/win32-aarch64/nucleus_energy_manager.dll"
"energy-manager/src/main/resources/nucleus/native/darwin-aarch64/libnucleus_energy_manager.dylib"
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Published releases are `2.5.x` (latest tag `v2.5.0`). Do not treat `IDEAL_API.md
- `darkmode-detector` - Reactive OS dark mode detection via JNI
- `system-color` - Reactive system accent color and high contrast detection via JNI
- `system-info` - CPU, memory, GPU, temperature, network, processes
- `screen-capture` - AWT-free screen capture in physical pixels: `ScreenCapture.displays()` / `captureDisplay(display, region, includeCursor)` / `captureWindow(id)` / permission. Windows: GDI `BitBlt` in a per-monitor-v2 thread DPI context; windows through `PrintWindow(PW_RENDERFULLCONTENT)` on a **worker thread with a 2 s timeout** (it sends `WM_PRINT` and waits forever on a thread that does not pump — including an app's own UI thread blocked on the capturing thread), falling back to the visible part from the screen; the window's own thread prints inline. macOS: ScreenCaptureKit (14+) / `CGDisplayCreateImageForRect` via `dlsym` (obsoleted in the 15 SDK), never pumps the main run loop. Linux: X11 (`XGetImage`, RandR monitors, XFixes cursor, XComposite for covered windows; everything `dlopen`ed, X errors trapped per call) or, on Wayland, `org.freedesktop.portal.Screenshot` (whole desktop, cropped; the portal file is deleted). E2E: `scripts/screen-capture-windows-e2e.ps1` (`examples/screen-capture-demo` self-test: pixel-exact pattern, occluded / minimized / blocked-UI / hung-foreign windows, torture + GDI/handle leak sampling) and `scripts/screen-capture-linux-e2e.sh` (Xvfb depths 24/16/8 vs `xwd`, fake portal)
- `energy-manager` - Energy efficiency & screen-awake APIs
- `autolaunch` - Start at login (Win32/MSIX/SMAppService/systemd/Flatpak portal)
- `scheduler` / `scheduler-testing` - OS-scheduled background tasks (Task Scheduler / launchd / systemd) + test doubles
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ Each module is published independently to Maven Central — use them together or
| `nucleus.darkmode-detector` | Reactive OS dark mode detection |
| `nucleus.system-color` | Reactive accent color & high contrast detection |
| `nucleus.system-info` | CPU, memory, GPU (NVIDIA/AMD/Intel), temperature, network, processes |
| `nucleus.screen-capture` | Native screen & window capture in physical pixels, no AWT (GDI / ScreenCaptureKit / X11 / xdg-desktop-portal) |
| `nucleus.decorated-window-tao` | Windowing backend (Rust `tao`, no AWT) |
| `nucleus.decorated-window-core` | Shared window types, layout, chrome (design-system agnostic) |
| `nucleus.decorated-window-jewel` | Jewel (IntelliJ theme) integration |
Expand Down
42 changes: 42 additions & 0 deletions examples/screen-capture-demo/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget

// Demo and E2E fixture of screen-capture: lists the displays, captures a display, a region or
// the demo's own window, and previews the result. With SCREEN_CAPTURE_DEMO_SELFTEST=1 it draws
// a pixel-exact target pattern and checks every capture path against it, then exits with the
// number of failed checks (scripts/screen-capture-windows-e2e.ps1).

plugins {
kotlin("jvm")
alias(libs.plugins.kotlinComposePlugin)
alias(libs.plugins.jetbrainsCompose)
id("dev.nucleusframework")
}

dependencies {
implementation(compose.desktop.currentOs)
implementation(project(":core-runtime"))
implementation(project(":screen-capture"))
implementation(project(":decorated-window-tao"))
implementation(project(":nucleus-application"))
implementation(libs.coroutines.core)
}

java {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}

kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}

nucleus.application {
mainClass = "screencapturedemo.MainKt"

nativeDistributions {
packageName = "ScreenCaptureDemo"
packageVersion = "1.0.0"
}
}
201 changes: 201 additions & 0 deletions examples/screen-capture-demo/src/main/kotlin/screencapturedemo/Main.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
package screencapturedemo

import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.rememberWindowState
import dev.nucleusframework.application.DecoratedWindow
import dev.nucleusframework.application.nucleusApplication
import dev.nucleusframework.core.runtime.Platform
import dev.nucleusframework.screencapture.CaptureRegion
import dev.nucleusframework.screencapture.ScreenCapture
import dev.nucleusframework.screencapture.ScreenCaptureException
import dev.nucleusframework.screencapture.ScreenImage
import dev.nucleusframework.window.NucleusDecoratedWindowTheme
import dev.nucleusframework.window.TitleBar
import dev.nucleusframework.window.tao.LocalTaoWindow
import dev.nucleusframework.window.tao.TaoWindow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jetbrains.skia.ColorAlphaType
import org.jetbrains.skia.ColorType
import org.jetbrains.skia.ImageInfo
import java.io.File
import kotlin.system.exitProcess

private val selfTest = System.getenv("SCREEN_CAPTURE_DEMO_SELFTEST") == "1"

fun main(args: Array<String>) =
nucleusApplication(args, enableSingleInstance = false) {
var coverVisible by remember { mutableStateOf(false) }
NucleusDecoratedWindowTheme(isDark = true) {
DecoratedWindow(
onCloseRequest = ::exitApplication,
title = "Screen Capture Demo",
// Self-test: nothing else may cover the pattern (the cover window, shown later, goes above).
alwaysOnTop = selfTest,
state = rememberWindowState(size = DpSize(760.dp, 620.dp), position = WindowPosition(120.dp, 120.dp)),
) {
TitleBar { BasicText("Screen Capture Demo", style = TextStyle(color = Color.White)) }
val window = LocalTaoWindow.current
if (window != null && selfTest) {
LaunchedEffect(window) {
val test =
SelfTest(
window = window,
windowId = windowIdOf(window),
showCover = { visible -> withContext(Dispatchers.Main) { coverVisible = visible } },
setMinimized = { minimized ->
withContext(Dispatchers.Main) { window.setMinimized(minimized) }
},
)
val failures = withContext(Dispatchers.IO) { test.run() }
exitProcess(failures.coerceAtMost(100))
}
}
DemoContent(window)
}
if (coverVisible) {
// Covers the demo window entirely, above it, for the occluded-window check.
DecoratedWindow(
onCloseRequest = { coverVisible = false },
title = "Cover",
alwaysOnTop = true,
focusable = false,
state = rememberWindowState(size = DpSize(860.dp, 720.dp), position = WindowPosition(70.dp, 70.dp)),
) {
Box(Modifier.fillMaxSize().background(Color(0xFFFF00FF)))
}
}
}
}

/** The id [ScreenCapture.captureWindow] takes for a Tao window; `0` where the demo has none. */
private fun windowIdOf(window: TaoWindow): Long =
when (Platform.Current) {
Platform.Windows -> window.nativeHandle
Platform.Linux -> window.x11WindowId ?: 0L
else -> 0L
}

@Composable
private fun DemoContent(window: TaoWindow?) {
val scope = rememberCoroutineScope()
var preview by remember { mutableStateOf<ImageBitmap?>(null) }
var last by remember { mutableStateOf<ScreenImage?>(null) }
var status by remember {
mutableStateOf("backend=${ScreenCapture.backend} permission=${ScreenCapture.permissionStatus()}")
}
val displays = remember { runCatching { ScreenCapture.displays() }.getOrDefault(emptyList()) }

fun capture(
label: String,
block: () -> ScreenImage,
) {
scope.launch {
status = "Capturing $label…"
val started = System.nanoTime()
val outcome = withContext(Dispatchers.IO) { runCatching(block) }
val ms = (System.nanoTime() - started) / 1_000_000
outcome
.onSuccess {
last = it
preview = it.toImageBitmap()
status = "$label: ${it.width}x${it.height} in ${ms}ms"
}.onFailure {
val reason = if (it is ScreenCaptureException) "${it.failure} — ${it.message}" else "$it"
status = "$label failed: $reason"
}
}
}

Column(
Modifier.fillMaxSize().background(Color(0xFF202124)).padding(12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
TargetPattern.Content()
FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
for (display in displays) {
DemoButton("${display.name} (${display.widthPx}x${display.heightPx})") {
capture(display.name) { ScreenCapture.captureDisplay(display, includeCursor = true) }
}
}
displays.firstOrNull()?.let { primary ->
DemoButton("Region 400x300") {
capture("region") { ScreenCapture.captureDisplay(primary, CaptureRegion(0, 0, 400, 300)) }
}
}
val id = window?.let(::windowIdOf) ?: 0L
if (id != 0L && ScreenCapture.isWindowCaptureSupported) {
DemoButton("This window") { capture("window") { ScreenCapture.captureWindow(id) } }
}
last?.let { image ->
DemoButton("Save PNG") {
scope.launch {
val file =
File(
System.getProperty("java.io.tmpdir"),
"screen-capture-${System.currentTimeMillis()}.png",
)
withContext(Dispatchers.IO) { file.writeBytes(image.toPng()) }
status = "Saved ${file.absolutePath}"
}
}
}
}
BasicText(status, style = TextStyle(color = Color.White, fontSize = 13.sp))
Box(Modifier.fillMaxWidth().weight(1f).border(1.dp, Color.Gray)) {
preview?.let {
Image(it, contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Fit)
}
}
}
}

@Composable
private fun DemoButton(
label: String,
onClick: () -> Unit,
) {
Box(
Modifier
.background(
Color(0xFF3C4043),
).clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 6.dp),
) {
BasicText(label, style = TextStyle(color = Color.White, fontSize = 13.sp))
}
}

private fun ScreenImage.toImageBitmap(): ImageBitmap =
org.jetbrains.skia.Image
.makeRaster(ImageInfo(width, height, ColorType.BGRA_8888, ColorAlphaType.OPAQUE), toBgraBytes(), width * 4)
.toComposeImageBitmap()
Loading
Loading