diff --git a/.github/workflows/extended-native-image.yml b/.github/workflows/extended-native-image.yml index 207693457a8..f9f97bf1821 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: feb1e4c595debbe28a015cdd11a30f4713068bf4 + CORE_EXTENDED_REF: fd733c8395fb24d107752a56d6893f486e46a62a # 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. @@ -128,7 +128,7 @@ jobs: - name: Plugin to mavenLocal working-directory: gradle-plugins - run: ./gradlew --no-daemon -q :compose:publishToMavenLocal :jdk-version-probe:publishToMavenLocal -Pdeploy.version=${{ env.PLUGIN_VERSION }} -Pcompose.version=${{ env.COMPOSE_VERSION }} + run: ./gradlew --no-daemon -q :compose:publishToMavenLocal :jdk-version-probe:publishToMavenLocal -Pdeploy.version=${{ env.PLUGIN_VERSION }} -Pcompose.version=${{ env.COMPOSE_VERSION }} -Pcompose.material3.version=${{ env.COMPOSE_VERSION }} - name: createDistributable working-directory: extended/samples/hello-jvm run: ./gradlew --no-daemon createDistributable @@ -224,8 +224,7 @@ jobs: - name: linux-arm64 os: ubuntu-24.04-arm nik: vm-openjdk25.0.4.1+2-25.0.4.1+1-linux-aarch64 - # The Kotlin/Native cells are the kn-* jobs below: their build is the core fork's - # toolchain, not packageNativeImage. + # The Kotlin/Native cells are the package-kn and kn-* jobs below. runs-on: ${{ matrix.os }} timeout-minutes: 240 env: @@ -343,7 +342,7 @@ jobs: - name: Plugin to mavenLocal working-directory: gradle-plugins - run: ./gradlew --no-daemon -q :compose:publishToMavenLocal :jdk-version-probe:publishToMavenLocal -Pdeploy.version=${{ env.PLUGIN_VERSION }} -Pcompose.version=${{ env.COMPOSE_VERSION }} + run: ./gradlew --no-daemon -q :compose:publishToMavenLocal :jdk-version-probe:publishToMavenLocal -Pdeploy.version=${{ env.PLUGIN_VERSION }} -Pcompose.version=${{ env.COMPOSE_VERSION }} -Pcompose.material3.version=${{ env.COMPOSE_VERSION }} # The AwtFree sample: packageNativeImage with the no-JAWT archive and the window layer. - name: packageNativeImage (AwtFree sample) @@ -484,6 +483,102 @@ jobs: extended/native-image/*/build/reports/ if-no-files-found: ignore + # The same gate for a Kotlin/Native executable: hello-awtfree's content in the core fork's + # Kotlin/Native window (native-macos), linked by Gradle with the native desktop plugin against + # the core fork's macosArm64 Compose, then checked as the GraalVM images are. Linux cells join + # this matrix with the Linux window; Windows comes later. + package-kn: + name: One executable (Kotlin/Native, ${{ matrix.name }}) + strategy: + fail-fast: false + matrix: + include: + - name: macos-arm64 + os: macos-26 + runs-on: ${{ matrix.os }} + timeout-minutes: 300 + env: + SAMPLE_KN: extended/native-desktop/hello-awtfree-kn + steps: + - uses: actions/checkout@v4 + + - uses: actions/checkout@v4 + with: + repository: thisisthepy/compose-multiplatform-core-extended + ref: ${{ env.CORE_EXTENDED_REF }} + path: core-extended + sparse-checkout: extended/window + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + # Built once per CORE_EXTENDED_REF and restored from the cache after that. + - name: The core fork's Compose for macosArm64 + uses: ./extended/ci/core-compose + with: + ref: ${{ env.CORE_EXTENDED_REF }} + targets: macosArm64 + + - name: Cache the Kotlin toolchain and konan + uses: actions/cache@v4 + with: + path: | + ~/Library/Caches/JetBrains/Kotlin + ~/.konan + key: kn-window-${{ matrix.name }}-${{ env.CORE_EXTENDED_REF }}-${{ hashFiles('extended/native-desktop/hello-awtfree-kn/*.kts') }} + restore-keys: | + kn-window-${{ matrix.name }}- + + - name: Publish the Kotlin/Native window modules to mavenLocal + shell: bash + working-directory: core-extended/extended/window + run: | + set -euo pipefail + cp project.yaml project.yaml.all + printf 'modules:\n - common\n - native/macos\n' > project.yaml + ./kotlin publish mavenLocal + cp project.yaml.all project.yaml + find ~/.m2/repository/org/thisisthepy/compose/window -name '*.module' | sort + + - name: The native desktop plugin to mavenLocal + working-directory: gradle-plugins + run: ./gradlew --no-daemon --console=plain :kotlin-native-desktop:publishToMavenLocal -Pdeploy.version=0.1.0-extended-dev + + - name: Link the Kotlin/Native sample + working-directory: ${{ env.SAMPLE_KN }} + run: ./gradlew --no-daemon --console=plain linkReleaseExecutableMacosArm64 + + - name: Stage the executable alone + shell: bash + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/kn-out" + cp "$SAMPLE_KN/build/bin/macosArm64/releaseExecutable/hello-awtfree-kn.kexe" "$RUNNER_TEMP/kn-out/hello-awtfree-kn" + otool -l "$RUNNER_TEMP/kn-out/hello-awtfree-kn" | grep -A 2 LC_RPATH | awk '/path /{print $2}' | while read -r rpath; do + install_name_tool -delete_rpath "$rpath" "$RUNNER_TEMP/kn-out/hello-awtfree-kn" + done + ls -la "$RUNNER_TEMP/kn-out" + + - name: Take every ICU data file off the runner + shell: bash + run: | + { find /usr /opt /Library /Applications /Users -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: bash extended/native-image/check-one-executable.sh "$RUNNER_TEMP/kn-out" "$RUNNER_TEMP/one-executable" + + - 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 + # 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/ diff --git a/extended/ci/core-compose/action.yml b/extended/ci/core-compose/action.yml index a66e9c5454c..06f66a6a47b 100644 --- a/extended/ci/core-compose/action.yml +++ b/extended/ci/core-compose/action.yml @@ -17,12 +17,26 @@ inputs: runs: using: composite steps: + # Keyed by what the Compose build reads, not by the commit: the whole tree except extended/ + # and .github/ (the fork's own modules and CI), plus the publishing script, as the core + # fork's own fork-compose action keys it. A commit that only changes the window modules + # then reuses the Compose already built, which for macosArm64 takes hours. Trees only are + # fetched, so working the key out costs seconds. - name: Cache key id: key shell: bash run: | + set -euo pipefail targets="$(echo '${{ inputs.targets }}' | tr ' ' '\n' | sort | paste -sd- -)" - echo "key=core-compose-${{ runner.os }}-${{ runner.arch }}-$targets-${{ inputs.ref }}" >> "$GITHUB_OUTPUT" + probe="$RUNNER_TEMP/core-compose-key" + rm -rf "$probe" + git init -q "$probe" + git -C "$probe" fetch -q --depth=1 --filter=blob:none \ + https://github.com/thisisthepy/compose-multiplatform-core-extended.git '${{ inputs.ref }}' + sources="$(git -C "$probe" ls-tree FETCH_HEAD | awk -F'\t' '$2 != "extended" && $2 != ".github" { print }' | git -C "$probe" hash-object --stdin)" + script="$(git -C "$probe" rev-parse FETCH_HEAD:extended/compose)" + rm -rf "$probe" + echo "key=core-compose-${{ runner.os }}-${{ runner.arch }}-$targets-$sources-$script" >> "$GITHUB_OUTPUT" - name: Restore the core fork's Compose id: restore diff --git a/extended/native-desktop/hello-awtfree-kn/build.gradle.kts b/extended/native-desktop/hello-awtfree-kn/build.gradle.kts new file mode 100644 index 00000000000..59afa01f43f --- /dev/null +++ b/extended/native-desktop/hello-awtfree-kn/build.gradle.kts @@ -0,0 +1,37 @@ +// The AWT-free sample as a Kotlin/Native macOS executable: the same Compose content as +// extended/native-image/hello-awtfree, in the core fork's Kotlin/Native AppKit and Metal window +// (org.thisisthepy.compose.window:native-macos), with no JVM at all. The one-executable gate +// checks it the way it checks the GraalVM images. +// +// ./gradlew linkReleaseExecutableMacosArm64 +// +// Kotlin has to be at least the version the window klibs were compiled with: a klib from a newer +// compiler does not link. +plugins { + kotlin("multiplatform") version "2.4.10" + kotlin("plugin.compose") version "2.4.10" + id("org.thisisthepy.kotlin.native.desktop") version "0.1.0-extended-dev" +} + +// The core fork's Compose and window modules, as compose-multiplatform-core-extended publishes +// them to the local Maven repository. +val compose = "1.11.1-ext.1" +val window = "1.11.1-ext.1" + +kotlin { + macosArm64 { + binaries.executable { + baseName = "hello-awtfree-kn" + entryPoint = "awtfree.main" + } + } + sourceSets.macosMain { + dependencies { + implementation("org.thisisthepy.compose.window:common:$window") + implementation("org.thisisthepy.compose.window:native-macos:$window") + implementation("org.thisisthepy.compose.runtime:runtime:$compose") + implementation("org.thisisthepy.compose.foundation:foundation:$compose") + implementation("org.thisisthepy.compose.ui:ui:$compose") + } + } +} diff --git a/extended/native-desktop/hello-awtfree-kn/gradle.properties b/extended/native-desktop/hello-awtfree-kn/gradle.properties new file mode 100644 index 00000000000..47099b1aff8 --- /dev/null +++ b/extended/native-desktop/hello-awtfree-kn/gradle.properties @@ -0,0 +1,2 @@ +kotlin.code.style=official +org.gradle.jvmargs=-Xmx3g diff --git a/extended/native-desktop/hello-awtfree-kn/gradle/wrapper/gradle-wrapper.jar b/extended/native-desktop/hello-awtfree-kn/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 00000000000..e6441136f3d Binary files /dev/null and b/extended/native-desktop/hello-awtfree-kn/gradle/wrapper/gradle-wrapper.jar differ diff --git a/extended/native-desktop/hello-awtfree-kn/gradle/wrapper/gradle-wrapper.properties b/extended/native-desktop/hello-awtfree-kn/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000000..1a704683a00 --- /dev/null +++ b/extended/native-desktop/hello-awtfree-kn/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/extended/native-desktop/hello-awtfree-kn/gradlew b/extended/native-desktop/hello-awtfree-kn/gradlew new file mode 100755 index 00000000000..1aa94a42690 --- /dev/null +++ b/extended/native-desktop/hello-awtfree-kn/gradlew @@ -0,0 +1,249 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/extended/native-desktop/hello-awtfree-kn/settings.gradle.kts b/extended/native-desktop/hello-awtfree-kn/settings.gradle.kts new file mode 100644 index 00000000000..7e3589802b7 --- /dev/null +++ b/extended/native-desktop/hello-awtfree-kn/settings.gradle.kts @@ -0,0 +1,19 @@ +pluginManagement { + repositories { + // org.thisisthepy.kotlin.native.desktop, published by the workflow from gradle-plugins. + mavenLocal() + gradlePluginPortal() + mavenCentral() + google() + } +} +dependencyResolutionManagement { + repositories { + // The core fork's Compose for macosArm64 and its window modules, published by the + // workflow from compose-multiplatform-core-extended. + mavenLocal() + mavenCentral() + google() + } +} +rootProject.name = "hello-awtfree-kn" diff --git a/extended/native-desktop/hello-awtfree-kn/src/macosMain/kotlin/Content.kt b/extended/native-desktop/hello-awtfree-kn/src/macosMain/kotlin/Content.kt new file mode 120000 index 00000000000..ce134cc3cbd --- /dev/null +++ b/extended/native-desktop/hello-awtfree-kn/src/macosMain/kotlin/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-kn/src/macosMain/kotlin/Main.kt b/extended/native-desktop/hello-awtfree-kn/src/macosMain/kotlin/Main.kt new file mode 100644 index 00000000000..bcfaa047aed --- /dev/null +++ b/extended/native-desktop/hello-awtfree-kn/src/macosMain/kotlin/Main.kt @@ -0,0 +1,156 @@ +@file:OptIn(kotlinx.cinterop.ExperimentalForeignApi::class) + +package awtfree + +import kotlinx.cinterop.addressOf +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.MacosAx +import org.thisisthepy.compose.window.TitleBarStyle +import org.thisisthepy.compose.window.WindowConfig +import org.thisisthepy.compose.window.WindowEvent +import org.thisisthepy.compose.window.WindowListener +import org.thisisthepy.compose.window.closeButtonInset +import org.thisisthepy.compose.window.formatMacosAxNode +import org.thisisthepy.compose.window.macos.MacosWindowPlatform +import org.thisisthepy.compose.window.macos.axApiProbe +import org.thisisthepy.compose.window.macosAccessibleContentFindings +import org.thisisthepy.compose.window.macosChromeFindings +import org.thisisthepy.compose.window.window +import platform.AppKit.NSApplication +import platform.AppKit.NSApplicationActivationPolicy +import platform.Foundation.NSData +import platform.Foundation.NSProcessInfo +import platform.Foundation.create +import platform.Foundation.writeToFile +import platform.posix.getenv +import kotlin.system.exitProcess + +/** + * The AWT-free sample in the Kotlin/Native window. With HELLO_SELF_CHECK naming a PNG it proves, + * with nobody watching, what hello-awtfree proves in the GraalVM window: + * 1. Korean is shaped, broken into words and wrapped correctly by what is linked in, with no + * ICU data file on the machine; + * 2. the sample text inks pixels, drawn into a raster surface and written to the PNG; + * 3. the window opens and presents frames of the Compose content, each drawn at the size the + * window had when it was presented; + * 4. the title bar is the one the configuration asked for, and a screen reader finds the field + * and the button by their names. + * The exit status is 0 only when all four hold. HELLO_TITLE_BAR=Simple opens the window with + * the plain title bar. + */ +fun main() { + val check = getenv("HELLO_SELF_CHECK")?.toKString() + var ok = true + if (check != null) { + ok = textSelfCheck() && drawSample(check) + } + val application = NSApplication.sharedApplication() + // An executable outside a bundle is not, by default, something the system puts in front + // or gives the keyboard. + application.setActivationPolicy(NSApplicationActivationPolicy.NSApplicationActivationPolicyRegular) + val titleBar = if (getenv("HELLO_TITLE_BAR")?.toKString() == "Simple") TitleBarStyle.Simple else TitleBarStyle.Normal + val config = WindowConfig("안녕하세요, AwtFree", 520, 360, minWidth = 240, minHeight = 160, titleBar = titleBar) + val platform = MacosWindowPlatform { Content() } + val listener = object : WindowListener { + override fun onEvent(event: WindowEvent) {} + } + if (!platform.open(config, listener)) { + println("self-check: the window did not open") + exitProcess(2) + } + application.activateIgnoringOtherApps(true) + if (check == null) { + application.run() + return + } + val started = NSProcessInfo.processInfo.systemUptime + fun presented() = platform.resizeStats?.presented ?: 0L + // A frame is drawn when something changed, and nothing changes in this content on its own, + // so each turn asks for one: 30 frames drawn and presented in a row. + while (presented() < 30 && NSProcessInfo.processInfo.systemUptime - started < 20.0) { + platform.requestFrame() + platform.pump(16) + } + val stats = platform.resizeStats + println("scene: presented ${stats?.presented ?: 0} frames of Compose content, ${stats?.stale ?: 0} at a size other than the window's") + if ((stats?.presented ?: 0) == 0L || (stats?.stale ?: 0) != 0L) ok = false + if (!readerChecks(platform, config)) ok = false + exitProcess(if (ok) 0 else 1) +} + +/** + * The title bar and what a screen reader is told, asked in process: an outside client needs an + * accessibility trust a CI runner has not given. What the system's API answers an outside + * client is printed with its error number. + */ +private fun readerChecks(platform: MacosWindowPlatform, config: WindowConfig): Boolean { + // Asking is what tells the window a reader is listening; it describes itself from the next + // frame on. + platform.accessibilitySnapshot() + repeat(20) { + platform.requestFrame() + platform.pump(16) + } + val snapshot = platform.accessibilitySnapshot() + val caption = platform.caption() + val style = config.titleBar + var ok = true + val chrome = macosChromeFindings(snapshot, config.title, platform.macosTitleBar, caption) + println("chrome: $style caption $caption, close button inset ${snapshot.closeButtonInset(config.title)} pt, macOS ${snapshot.macosMajor}") + if (chrome.isEmpty()) { + println("chrome: ok ($style)") + } else { + ok = false + chrome.forEach { println("chrome: fail: $it") } + } + val content = macosAccessibleContentFindings( + snapshot, + config.title, + listOf(MacosAx.TEXT_FIELD to FIELD_LABEL, MacosAx.BUTTON to BUTTON_LABEL), + ) + if (content.isEmpty()) { + println("accessibility: ok (a text field '$FIELD_LABEL' and a button '$BUTTON_LABEL')") + } else { + ok = false + content.forEach { println("accessibility: fail: $it") } + } + println("accessibility: ${axApiProbe().describe()}") + if (!ok) { + println("accessibility: what the window told a reader:") + snapshot.window(config.title).forEach { println(" ${formatMacosAxNode(it)}") } + } + return ok +} + +/** 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) + 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 written = bytes.usePinned { + NSData.create(bytes = it.addressOf(0), length = bytes.size.toULong()).writeToFile(png, atomically = true) + } + if (!written) { + println("self-check: the PNG could not be written to $png") + return false + } + println("self-check: drew ${bytes.size} bytes of PNG to $png") + return true +} diff --git a/extended/native-desktop/hello-awtfree-kn/src/macosMain/kotlin/TextSelfCheck.kt b/extended/native-desktop/hello-awtfree-kn/src/macosMain/kotlin/TextSelfCheck.kt new file mode 120000 index 00000000000..c672265666f --- /dev/null +++ b/extended/native-desktop/hello-awtfree-kn/src/macosMain/kotlin/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-image/README.md b/extended/native-image/README.md index 44154457936..195d7b9e69b 100644 --- a/extended/native-image/README.md +++ b/extended/native-image/README.md @@ -159,6 +159,24 @@ for Gradle, the Kotlin compiler and native-image, and the gate says so in a step An executable that needs an ICU data file shows it there: a double click would select one syllable, and lines would wrap inside words. +On macOS the gate also checks the window as a screen reader meets it, and runs the executable +twice, with `HELLO_TITLE_BAR` unset (the Normal title bar) and set to `Simple`. Each run walks +its own accessibility elements in process, through the objects AppKit's accessibility server +answers every client from, because a hosted runner has not trusted any process for +accessibility and an outside client is refused there. The run prints what the system's API +answered an outside client, with its error number (`kAXErrorAPIDisabled`, -25211, on a runner +that has not granted the trust). It fails unless the three system buttons are there, the content +runs under the title bar, no title text is drawn, the caption's height and button room match the +buttons' own frames, the corner radius is the core fork's measured table for that style and +release, and the field and the button are found by their roles and names. On macOS 26 the +Normal title bar's buttons must sit further in than the Simple one's. + +The "One executable (Kotlin/Native, )" jobs run the same gate on +`extended/native-desktop/hello-awtfree-kn`: the same content (`hello-awtfree/src/shared`) in the +core fork's Kotlin/Native window (`native-macos`), linked by Gradle with the native desktop +plugin against the core fork's `macosArm64` Compose, with no JVM. The macOS cell is there; the +Linux cells come with the Linux window, and Windows after it. + Measured by the gate, run 39028163166 (2026-10-17, NIK 25.0.4.1), before `hello-awtfree` hosted a Compose scene: it is one executable of 37.62 MB on macOS arm64, 46.27 MB on Linux x64 and 44.96 MB on Linux arm64, naming diff --git a/extended/native-image/check-one-executable.sh b/extended/native-image/check-one-executable.sh index 3d86c078ea4..a67e862322a 100755 --- a/extended/native-image/check-one-executable.sh +++ b/extended/native-image/check-one-executable.sh @@ -235,4 +235,34 @@ done <<< "$accessed" [[ -z "$found" ]] || fail "running the executable looked for a Java runtime or a library of its own" "$found" echo "-- run watched ($( [[ "$os" == macos ]] && echo "every library loaded" || echo "every file accessed")): $(wc -l <<< "$accessed" | tr -d ' ') paths, none in a Java runtime" +# 6. On macOS, the title bar and what a screen reader is told, for both title bar styles: the +# report above is the Normal one, and the executable runs once more with the Simple title bar. +if [[ "$os" == macos ]]; then + grep -q '^chrome: ok (Normal)' "$log" || + fail "the Normal title bar is not the one the configuration asked for" "Its own report is above." + grep -q '^accessibility: ok' "$log" || + fail "a screen reader does not find the field and the button by their names" "Its own report is above." + simple="$scratch/run-simple" + simple_log="$scratch/run-simple.log" + mkdir -p "$simple" + cp "$binary" "$simple/$name" + simple_status=0 + ( cd "$simple" && HELLO_TITLE_BAR=Simple HELLO_SELF_CHECK="$scratch/self-check-simple.png" \ + perl -e 'alarm 120; exec @ARGV' "./$name" ) > "$simple_log" 2>&1 || simple_status=$? + echo "== the application's own report, with the Simple title bar" + cat "$simple_log" + grep -q '^chrome: ok (Simple)' "$simple_log" || + fail "the Simple title bar is not the one the configuration asked for" "Its own report is above." + [[ "$simple_status" -eq 0 ]] || fail "the Simple run did not end on its own (exit $simple_status)" + inset() { sed -n 's/.*close button inset \([0-9.]*\) pt, macOS \([0-9]*\).*/\1 \2/p' "$1" | head -1; } + read -r normal_inset major <<< "$(inset "$log")" + read -r simple_inset _ <<< "$(inset "$simple_log")" + echo "-- close button inset: Normal ${normal_inset:-?} pt, Simple ${simple_inset:-?} pt, macOS ${major:-?}" + # macOS 26 sets a unified toolbar window's buttons further in; earlier releases do not. + if [[ "${major:-0}" -ge 26 ]]; then + awk -v n="${normal_inset:-0}" -v s="${simple_inset:-0}" 'BEGIN { exit !(n > s) }' || + fail "the Normal title bar's buttons are not set further in than the Simple one's on macOS $major" + fi +fi + echo "ok $name is one executable: system libraries only, Korean laid out with no ICU data file" 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 05c9ac0f1a2..00d900988c2 100644 --- a/extended/native-image/hello-awtfree/src/linux/kotlin/Platform.kt +++ b/extended/native-image/hello-awtfree/src/linux/kotlin/Platform.kt @@ -1,6 +1,8 @@ package awtfree +import org.thisisthepy.compose.window.WindowConfig import org.thisisthepy.compose.window.WindowPlatform +import org.thisisthepy.compose.window.scene.ComposeWindowHost import org.thisisthepy.compose.window.graalvm.linux.X11Window import org.thisisthepy.compose.window.graalvm.linux.x11WindowSurface import org.thisisthepy.compose.window.scene.WindowSurface @@ -12,5 +14,14 @@ 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 platform's checks of the running window. None on this desktop yet: the X11 title bar + * and accessibility checks are the Linux window's to add. + */ +internal fun platformChecks(host: ComposeWindowHost, platform: WindowPlatform, config: WindowConfig): Boolean { + println("chrome: not checked on this desktop; the X11 window's title bar and accessibility checks are added with it") + return 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 150c3a4349f..68ddae48331 100644 --- a/extended/native-image/hello-awtfree/src/macos/kotlin/Platform.kt +++ b/extended/native-image/hello-awtfree/src/macos/kotlin/Platform.kt @@ -1,7 +1,15 @@ package awtfree import org.graalvm.nativeimage.c.function.CFunction +import org.thisisthepy.compose.window.MacosAx +import org.thisisthepy.compose.window.WindowConfig import org.thisisthepy.compose.window.WindowPlatform +import org.thisisthepy.compose.window.closeButtonInset +import org.thisisthepy.compose.window.formatMacosAxNode +import org.thisisthepy.compose.window.macosAccessibleContentFindings +import org.thisisthepy.compose.window.macosChromeFindings +import org.thisisthepy.compose.window.window +import org.thisisthepy.compose.window.scene.ComposeWindowHost import org.thisisthepy.compose.window.graalvm.macos.AppKitWindowPlatform import org.thisisthepy.compose.window.graalvm.macos.appKitWindowSurface import org.thisisthepy.compose.window.graalvm.macos.installApplicationMenu @@ -28,5 +36,60 @@ private external fun pthreadMainNp(): Int */ internal fun onFirstThread(): Boolean = pthreadMainNp() != 0 +/** + * Checks the running window as a reader who cannot see it meets it, and prints what it found. + * + * Asked in process, because an outside accessibility client needs a trust a CI runner has not + * given: the window walks its own accessibility elements, the objects AppKit's accessibility + * server answers every client from. What the system's API answers an outside client is + * printed too, with its error number. + * + * - The title bar the configuration asked for: the three system buttons, the content under + * the bar, no title drawn, the caption's height and button room as the buttons' own frames + * say, and the corner radius from the measured table. + * - The field and the button, by role and by the names a screen reader is told. + * + * Returns whether both hold. + */ +internal fun platformChecks(host: ComposeWindowHost, platform: WindowPlatform, config: WindowConfig): Boolean { + val window = platform as AppKitWindowPlatform + // Asking is what tells the window a reader is listening; the scene describes itself from + // the next frame on. + window.accessibilitySnapshot() + repeat(20) { + host.requestFrame() + host.turn(16) + } + val snapshot = window.accessibilitySnapshot() + val caption = window.caption() + val style = config.titleBar + var ok = true + val chrome = macosChromeFindings(snapshot, config.title, window.macosTitleBar, caption) + println("chrome: $style caption $caption, close button inset ${snapshot.closeButtonInset(config.title)} pt, macOS ${snapshot.macosMajor}") + if (chrome.isEmpty()) { + println("chrome: ok ($style)") + } else { + ok = false + chrome.forEach { println("chrome: fail: $it") } + } + val content = macosAccessibleContentFindings( + snapshot, + config.title, + listOf(MacosAx.TEXT_FIELD to FIELD_LABEL, MacosAx.BUTTON to BUTTON_LABEL), + ) + if (content.isEmpty()) { + println("accessibility: ok (a text field '$FIELD_LABEL' and a button '$BUTTON_LABEL')") + } else { + ok = false + content.forEach { println("accessibility: fail: $it") } + } + println("accessibility: ${window.accessibilityApiProbe().describe()}") + if (!ok) { + println("accessibility: what the window told a reader:") + snapshot.window(config.title).forEach { println(" ${formatMacosAxNode(it)}") } + } + return ok +} + /** 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/Main.kt b/extended/native-image/hello-awtfree/src/main/kotlin/Main.kt index 9e555342bf0..228cf879e69 100644 --- a/extended/native-image/hello-awtfree/src/main/kotlin/Main.kt +++ b/extended/native-image/hello-awtfree/src/main/kotlin/Main.kt @@ -5,6 +5,7 @@ import kotlin.system.exitProcess import org.jetbrains.skia.Bitmap import org.jetbrains.skia.EncodedImageFormat import org.jetbrains.skia.Surface +import org.thisisthepy.compose.window.TitleBarStyle import org.thisisthepy.compose.window.WindowConfig import org.thisisthepy.compose.window.scene.ComposeWindowHost @@ -19,8 +20,12 @@ import org.thisisthepy.compose.window.scene.ComposeWindowHost * ([textSelfCheck]), with no ICU data file on the machine; * 2. the sample text inks pixels, drawn into a raster surface and written to the PNG; * 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. + * 3. the window opens and presents frames of the Compose content, each drawn at the size the + * window had when it was presented; + * 4. the platform's checks of the running window pass: on macOS, the title bar the + * configuration asked for, and the field and the button with their names where a screen + * reader looks. + * The exit status is 0 only when all four 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 @@ -38,10 +43,13 @@ fun main() { System.err.println("self-check: main is not on the process's first thread, which the window's calls need") exitProcess(3) } + // HELLO_TITLE_BAR=Simple opens the window with the plain title bar; the default is Normal. + val titleBar = if (System.getenv("HELLO_TITLE_BAR") == "Simple") TitleBarStyle.Simple else TitleBarStyle.Normal val platform = newPlatform() + val config = WindowConfig("안녕하세요, AwtFree", 520, 360, minWidth = 240, minHeight = 160, titleBar = titleBar) val host = ComposeWindowHost( platform = platform, - config = WindowConfig("안녕하세요, AwtFree", 520, 360, minWidth = 240, minHeight = 160), + config = config, surfaceFor = ::newSurface, content = { Content() }, ) @@ -74,6 +82,9 @@ fun main() { host.requestFrame() host.turn(16) } + // 4. What the platform's own checks say about the running window: on macOS the title + // bar and what a screen reader is told (platformChecks in Platform.kt). + if (!host.closed && !platformChecks(host, platform, config)) ok = false } finally { host.close() }