diff --git a/.github/workflows/extended-native-image.yml b/.github/workflows/extended-native-image.yml index 621e86daab..207693457a 100644 --- a/.github/workflows/extended-native-image.yml +++ b/.github/workflows/extended-native-image.yml @@ -23,7 +23,7 @@ permissions: env: # thisisthepy/compose-multiplatform-core-extended, the commit the window modules and # extended/skiko come from. - CORE_EXTENDED_REF: 57b4a0e102d837ba3271699297b69278fcefedef + CORE_EXTENDED_REF: feb1e4c595debbe28a015cdd11a30f4713068bf4 # The plugin as a local development build, asking for the core fork's Compose at its own # version. The core fork's desktop Compose is built once from CORE_EXTENDED_REF, by the # core-compose job (extended/ci/core-compose), and handed to the jobs that need it. @@ -178,7 +178,7 @@ jobs: shell: bash working-directory: core-extended/extended/window run: | - perl -0pi -e 's/^ platforms: \[ jvm, macosArm64 \]$/ platforms: [ jvm ]/m' scene/module.yaml + perl -0pi -e 's/^ platforms: \[.*\]$/ platforms: [ jvm ]/m' scene/module.yaml grep -q '^ platforms: \[ jvm \]$' scene/module.yaml perl -0pi -e 's/^ platforms: \[.*\]$/ platforms: [ jvm ]/m' common/module.yaml grep -q '^ platforms: \[ jvm \]$' common/module.yaml @@ -224,11 +224,8 @@ jobs: - name: linux-arm64 os: ubuntu-24.04-arm nik: vm-openjdk25.0.4.1+2-25.0.4.1+1-linux-aarch64 - # Kotlin/Native packaging cells go here. Each one runs check-native-config.sh (already - # scans extended/window/native) and, after linking, `nm` on the executable for - # Objective-C or class-name lookups; see the K/N section of the plugin change list. - # Kotlin/Native packaging cells go here. The K/N window agent adds them: one per - # platform, building the native window modules into an executable and starting it. + # The Kotlin/Native cells are the kn-* jobs below: their build is the core fork's + # toolchain, not packageNativeImage. runs-on: ${{ matrix.os }} timeout-minutes: 240 env: @@ -304,7 +301,7 @@ jobs: shell: bash working-directory: core-extended/extended/window run: | - perl -0pi -e 's/^ platforms: \[ jvm, macosArm64 \]$/ platforms: [ jvm ]/m' scene/module.yaml + perl -0pi -e 's/^ platforms: \[.*\]$/ platforms: [ jvm ]/m' scene/module.yaml grep -q '^ platforms: \[ jvm \]$' scene/module.yaml perl -0pi -e 's/^ platforms: \[.*\]$/ platforms: [ jvm ]/m' common/module.yaml grep -q '^ platforms: \[ jvm \]$' common/module.yaml @@ -445,6 +442,19 @@ jobs: else bash extended/native-image/check-one-executable.sh "$out" "$RUNNER_TEMP/one-executable" fi + # The window chrome and a screen reader, on the same executable: openbox drags its caption + # and every edge, double clicks to maximise, and libatspi walks its AT-SPI tree for the + # field, its label and the button, sets the field and presses the button and Close. + - name: Window chrome and a reader (openbox and at-spi2, under a virtual display) + if: runner.os == 'Linux' + shell: bash + run: | + set -euo pipefail + sudo apt-get install -y -q --no-install-recommends openbox xdotool x11-utils dbus at-spi2-core libatspi2.0-dev + out="$(find "$SAMPLE_AWTFREE/build/compose/native-image" -mindepth 1 -maxdepth 1 -type d | head -1)" + exe="$(find "$out" -mindepth 1 -maxdepth 1 -type f | head -1)" + bash extended/native-image/check-linux-chrome.sh "$exe" core-extended "$RUNNER_TEMP/chrome" + # Said here rather than left out: an application built from source with this plugin needs a # JDK on the machine that builds it, because Gradle, the Kotlin compiler and native-image # all run on one. Needing nothing but the platform's linker holds only where the renderer @@ -473,3 +483,169 @@ jobs: extended/native-image/*/build/tmp/packageNativeImage/**/*.log extended/native-image/*/build/reports/ if-no-files-found: ignore + + # The same application as a Kotlin/Native executable for Linux: the content of hello-awtfree + # hosted by the core fork's scene in its Kotlin/Native X11 window (native/linux), built by the + # core fork's own toolchain as a module of its window project (extended/native-desktop/ + # hello-awtfree says why). Kotlin/Native has no arm64 Linux host, so both architectures are + # built here on x86-64; the arm64 executable is linked against the arm64 desktop libraries + # and run on an arm64 runner by kn-linux-arm64. + kn-build: + name: Build (Kotlin/Native, linux-x64 and linux-arm64) + runs-on: ubuntu-24.04 + timeout-minutes: 240 + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: | + extended/native-desktop/hello-awtfree + extended/native-image/hello-awtfree/src/main/kotlin + extended/ci + - uses: actions/checkout@v4 + with: + repository: thisisthepy/compose-multiplatform-core-extended + ref: ${{ env.CORE_EXTENDED_REF }} + path: core-extended + sparse-checkout: extended/window + - name: Make room for the Compose build + run: | + sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/.ghcup /usr/local/share/boost \ + /opt/hostedtoolcache/CodeQL /usr/share/swift /usr/local/share/powershell \ + /usr/local/lib/node_modules /usr/local/julia* /opt/microsoft /opt/google + df -h / + - name: The X11, GL, fontconfig and FreeType development libraries + run: | + sudo apt-get update -q + sudo apt-get install -y -q pkg-config libx11-dev libxext-dev libgl1-mesa-dev \ + libfontconfig1-dev libfreetype6-dev + # The arm64 executable is linked here, so the arm64 libraries it names have to be here, + # where native/linux's x11.def looks for them (/usr/lib/aarch64-linux-gnu). + - name: The arm64 desktop libraries to link against + run: | + set -euo pipefail + sudo dpkg --add-architecture arm64 + for sources in /etc/apt/sources.list.d/*.sources; do + sudo sed -i '/^Types: deb$/a Architectures: amd64' "$sources" + done + [[ ! -f /etc/apt/sources.list ]] || sudo sed -i 's/^deb \(http\)/deb [arch=amd64] \1/' /etc/apt/sources.list + printf '%s\n' 'Types: deb' 'URIs: http://ports.ubuntu.com/ubuntu-ports' \ + 'Suites: noble noble-updates noble-security' 'Components: main universe' \ + 'Architectures: arm64' | sudo tee /etc/apt/sources.list.d/arm64-ports.sources + sudo apt-get update -q + sudo apt-get install -y -q libx11-dev:arm64 libxext-dev:arm64 libgl-dev:arm64 libegl-dev:arm64 \ + libfontconfig-dev:arm64 libfreetype-dev:arm64 + # Both architectures, because native/linux declares both and the toolchain resolves every + # platform a module declares. + - name: The core fork's Compose for Kotlin/Native Linux + uses: ./extended/ci/core-compose + with: + ref: ${{ env.CORE_EXTENDED_REF }} + targets: linuxX64 linuxArm64 + - name: Cache the Kotlin toolchain + uses: actions/cache@v4 + with: + path: | + ~/.konan + ~/.cache/JetBrains/Kotlin + key: kn-hello-linux-${{ env.CORE_EXTENDED_REF }} + restore-keys: kn-hello-linux- + # The sample becomes a module of the window project, with its links followed. The scene + # declares the JVM and macOS as well, whose Compose is not built here, so it is built as + # the Linux module it is for this executable; the sample is built for one architecture + # at a time. + - name: The sample as a module of the core fork's window project + shell: bash + working-directory: core-extended/extended/window + run: | + set -euo pipefail + mkdir -p samples + cp -RL "$GITHUB_WORKSPACE/extended/native-desktop/hello-awtfree" samples/hello-awtfree + ls -la samples/hello-awtfree/src + perl -0pi -e 's/^ platforms: \[.*\]$/ platforms: [ linuxX64, linuxArm64 ]/m' scene/module.yaml + printf 'modules:\n - common\n - scene\n - native/linux\n - native/linux-scene\n - samples/hello-awtfree\n' > project.yaml + cat project.yaml + - name: Build the executables + shell: bash + working-directory: core-extended/extended/window + run: | + set -euo pipefail + for cell in linux-x64:linuxX64 linux-arm64:linuxArm64; do + name="${cell%%:*}"; platform="${cell#*:}" + perl -0pi -e "s/^ platforms: \\[.*\\]\$/ platforms: [ $platform ]/m" samples/hello-awtfree/module.yaml + grep -n '^ platforms:' samples/hello-awtfree/module.yaml + rm -rf build/tasks/*hello-awtfree* + ./kotlin build -m hello-awtfree -p "$platform" + exe="$(find build -type f -name '*.kexe' -path '*hello-awtfree*' | head -1)" + [[ -n "$exe" ]] || { echo "no executable under build/:" >&2; find build -name '*.kexe' >&2; exit 1; } + mkdir -p "$RUNNER_TEMP/kn-out-$name/hello-awtfree" + cp "$exe" "$RUNNER_TEMP/kn-out-$name/hello-awtfree/hello-awtfree" + file "$RUNNER_TEMP/kn-out-$name/hello-awtfree/hello-awtfree" + done + - uses: actions/upload-artifact@v4 + with: + name: kn-hello-awtfree-linux-x64 + path: ${{ runner.temp }}/kn-out-linux-x64 + retention-days: 3 + - uses: actions/upload-artifact@v4 + with: + name: kn-hello-awtfree-linux-arm64 + path: ${{ runner.temp }}/kn-out-linux-arm64 + retention-days: 3 + + # The gate the GraalVM cells run, on the Kotlin/Native executable, on its own architecture: + # one file, only the system's libraries, Korean laid out with no ICU data file, the Compose + # content presented at the window's size; then the window chrome and a screen reader. + kn-run: + needs: kn-build + name: One executable (Kotlin/Native, ${{ matrix.name }}) + strategy: + fail-fast: false + matrix: + include: + - { name: linux-x64, os: ubuntu-24.04 } + - { name: linux-arm64, os: ubuntu-24.04-arm } + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + with: + sparse-checkout: extended/native-image + - uses: actions/checkout@v4 + with: + repository: thisisthepy/compose-multiplatform-core-extended + ref: ${{ env.CORE_EXTENDED_REF }} + path: core-extended + sparse-checkout: extended/window/scripts + - uses: actions/download-artifact@v4 + with: + name: kn-hello-awtfree-${{ matrix.name }} + path: ${{ runner.temp }}/kn-out + - name: Linux packages + run: | + sudo apt-get update -q + sudo apt-get install -y -q --no-install-recommends xvfb xauth libgl1-mesa-dri libglx-mesa0 libegl1 \ + libx11-6 libxext6 libfontconfig1 libfreetype6 fonts-noto-cjk binutils gdb build-essential pkg-config \ + openbox xdotool x11-utils dbus at-spi2-core libatspi2.0-dev + chmod +x "$RUNNER_TEMP/kn-out/hello-awtfree/hello-awtfree" + - name: Take every ICU data file off the runner + shell: bash + run: | + { find /usr /opt /Library /Applications /home /Users /snap -name 'icudtl.dat' 2>/dev/null || true; } | + while IFS= read -r file; do echo "removing $file"; sudo rm -f "$file"; done + - name: One file, system libraries only, Korean with no ICU data, run alone + shell: bash + run: | + xvfb-run -a -s '-screen 0 1280x1024x24' bash extended/native-image/check-one-executable.sh \ + "$RUNNER_TEMP/kn-out/hello-awtfree" "$RUNNER_TEMP/one-executable" + - name: Window chrome and a reader (openbox and at-spi2, under a virtual display) + shell: bash + run: | + bash extended/native-image/check-linux-chrome.sh "$RUNNER_TEMP/kn-out/hello-awtfree/hello-awtfree" \ + core-extended "$RUNNER_TEMP/chrome" + - name: Keep the self-check drawing + if: always() + uses: actions/upload-artifact@v4 + with: + name: one-executable-kn-${{ matrix.name }} + path: ${{ runner.temp }}/one-executable/*.png + if-no-files-found: ignore diff --git a/extended/README.md b/extended/README.md index 6274055bea..f13fbf5fb0 100644 --- a/extended/README.md +++ b/extended/README.md @@ -343,8 +343,8 @@ The plugin is published under this fork's own name, so that it can sit beside Je `-ext.-dev` for a local build. The probes and samples use `1.11.1-ext.1-dev`. - the aliases (`compose.desktop.currentOs`, `compose.foundation`, `compose.material3` and the rest) resolve to compose-multiplatform-core-extended's `org.thisisthepy.compose.*` libraries at - `-Pcompose.version` (`1.11.1-ext.1`) and `-Pcompose.material3.version` - (`1.11.0-alpha07-ext.1`). `compose("org.jetbrains.compose.ui:ui")` is turned into the fork's + `-Pcompose.version` and `-Pcompose.material3.version` (`1.11.1-ext.1`). + `compose("org.jetbrains.compose.ui:ui")` is turned into the fork's coordinate too. - the components and Compose HTML stay JetBrains', at the upstream version (`1.11.1`), because the core fork does not build them. diff --git a/extended/README_ko.md b/extended/README_ko.md index be2527855a..7f54a326af 100644 --- a/extended/README_ko.md +++ b/extended/README_ko.md @@ -257,7 +257,7 @@ compose.desktop.application { 로컬 빌드는 `-ext.-dev` 이고, probe 와 예제는 `1.11.1-ext.1-dev` 를 씁니다. - 별칭(`compose.desktop.currentOs`, `compose.foundation`, `compose.material3` 등)은 compose-multiplatform-core-extended 의 `org.thisisthepy.compose.*` 라이브러리를 `-Pcompose.version` - (`1.11.1-ext.1`)과 `-Pcompose.material3.version`(`1.11.0-alpha07-ext.1`)으로 가리킵니다. + 과 `-Pcompose.material3.version`(`1.11.1-ext.1`)으로 가리킵니다. `compose("org.jetbrains.compose.ui:ui")` 도 포크의 좌표로 바뀝니다. - components 와 Compose HTML 은 JetBrains 것을 업스트림 버전(`1.11.1`)으로 씁니다. core 포크가 빌드하지 않기 때문입니다. diff --git a/extended/native-desktop/hello-awtfree/module.yaml b/extended/native-desktop/hello-awtfree/module.yaml new file mode 100644 index 0000000000..6f7ccfff85 --- /dev/null +++ b/extended/native-desktop/hello-awtfree/module.yaml @@ -0,0 +1,76 @@ +# The AWT-free sample as a Kotlin/Native executable for Linux: the same Compose content as +# extended/native-image/hello-awtfree, hosted by the core fork's scene module in its +# Kotlin/Native X11 window (native/linux), with no virtual machine in the file. +# +# An Amper module of the core fork's window project, not a Gradle build of its own: the window +# modules and their Kotlin/Native klibs are built by that project's toolchain, and a klib is +# read only by the compiler that wrote it or a newer one. The "One executable (Kotlin/Native)" +# cells copy this directory into a checkout of the core fork as extended/window/samples/ +# hello-awtfree (src/ follows its links), list it in project.yaml beside common, scene, +# native/linux and native/linux-scene, and build it for one architecture at a time. arm64 is cross compiled on x86-64, +# because Kotlin/Native has no arm64 Linux host, and linked against the arm64 desktop libraries +# native/linux's x11.def points at. +product: + type: linux/app + platforms: [ linuxX64, linuxArm64 ] + +# Compose's compiler plugin through the core fork's template, as the window modules do. +apply: + - ../../compose.module-template.yaml + +repositories: + - id: mavenLocal + url: mavenLocal + +dependencies: + - ../../common + - ../../scene + - ../../native/linux + # x11WindowSurface, where the scene draws on the Kotlin/Native X11 window. + - ../../native/linux-scene + +dependencies@linuxX64: + - org.thisisthepy.compose.runtime:runtime-linuxx64:1.11.1-ext.1 + - org.thisisthepy.compose.runtime:runtime-saveable-linuxx64:1.11.1-ext.1 + - org.thisisthepy.compose.animation:animation-linuxx64:1.11.1-ext.1 + - org.thisisthepy.compose.animation:animation-core-linuxx64:1.11.1-ext.1 + - org.thisisthepy.compose.foundation:foundation-linuxx64:1.11.1-ext.1 + - org.thisisthepy.compose.foundation:foundation-layout-linuxx64:1.11.1-ext.1 + - org.thisisthepy.compose.material:material-ripple-linuxx64:1.11.1-ext.1 + - org.thisisthepy.compose.material3:material3-linuxx64:1.11.1-ext.1 + - org.thisisthepy.compose.ui:ui-linuxx64:1.11.1-ext.1 + - org.thisisthepy.compose.ui:ui-backhandler-linuxx64:1.11.1-ext.1 + - org.thisisthepy.compose.ui:ui-geometry-linuxx64:1.11.1-ext.1 + - org.thisisthepy.compose.ui:ui-graphics-linuxx64:1.11.1-ext.1 + - org.thisisthepy.compose.ui:ui-text-linuxx64:1.11.1-ext.1 + - org.thisisthepy.compose.ui:ui-tooling-preview-linuxx64:1.11.1-ext.1 + - org.thisisthepy.compose.ui:ui-unit-linuxx64:1.11.1-ext.1 + - org.thisisthepy.compose.ui:ui-util-linuxx64:1.11.1-ext.1 + +dependencies@linuxArm64: + - org.thisisthepy.compose.runtime:runtime-linuxarm64:1.11.1-ext.1 + - org.thisisthepy.compose.runtime:runtime-saveable-linuxarm64:1.11.1-ext.1 + - org.thisisthepy.compose.animation:animation-linuxarm64:1.11.1-ext.1 + - org.thisisthepy.compose.animation:animation-core-linuxarm64:1.11.1-ext.1 + - org.thisisthepy.compose.foundation:foundation-linuxarm64:1.11.1-ext.1 + - org.thisisthepy.compose.foundation:foundation-layout-linuxarm64:1.11.1-ext.1 + - org.thisisthepy.compose.material:material-ripple-linuxarm64:1.11.1-ext.1 + - org.thisisthepy.compose.material3:material3-linuxarm64:1.11.1-ext.1 + - org.thisisthepy.compose.ui:ui-linuxarm64:1.11.1-ext.1 + - org.thisisthepy.compose.ui:ui-backhandler-linuxarm64:1.11.1-ext.1 + - org.thisisthepy.compose.ui:ui-geometry-linuxarm64:1.11.1-ext.1 + - org.thisisthepy.compose.ui:ui-graphics-linuxarm64:1.11.1-ext.1 + - org.thisisthepy.compose.ui:ui-text-linuxarm64:1.11.1-ext.1 + - org.thisisthepy.compose.ui:ui-tooling-preview-linuxarm64:1.11.1-ext.1 + - org.thisisthepy.compose.ui:ui-unit-linuxarm64:1.11.1-ext.1 + - org.thisisthepy.compose.ui:ui-util-linuxarm64:1.11.1-ext.1 + +settings: + native: + entryPoint: awtfree.main + kotlin: + freeCompilerArgs: + - -module-name + - org.thisisthepy.compose.samples:hello-awtfree + optIns: + - kotlinx.cinterop.ExperimentalForeignApi diff --git a/extended/native-desktop/hello-awtfree/src/Content.kt b/extended/native-desktop/hello-awtfree/src/Content.kt new file mode 120000 index 0000000000..a373e3da76 --- /dev/null +++ b/extended/native-desktop/hello-awtfree/src/Content.kt @@ -0,0 +1 @@ +../../../native-image/hello-awtfree/src/main/kotlin/Content.kt \ No newline at end of file diff --git a/extended/native-desktop/hello-awtfree/src/TextSelfCheck.kt b/extended/native-desktop/hello-awtfree/src/TextSelfCheck.kt new file mode 120000 index 0000000000..20a27b2b86 --- /dev/null +++ b/extended/native-desktop/hello-awtfree/src/TextSelfCheck.kt @@ -0,0 +1 @@ +../../../native-image/hello-awtfree/src/main/kotlin/TextSelfCheck.kt \ No newline at end of file diff --git a/extended/native-desktop/hello-awtfree/src/main.kt b/extended/native-desktop/hello-awtfree/src/main.kt new file mode 100644 index 0000000000..568b23cc95 --- /dev/null +++ b/extended/native-desktop/hello-awtfree/src/main.kt @@ -0,0 +1,119 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package awtfree + +import kotlin.system.exitProcess +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.alloc +import kotlinx.cinterop.convert +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.toKString +import kotlinx.cinterop.usePinned +import org.jetbrains.skia.Bitmap +import org.jetbrains.skia.EncodedImageFormat +import org.jetbrains.skia.Surface +import org.thisisthepy.compose.window.WindowConfig +import org.thisisthepy.compose.window.linux.X11Window +import org.thisisthepy.compose.window.linux.x11WindowSurface +import org.thisisthepy.compose.window.scene.ComposeWindowHost +import platform.posix.CLOCK_MONOTONIC +import platform.posix.clock_gettime +import platform.posix.fclose +import platform.posix.fopen +import platform.posix.fwrite +import platform.posix.getenv +import platform.posix.timespec + +/** + * The GraalVM AWT-free sample's application as a Kotlin/Native executable: the same Compose + * content (Content.kt, linked from extended/native-image/hello-awtfree) hosted by the core + * fork's scene module in its Kotlin/Native X11 window, with no virtual machine in the file. + * + * It answers the gate the GraalVM cells run, in the same words: + * - with HELLO_SELF_CHECK naming a PNG: the Korean text check, the sample text drawn into the + * PNG, 30 frames of the Compose content presented, then exit, 0 only when all of it held; + * - with HELLO_RUN_SECONDS: it stays up that long or until the window is closed, for the Linux + * chrome and accessibility check that drives it from outside, then reports the frames it + * presented at the wrong size and the window's resize count. + */ +fun main() { + val check = getenv("HELLO_SELF_CHECK")?.toKString() + var ok = true + if (check != null) ok = textSelfCheck() && drawSample(check) + + val platform = X11Window() + val host = ComposeWindowHost( + platform = platform, + config = WindowConfig("안녕하세요, AwtFree", 520, 360, minWidth = 240, minHeight = 160), + surfaceFor = ::x11WindowSurface, + content = { Content() }, + ) + if (!host.open()) { + println("self-check: the window did not open") + exitProcess(2) + } + val stay = getenv("HELLO_RUN_SECONDS")?.toKString()?.toLongOrNull() + if (check == null && stay == null) { + host.run() + return + } + val until = nowNanos() + (stay ?: 20L) * 1_000_000_000L + try { + while (!host.closed && nowNanos() < until) { + // The self-check draws 30 frames in a row; nothing in the content changes on its + // own, so each turn asks for one. A run that is being driven draws when it is. + if (check != null) { + if (host.presentLog.frames >= 30) break + host.requestFrame() + } + host.turn(16) + } + } finally { + host.close() + } + val log = host.presentLog + println("scene: presented ${log.frames} frames of Compose content, ${log.mismatched} at a size other than the window's") + if (stay != null) println(platform.resizeStats.report()) + if (log.frames == 0 || log.mismatched > 2) ok = false + exitProcess(if (ok) 0 else 1) +} + +private fun nowNanos(): Long = memScoped { + val now = alloc() + clock_gettime(CLOCK_MONOTONIC, now.ptr) + now.tv_sec * 1_000_000_000L + now.tv_nsec +} + +/** Draws the sample text into a raster surface, counts what it inked and writes the PNG. */ +private fun drawSample(png: String): Boolean { + val surface = Surface.makeRasterN32Premul(480, 240) + surface.canvas.clear(org.jetbrains.skia.Color.WHITE) + sampleParagraph(440f).paint(surface.canvas, 20f, 20f) + // Text that laid out but drew nothing would pass the layout check; count what it drew. + val bitmap = Bitmap().apply { allocN32Pixels(480, 240) } + surface.readPixels(bitmap, 0, 0) + var inked = 0 + for (y in 0 until 240) for (x in 0 until 480) if (bitmap.getColor(x, y) != org.jetbrains.skia.Color.WHITE) inked++ + if (inked == 0) { + println("self-check: the sample text drew no pixels") + return false + } + println("self-check: the sample text inked $inked pixels") + val bytes = surface.makeImageSnapshot().encodeToData(EncodedImageFormat.PNG)?.bytes ?: run { + println("self-check: Skia did not encode the PNG") + return false + } + val file = fopen(png, "wb") ?: run { + println("self-check: could not open $png") + return false + } + val written = bytes.usePinned { fwrite(it.addressOf(0), 1.convert(), bytes.size.convert(), file) } + fclose(file) + if (written.toLong() != bytes.size.toLong()) { + println("self-check: wrote $written of ${bytes.size} bytes to $png") + return false + } + println("self-check: drew ${bytes.size} bytes of PNG to $png") + return true +} diff --git a/extended/native-image/README.md b/extended/native-image/README.md index cdc8979215..4415445793 100644 --- a/extended/native-image/README.md +++ b/extended/native-image/README.md @@ -31,7 +31,7 @@ local Maven repository first, as development builds, which is what the probe wor -Pjetbrains.publication.libraries=COMPOSE,COMPOSE_MATERIAL3 -Pextended.publication.dev=true # in gradle-plugins ./gradlew :compose:publishToMavenLocal -Pdeploy.version=1.11.1-ext.1-dev \ - -Pcompose.version=1.11.1-ext.1-dev -Pcompose.material3.version=1.11.0-alpha07-ext.1-dev + -Pcompose.version=1.11.1-ext.1-dev -Pcompose.material3.version=1.11.1-ext.1-dev ``` The agent writes its metadata on a clean exit, so close the application rather than kill @@ -165,6 +165,39 @@ executable of 37.62 MB on macOS arm64, 46.27 MB on Linux x64 and 44.96 MB on Lin only system libraries, and on runners with no `icudtl.dat` each prints `text self-check: ok (no unresolved glyphs, word "반갑습니다", ...)` and inks the sample text. +### The Kotlin/Native cells + +"One executable (Kotlin/Native, linux-x64)" and "(Kotlin/Native, linux-arm64)" run the same gate +on the same application built with Kotlin/Native: `extended/native-desktop/hello-awtfree`, whose +content is this sample's own `Content.kt` and `TextSelfCheck.kt` (linked, not copied), hosted by +the scene in the core fork's Kotlin/Native X11 window (`native/linux`) and drawn through +`native/linux-scene`'s `x11WindowSurface`. It is an Amper module of +the core fork's window project rather than a Gradle build, because a klib is read only by the +compiler that wrote it or a newer one, and the window modules are built by that project's +toolchain. The "Build (Kotlin/Native, ...)" job copies it into a checkout of the core fork and +builds both executables on x86-64, arm64 cross compiled, since Kotlin/Native has no arm64 Linux +host; each is then run on its own architecture. + +### Window chrome and a reader on Linux + +`check-linux-chrome.sh` runs after the gate on every Linux cell, GraalVM and Kotlin/Native. It +starts the application with `HELLO_RUN_SECONDS` on a virtual display and a session bus of its +own, with openbox and at-spi2, and requires: + +- no title bar of the window manager's (`WindowChrome.Modern`, the default): `_NET_FRAME_EXTENTS` + is all zero; +- a drag of the scene's caption moves the window by as far as the pointer went, a drag at each + edge and corner changes the size from that side only, and a double click in the caption + maximises it and a second restores it (the core fork's `extended/window/scripts/wm-chrome.sh`); +- no frame presented at a size the window had already left, and no size the window took left + without a frame (the window's own `dxc resize:` count); +- walking the application's AT-SPI tree with libatspi finds the text field (`entry` "Name"), its + label (`label` "Name"), the button (`push button` "click me") and the caption's three buttons; + setting the field's text and pressing the button reach the application, and pressing Close + closes it (the core fork's `extended/window/scripts/atspi-readback.c`). + +Whether Orca reads the window well, and how a drag feels, are checks by hand on a desktop. + ## Running the Windows probe locally `extended/native-image/windows-probe.sh` is what CI runs, after the two local publications diff --git a/extended/native-image/check-linux-chrome.sh b/extended/native-image/check-linux-chrome.sh new file mode 100755 index 0000000000..27146c1cc1 --- /dev/null +++ b/extended/native-image/check-linux-chrome.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +# Usage: check-linux-chrome.sh +# +# Drives a running AWT-free application on Linux from outside, the way a person and a screen +# reader would, and requires what the window chrome and accessibility promise: +# +# chrome the window has no title bar of the window manager's; a drag of its caption moves +# it by as far as the pointer went; a drag at each edge and corner changes its size +# from that side only; a double click in the caption maximises and restores it; and +# no frame was presented at a size the window had already left, and no size the +# window took was left without a frame (the window's own resize count). +# reader walking the application's AT-SPI tree from the registry finds the text field, its +# label and the button by role and name; setting the field's text and pressing the +# button through AT-SPI reach the application; the caption's own Close button, +# pressed the same way, closes the window. +# +# The window manager is openbox, which honours _NET_WM_MOVERESIZE and the sync counter, and +# the drags are the core fork's extended/window/scripts/wm-chrome.sh, the script its own window +# tests run. The reader is its extended/window/scripts/atspi-readback.c, built here against +# libatspi. Everything runs on a virtual display and a session bus of its own. +# +# The application has to be extended/native-image/hello-awtfree or its Kotlin/Native twin +# under extended/native-desktop/hello-awtfree: they stay up while HELLO_RUN_SECONDS lasts and +# then report their frames and the window's resize count. +# +# Needs xvfb-run, dbus-run-session, openbox, xdotool, xprop, at-spi2-core and libatspi's +# development files. +set -euo pipefail + +if [[ $# -ne 3 ]]; then + echo "usage: $0 " >&2 + exit 2 +fi +executable="$(cd "$(dirname "$1")" && pwd)/$(basename "$1")" +core="$(cd "$2" && pwd)" +mkdir -p "$3" +scratch="$(cd "$3" && pwd)" + +scripts="$core/extended/window/scripts" +[[ -x "$scripts/wm-chrome.sh" ]] || chmod +x "$scripts/wm-chrome.sh" +reader="$scratch/atspi-readback" +cc -O1 -Wall -o "$reader" "$scripts/atspi-readback.c" $(pkg-config --cflags --libs atspi-2 gobject-2.0) + +inner="$scratch/inner.sh" +cat > "$inner" <<'INNER' +#!/usr/bin/env bash +set -euo pipefail +executable="$1"; scripts="$2"; reader="$3"; scratch="$4" +log="$scratch/app.log" +title="안녕하세요, AwtFree" +# The window counts every size it takes and every frame it presents against the size it has +# at that moment, which DXC_REPORT_RESIZE asks of it. +sleep 1 +( cd "$scratch" && DXC_REPORT_RESIZE=1 HELLO_RUN_SECONDS=240 exec "$executable" ) > "$log" 2>&1 & +app=$! +for _ in $(seq 1 50); do + xdotool search --name "AwtFree" >/dev/null 2>&1 && break + sleep 0.1 +done +status=0 +WM_DRAG_STEPS=40 "$scripts/wm-chrome.sh" "AwtFree" modern 46 || status=$? +echo "== wm-chrome.sh exit $status" +read_status=0 +"$reader" "$title" --timeout 60 \ + --expect "entry:Name" --expect "label:Name" --expect "push button:click me" \ + --expect "push button:Minimize" --expect "push button:Maximize" --expect "push button:Close" \ + --set "Name=Ada" --press "click me" --press "Close" || read_status=$? +echo "== atspi-readback exit $read_status" +# Close, pressed through AT-SPI, ends the run; anything still up after that is stopped. +for _ in $(seq 30); do kill -0 "$app" 2>/dev/null || break; sleep 1; done +if kill -0 "$app" 2>/dev/null; then + echo "== the application was still running after Close; stopping it" + kill "$app" 2>/dev/null || true + read_status=1 +fi +app_status=0 +wait "$app" || app_status=$? +echo "== the application's own report (exit $app_status)" +cat "$log" +failed=0 +[[ "$status" -eq 0 ]] || { echo "fail the window chrome is wrong; wm-chrome.sh says how above" >&2; failed=1; } +[[ "$read_status" -eq 0 ]] || { echo "fail a reader did not find or could not use the controls; atspi-readback says how above" >&2; failed=1; } +grep -qE '^scene: presented [1-9][0-9]* frames of Compose content, [0-2] at a size' "$log" || + { echo "fail a frame was presented at a size other than the window's" >&2; failed=1; } +resize="$(grep -E '^dxc resize: ' "$log" | tail -1 || true)" +[[ -n "$resize" ]] || { echo "fail the application did not report the window's resize count" >&2; failed=1; } +if [[ -n "$resize" ]]; then + steps="$(sed -n 's/.*steps=\([0-9]*\).*/\1/p' <<< "$resize")" + stale="$(sed -n 's/.*stale=\([0-9]*\).*/\1/p' <<< "$resize")" + stretched="$(sed -n 's/.*stretched=\([0-9]*\).*/\1/p' <<< "$resize")" + [[ "${steps:-0}" -ge 8 ]] || { echo "fail the edges took the window through ${steps:-0} sizes, fewer than eight" >&2; failed=1; } + [[ "${stale:-0}" -le 2 ]] || { echo "fail $stale frame(s) presented at a size the window had already left" >&2; failed=1; } + [[ "$stretched" == 0 ]] || { echo "fail $stretched size(s) the window took were left without a frame" >&2; failed=1; } +fi +[[ "$app_status" -eq 0 ]] || { echo "fail the application ended with $app_status" >&2; failed=1; } +[[ "$failed" -eq 0 ]] && echo "ok the chrome moves, resizes and maximises, and a reader finds and uses the controls" +exit "$failed" +INNER +chmod +x "$inner" +dbus-run-session -- xvfb-run -a -s '-screen 0 1600x1000x24' "$inner" "$executable" "$scripts" "$reader" "$scratch" diff --git a/extended/native-image/hello-awtfree-link/build.gradle.kts b/extended/native-image/hello-awtfree-link/build.gradle.kts index d3f59bdf99..3cc805c4a0 100644 --- a/extended/native-image/hello-awtfree-link/build.gradle.kts +++ b/extended/native-image/hello-awtfree-link/build.gradle.kts @@ -16,14 +16,20 @@ sourceSets.main { } dependencies { - implementation("org.thisisthepy.compose.window:common:0.1.0") - implementation("org.thisisthepy.compose.window:$windowArtifact:0.1.0") + implementation("org.thisisthepy.compose.window:common:1.11.1-ext.1") + implementation("org.thisisthepy.compose.window:$windowArtifact:1.11.1-ext.1") // skiko is linked into every image this plugin makes; the sample draws nothing with it. implementation(compose.desktop.currentOs) // skiko-awt with the AWT switch, from compose-multiplatform-core-extended's // extended/skiko/build-skiko-awt.sh. Its version sorts above the 0.144.6 Compose asks for, so // it is the one the image takes. - implementation("org.jetbrains.skiko:skiko-awt:0.144.6.1") + implementation("org.thisisthepy.compose.skiko:skiko-awt:1.11.1-ext.1") +} + +configurations.all { + resolutionStrategy.capabilitiesResolution.withCapability("org.jetbrains.skiko:skiko-awt") { + select("org.thisisthepy.compose.skiko:skiko-awt:1.11.1-ext.1") + } } compose.desktop.application { diff --git a/extended/native-image/hello-awtfree/build.gradle.kts b/extended/native-image/hello-awtfree/build.gradle.kts index 304870d7b9..65664739b8 100644 --- a/extended/native-image/hello-awtfree/build.gradle.kts +++ b/extended/native-image/hello-awtfree/build.gradle.kts @@ -18,10 +18,10 @@ sourceSets.main { } dependencies { - implementation("org.thisisthepy.compose.window:common:0.1.0") - implementation("org.thisisthepy.compose.window:$windowArtifact:0.1.0") + implementation("org.thisisthepy.compose.window:common:1.11.1-ext.1") + implementation("org.thisisthepy.compose.window:$windowArtifact:1.11.1-ext.1") // The scene host, and the core fork's foundation the content is written with. - implementation("org.thisisthepy.compose.window:scene:0.1.0") + implementation("org.thisisthepy.compose.window:scene:1.11.1-ext.1") implementation(compose.foundation) // @CFunction, to ask the system which thread main runs on. compileOnly("org.graalvm.sdk:nativeimage:25.0.1") @@ -30,7 +30,13 @@ dependencies { // skiko-awt with the AWT switch, from compose-multiplatform-core-extended's // extended/skiko/build-skiko-awt.sh. Its version sorts above the 0.144.6 Compose asks for, so // it is the one the image takes. - implementation("org.jetbrains.skiko:skiko-awt:0.144.6.1") + implementation("org.thisisthepy.compose.skiko:skiko-awt:1.11.1-ext.1") +} + +configurations.all { + resolutionStrategy.capabilitiesResolution.withCapability("org.jetbrains.skiko:skiko-awt") { + select("org.thisisthepy.compose.skiko:skiko-awt:1.11.1-ext.1") + } } compose.desktop.application { diff --git a/extended/native-image/hello-awtfree/src/linux/kotlin/Platform.kt b/extended/native-image/hello-awtfree/src/linux/kotlin/Platform.kt index ff699a62a6..05c9ac0f1a 100644 --- a/extended/native-image/hello-awtfree/src/linux/kotlin/Platform.kt +++ b/extended/native-image/hello-awtfree/src/linux/kotlin/Platform.kt @@ -11,3 +11,6 @@ internal fun newSurface(platform: WindowPlatform): WindowSurface? = x11WindowSur /** X11 takes calls from any one thread, so where main runs does not matter here. */ internal fun onFirstThread(): Boolean = true + +/** The window's own count of the sizes it took and the frames it presented for them. */ +internal fun resizeReport(platform: WindowPlatform): String = (platform as X11Window).resizeReport() diff --git a/extended/native-image/hello-awtfree/src/macos/kotlin/Platform.kt b/extended/native-image/hello-awtfree/src/macos/kotlin/Platform.kt index 06b6df7d28..150c3a4349 100644 --- a/extended/native-image/hello-awtfree/src/macos/kotlin/Platform.kt +++ b/extended/native-image/hello-awtfree/src/macos/kotlin/Platform.kt @@ -27,3 +27,6 @@ private external fun pthreadMainNp(): Int * forever. */ internal fun onFirstThread(): Boolean = pthreadMainNp() != 0 + +/** The run's resize count, which only the Linux check reads; the macOS window keeps its own in C. */ +internal fun resizeReport(platform: WindowPlatform): String = "resize: counted by the window in C on macOS" diff --git a/extended/native-image/hello-awtfree/src/main/kotlin/Content.kt b/extended/native-image/hello-awtfree/src/main/kotlin/Content.kt new file mode 100644 index 0000000000..b66c778167 --- /dev/null +++ b/extended/native-image/hello-awtfree/src/main/kotlin/Content.kt @@ -0,0 +1,73 @@ +package awtfree + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +// The content both AWT-free samples show: this GraalVM one and the Kotlin/Native one under +// extended/native-desktop/hello-awtfree, which links this same file. The field and the button +// carry the names a screen reader announces them by (the field's own label, "Name", and the +// button's text), which the Linux accessibility check requires. + +/** + * Text, a field with its label, and a control that changes when it is clicked. Text needs a + * font manager, a shaper and a layout pass, the field needs the platform's text input, and the + * control shows that pointer input reaches the scene. + */ +@Composable +fun Content() { + var clicks by remember { mutableStateOf(0) } + var typed by remember { mutableStateOf("") } + Box(Modifier.fillMaxSize().background(Color(0xFF12321A))) { + Column(Modifier.padding(top = 32.dp, start = 24.dp)) { + BasicText("안녕하세요, no AWT here", style = TextStyle(color = Color.White, fontSize = 24.sp)) + BasicText( + "composed by Compose, painted by Skia, shown by a window of our own", + style = TextStyle(color = Color(0xFF9CCC9C), fontSize = 14.sp), + ) + BasicText(FIELD_LABEL, Modifier.padding(top = 16.dp), style = TextStyle(color = Color(0xFF9CCC9C), fontSize = 14.sp)) + BasicTextField( + value = typed, + onValueChange = { typed = it }, + modifier = Modifier.padding(top = 4.dp).size(260.dp, 32.dp).background(Color(0xFF1E4620)) + .semantics { contentDescription = FIELD_LABEL }, + textStyle = TextStyle(color = Color.White, fontSize = 16.sp), + cursorBrush = SolidColor(Color.White), + ) + Box( + Modifier.padding(top = 24.dp).size(220.dp, 56.dp).background(Color(0xFF2E7D32)).clickable { clicks++ }, + ) { + BasicText( + if (clicks == 0) BUTTON_LABEL else "clicked $clicks", + Modifier.padding(16.dp), + style = TextStyle(color = Color.White, fontSize = 18.sp), + ) + } + } + } +} + +/** What the field is called, on the screen and to a reader. */ +const val FIELD_LABEL = "Name" + +/** The button's text before it is first clicked. */ +const val BUTTON_LABEL = "click me" diff --git a/extended/native-image/hello-awtfree/src/main/kotlin/Main.kt b/extended/native-image/hello-awtfree/src/main/kotlin/Main.kt index 084926a0e9..9e555342bf 100644 --- a/extended/native-image/hello-awtfree/src/main/kotlin/Main.kt +++ b/extended/native-image/hello-awtfree/src/main/kotlin/Main.kt @@ -1,25 +1,5 @@ package awtfree -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.text.BasicText -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import java.io.File import kotlin.system.exitProcess import org.jetbrains.skia.Bitmap @@ -28,42 +8,6 @@ import org.jetbrains.skia.Surface import org.thisisthepy.compose.window.WindowConfig import org.thisisthepy.compose.window.scene.ComposeWindowHost -/** - * Text, a field and a control that changes when it is clicked. Text needs a font manager, a - * shaper and a layout pass, the field needs the platform's text input, and the control shows - * that pointer input reaches the scene. - */ -@Composable -fun Content() { - var clicks by remember { mutableStateOf(0) } - var typed by remember { mutableStateOf("") } - Box(Modifier.fillMaxSize().background(Color(0xFF12321A))) { - Column(Modifier.padding(top = 32.dp, start = 24.dp)) { - BasicText("안녕하세요, no AWT here", style = TextStyle(color = Color.White, fontSize = 24.sp)) - BasicText( - "composed by Compose, painted by Skia, shown by a window of our own", - style = TextStyle(color = Color(0xFF9CCC9C), fontSize = 14.sp), - ) - BasicTextField( - value = typed, - onValueChange = { typed = it }, - modifier = Modifier.padding(top = 16.dp).size(260.dp, 32.dp).background(Color(0xFF1E4620)), - textStyle = TextStyle(color = Color.White, fontSize = 16.sp), - cursorBrush = SolidColor(Color.White), - ) - Box( - Modifier.padding(top = 24.dp).size(220.dp, 56.dp).background(Color(0xFF2E7D32)).clickable { clicks++ }, - ) { - BasicText( - if (clicks == 0) "click me" else "clicked $clicks", - Modifier.padding(16.dp), - style = TextStyle(color = Color.White, fontSize = 18.sp), - ) - } - } - } -} - /** * A Compose application with no AWT in it: the window is the core fork's GraalVM window, the * scene is hosted by its scene module, and Skia draws into the window's own surface, Metal on @@ -77,6 +21,11 @@ fun Content() { * 3. the window opens and presents frames of the Compose content, each drawn at the size the * window had when it was presented. * The exit status is 0 only when all three hold. + * + * With HELLO_RUN_SECONDS set it stays up that long, or until the window is closed, for a check + * that drives the window from outside (the Linux chrome and accessibility check), and then says + * how many frames it presented at the wrong size and what the window's resize count was. The + * exit status is 0 only when no frame was presented at a size other than the window's. */ fun main() { val check = System.getenv("HELLO_SELF_CHECK") @@ -89,8 +38,9 @@ fun main() { System.err.println("self-check: main is not on the process's first thread, which the window's calls need") exitProcess(3) } + val platform = newPlatform() val host = ComposeWindowHost( - platform = newPlatform(), + platform = platform, config = WindowConfig("안녕하세요, AwtFree", 520, 360, minWidth = 240, minHeight = 160), surfaceFor = ::newSurface, content = { Content() }, @@ -99,6 +49,19 @@ fun main() { System.err.println("self-check: the window did not open") exitProcess(2) } + val stay = System.getenv("HELLO_RUN_SECONDS")?.toLongOrNull() + if (check == null && stay != null) { + val until = System.nanoTime() + stay * 1_000_000_000L + try { + while (!host.closed && System.nanoTime() < until) host.turn(16) + } finally { + host.close() + } + val log = host.presentLog + println("scene: presented ${log.frames} frames of Compose content, ${log.mismatched} at a size other than the window's") + println(resizeReport(platform)) + exitProcess(if (log.frames > 0 && log.mismatched <= 2) 0 else 1) + } if (check == null) { host.run() return diff --git a/extended/native-image/hello/build.gradle.kts b/extended/native-image/hello/build.gradle.kts index b5052f0b5c..7def8acf8c 100644 --- a/extended/native-image/hello/build.gradle.kts +++ b/extended/native-image/hello/build.gradle.kts @@ -11,7 +11,7 @@ plugins { // (-Pextended.publication.dev=true), which is what the plugin above is built to ask for. dependencies { implementation(compose.desktop.currentOs) - implementation("org.thisisthepy.compose.material3:material3:1.11.0-alpha07-ext.1-dev") + implementation("org.thisisthepy.compose.material3:material3:1.11.1-ext.1-dev") } compose.desktop.application { diff --git a/extended/samples/hello-jvm/build.gradle.kts b/extended/samples/hello-jvm/build.gradle.kts index 6f5ce02cb6..964041eb7e 100644 --- a/extended/samples/hello-jvm/build.gradle.kts +++ b/extended/samples/hello-jvm/build.gradle.kts @@ -9,7 +9,7 @@ plugins { dependencies { implementation(compose.desktop.currentOs) - implementation("org.thisisthepy.compose.material3:material3:1.11.0-alpha07-ext.1") + implementation("org.thisisthepy.compose.material3:material3:1.11.1-ext.1") } compose.desktop.application { diff --git a/gradle-plugins/gradle.properties b/gradle-plugins/gradle.properties index b771a010a1..f5ec85acb4 100644 --- a/gradle-plugins/gradle.properties +++ b/gradle-plugins/gradle.properties @@ -12,7 +12,7 @@ dev.junit.parallel=false # published as org.thisisthepy.compose.* at -ext.. A plugin that should # resolve a local development build of those libraries passes -Pcompose.version=...-ext.-dev. compose.version=1.11.1-ext.1 -compose.material3.version=1.11.0-alpha07-ext.1 +compose.material3.version=1.11.1-ext.1 # The latest version of Kotlin compatible with compose.tests.compiler.version. Used only in tests/CI. compose.tests.kotlin.version=2.4.20 # __SUPPORTED_GRADLE_VERSIONS__