Milan Jurkulak 3 månader sedan
förälder
incheckning
42286bb865
40 ändrade filer med 3637 tillägg och 170 borttagningar
  1. 4 1
      .claude/settings.local.json
  2. 0 74
      .run/android.run.xml
  3. 31 0
      .run/installDesktop.run.xml
  4. 1 1
      .run/runDesktop.run.xml
  5. 31 0
      .run/runDesktopNested.run.xml
  6. 31 0
      .run/runDesktopOnAndroid.run.xml
  7. 15 1
      ai-todo.txt
  8. 27 0
      androidApp/src/main/kotlin/org/mjdev/desktop/components/OrientatedView.kt
  9. 74 40
      androidApp/src/main/kotlin/org/mjdev/desktop/main/MainView.kt
  10. 1 0
      build.gradle.kts
  11. 300 0
      compositor/compositor.gradle.kts
  12. 1706 0
      compositor/native/shim.c
  13. 2 0
      compositor/native/shim.def
  14. 120 0
      compositor/native/shim.h
  15. 44 0
      compositor/src/linuxX64Main/kotlin/eu/mjdev/compositor/Clog.kt
  16. 130 0
      compositor/src/linuxX64Main/kotlin/eu/mjdev/compositor/GeometryStore.kt
  17. 300 0
      compositor/src/linuxX64Main/kotlin/eu/mjdev/compositor/Ipc.kt
  18. 268 0
      compositor/src/linuxX64Main/kotlin/eu/mjdev/compositor/Main.kt
  19. 112 0
      compositor/src/linuxX64Main/kotlin/eu/mjdev/compositor/Policy.kt
  20. 81 0
      compositor/src/linuxX64Main/kotlin/eu/mjdev/compositor/Session.kt
  21. 116 0
      compositor/src/linuxX64Main/kotlin/eu/mjdev/compositor/WindowModel.kt
  22. 10 0
      desktopApp/desktopApp.gradle.kts
  23. BIN
      fix-it/Snímek obrazovky z 2026-06-14 22-01-09.png
  24. 1 0
      gradle/libs.versions.toml
  25. 35 0
      session/mjdev-session
  26. 6 0
      session/mjdev.desktop
  27. 1 0
      settings.gradle.kts
  28. 2 0
      shared/shared.gradle.kts
  29. 4 1
      shared/src/androidMain/kotlin/org/mjdev/desktop/data/User.kt
  30. 3 1
      shared/src/androidMain/kotlin/org/mjdev/desktop/helpers/system/meminfo/MemInfoAndroid.kt
  31. 2 2
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/desktop/Desktop.kt
  32. 4 4
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/sliding/base/VisibilityState.kt
  33. 1 1
      shared/src/commonMain/kotlin/org/mjdev/desktop/managers/palette/Palette.kt
  34. BIN
      shared/src/commonMain/resources/drawable/icon.png
  35. 0 21
      shared/src/commonMain/resources/drawable/icon.svg
  36. 128 0
      shared/src/desktopMain/kotlin/org/mjdev/desktop/helpers/mouseevents/CompositorMouseSource.kt
  37. 10 5
      shared/src/desktopMain/kotlin/org/mjdev/desktop/helpers/mouseevents/GlobalMouseListener.kt
  38. 9 11
      shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/processes/ProcessManager.kt
  39. 4 1
      shared/src/desktopMain/kotlin/org/mjdev/desktop/windows/ChromeWindow.kt
  40. 23 6
      shared/src/desktopMain/kotlin/org/mjdev/desktop/windows/ChromeWindowState.kt

+ 4 - 1
.claude/settings.local.json

@@ -33,7 +33,10 @@
       "Bash(cd *)",
       "Bash(./gradlew buildAll -PdepCheck=false --console=plain)",
       "Bash(awk '{printf \"  %-34s %s\\\\n\",$NF,$5}')",
-      "WebFetch(domain:blog.jetbrains.com)"
+      "WebFetch(domain:blog.jetbrains.com)",
+      "Bash(timeout 580 ./gradlew :shared:compileKotlinDesktop -q)",
+      "Bash(xargs ls -la)",
+      "Bash(./gradlew :compositor:help --task installDesktop -q)"
     ]
   }
 }

+ 0 - 74
.run/android.run.xml

@@ -1,74 +0,0 @@
-<component name="ProjectRunConfigurationManager">
-  <configuration default="false" name="android" type="AndroidRunConfigurationType" factoryName="Android App" activateToolWindowBeforeRun="false">
-    <module name="mjdev-desktop.androidApp" />
-    <option name="ANDROID_RUN_CONFIGURATION_SCHEMA_VERSION" value="1" />
-    <option name="DEPLOY" value="true" />
-    <option name="DEPLOY_APK_FROM_BUNDLE" value="false" />
-    <option name="DEPLOY_AS_INSTANT" value="false" />
-    <option name="ARTIFACT_NAME" value="" />
-    <option name="PM_INSTALL_OPTIONS" value="" />
-    <option name="ALL_USERS" value="false" />
-    <option name="ALWAYS_INSTALL_WITH_PM" value="false" />
-    <option name="ALLOW_ASSUME_VERIFIED" value="false" />
-    <option name="CLEAR_APP_STORAGE" value="false" />
-    <option name="DYNAMIC_FEATURES_DISABLED_LIST" value="" />
-    <option name="ACTIVITY_EXTRA_FLAGS" value="" />
-    <option name="MODE" value="default_activity" />
-    <option name="RESTORE_ENABLED" value="false" />
-    <option name="RESTORE_FILE" value="" />
-    <option name="RESTORE_FRESH_INSTALL_ONLY" value="false" />
-    <option name="CLEAR_LOGCAT" value="false" />
-    <option name="SHOW_LOGCAT_AUTOMATICALLY" value="false" />
-    <option name="TARGET_SELECTION_MODE" value="DEVICE_AND_SNAPSHOT_COMBO_BOX" />
-    <option name="SELECTED_CLOUD_MATRIX_CONFIGURATION_ID" value="-1" />
-    <option name="SELECTED_CLOUD_MATRIX_PROJECT_ID" value="" />
-    <option name="DEBUGGER_TYPE" value="Auto" />
-    <Auto>
-      <option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
-      <option name="SHOW_STATIC_VARS" value="true" />
-      <option name="WORKING_DIR" value="" />
-      <option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
-      <option name="SHOW_OPTIMIZED_WARNING" value="true" />
-      <option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
-      <option name="DEBUG_SANDBOX_SDK" value="false" />
-    </Auto>
-    <Hybrid>
-      <option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
-      <option name="SHOW_STATIC_VARS" value="true" />
-      <option name="WORKING_DIR" value="" />
-      <option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
-      <option name="SHOW_OPTIMIZED_WARNING" value="true" />
-      <option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
-      <option name="DEBUG_SANDBOX_SDK" value="false" />
-    </Hybrid>
-    <Java>
-      <option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
-      <option name="DEBUG_SANDBOX_SDK" value="false" />
-    </Java>
-    <Native>
-      <option name="USE_JAVA_AWARE_DEBUGGER" value="false" />
-      <option name="SHOW_STATIC_VARS" value="true" />
-      <option name="WORKING_DIR" value="" />
-      <option name="TARGET_LOGGING_CHANNELS" value="lldb process:gdb-remote packets" />
-      <option name="SHOW_OPTIMIZED_WARNING" value="true" />
-      <option name="ATTACH_ON_WAIT_FOR_DEBUGGER" value="false" />
-      <option name="DEBUG_SANDBOX_SDK" value="false" />
-    </Native>
-    <Profilers>
-      <option name="ADVANCED_PROFILING_ENABLED" value="false" />
-      <option name="STARTUP_PROFILING_ENABLED" value="false" />
-      <option name="STARTUP_CPU_PROFILING_ENABLED" value="false" />
-      <option name="STARTUP_CPU_PROFILING_CONFIGURATION_NAME" value="Java/Kotlin Method Sample (legacy)" />
-      <option name="STARTUP_NATIVE_MEMORY_PROFILING_ENABLED" value="false" />
-      <option name="NATIVE_MEMORY_SAMPLE_RATE_BYTES" value="2048" />
-    </Profilers>
-    <option name="DEEP_LINK" value="" />
-    <option name="ACTIVITY" value="" />
-    <option name="ACTIVITY_CLASS" value="" />
-    <option name="SEARCH_ACTIVITY_IN_GLOBAL_SCOPE" value="false" />
-    <option name="SKIP_ACTIVITY_VALIDATION" value="false" />
-    <method v="2">
-      <option name="Android.Gradle.BeforeRunTask" enabled="true" />
-    </method>
-  </configuration>
-</component>

+ 31 - 0
.run/installDesktop.run.xml

@@ -0,0 +1,31 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="installDesktop" type="GradleRunConfiguration" factoryName="Gradle">
+    <ExternalSystemSettings>
+      <option name="executionName" />
+      <option name="externalProjectPath" value="$PROJECT_DIR$/compositor" />
+      <option name="externalSystemIdString" value="GRADLE" />
+      <option name="scriptParameters" value="" />
+      <option name="taskDescriptions">
+        <list />
+      </option>
+      <option name="taskNames">
+        <list>
+          <option value="installDesktop" />
+        </list>
+      </option>
+      <option name="vmOptions" />
+    </ExternalSystemSettings>
+    <ExternalSystemDebugServerProcess>false</ExternalSystemDebugServerProcess>
+    <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
+    <ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
+    <EXTENSION ID="com.android.tools.idea.testartifacts.testsuite.GradleRunConfigurationExtension">
+      <com.android.tools.idea.testartifacts.testsuite.SHOW_TEST_RESULT_IN_ANDROID_TEST_SUITE_VIEW>false</com.android.tools.idea.testartifacts.testsuite.SHOW_TEST_RESULT_IN_ANDROID_TEST_SUITE_VIEW>
+      <android.execution.deploysToLocalDevice>false</android.execution.deploysToLocalDevice>
+    </EXTENSION>
+    <DebugAllEnabled>false</DebugAllEnabled>
+    <RunAsTest>false</RunAsTest>
+    <GradleProfilingDisabled>false</GradleProfilingDisabled>
+    <GradleCoverageDisabled>false</GradleCoverageDisabled>
+    <method v="2" />
+  </configuration>
+</component>

+ 1 - 1
.run/desktop.run.xml → .run/runDesktop.run.xml

@@ -1,5 +1,5 @@
 <component name="ProjectRunConfigurationManager">
-  <configuration default="false" name="desktop" type="GradleRunConfiguration" factoryName="Gradle">
+  <configuration default="false" name="runDesktop" type="GradleRunConfiguration" factoryName="Gradle">
     <ExternalSystemSettings>
       <option name="executionName" />
       <option name="externalProjectPath" value="$PROJECT_DIR$/desktopApp" />

+ 31 - 0
.run/runDesktopNested.run.xml

@@ -0,0 +1,31 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="runDesktopNested" type="GradleRunConfiguration" factoryName="Gradle">
+    <ExternalSystemSettings>
+      <option name="executionName" />
+      <option name="externalProjectPath" value="$PROJECT_DIR$/compositor" />
+      <option name="externalSystemIdString" value="GRADLE" />
+      <option name="scriptParameters" value="" />
+      <option name="taskDescriptions">
+        <list />
+      </option>
+      <option name="taskNames">
+        <list>
+          <option value="runNestedDesktop" />
+        </list>
+      </option>
+      <option name="vmOptions" />
+    </ExternalSystemSettings>
+    <ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
+    <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
+    <ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
+    <EXTENSION ID="com.android.tools.idea.testartifacts.testsuite.GradleRunConfigurationExtension">
+      <com.android.tools.idea.testartifacts.testsuite.SHOW_TEST_RESULT_IN_ANDROID_TEST_SUITE_VIEW>false</com.android.tools.idea.testartifacts.testsuite.SHOW_TEST_RESULT_IN_ANDROID_TEST_SUITE_VIEW>
+      <android.execution.deploysToLocalDevice>false</android.execution.deploysToLocalDevice>
+    </EXTENSION>
+    <DebugAllEnabled>false</DebugAllEnabled>
+    <RunAsTest>false</RunAsTest>
+    <GradleProfilingDisabled>false</GradleProfilingDisabled>
+    <GradleCoverageDisabled>false</GradleCoverageDisabled>
+    <method v="2" />
+  </configuration>
+</component>

+ 31 - 0
.run/runDesktopOnAndroid.run.xml

@@ -0,0 +1,31 @@
+<component name="ProjectRunConfigurationManager">
+  <configuration default="false" name="runDesktopOnAndroid" type="GradleRunConfiguration" factoryName="Gradle">
+    <ExternalSystemSettings>
+      <option name="executionName" />
+      <option name="externalProjectPath" value="$PROJECT_DIR$" />
+      <option name="externalSystemIdString" value="GRADLE" />
+      <option name="scriptParameters" value="" />
+      <option name="taskDescriptions">
+        <list />
+      </option>
+      <option name="taskNames">
+        <list>
+          <option value="runAndroid" />
+        </list>
+      </option>
+      <option name="vmOptions" />
+    </ExternalSystemSettings>
+    <ExternalSystemDebugServerProcess>true</ExternalSystemDebugServerProcess>
+    <ExternalSystemReattachDebugProcess>true</ExternalSystemReattachDebugProcess>
+    <ExternalSystemDebugDisabled>false</ExternalSystemDebugDisabled>
+    <EXTENSION ID="com.android.tools.idea.testartifacts.testsuite.GradleRunConfigurationExtension">
+      <com.android.tools.idea.testartifacts.testsuite.SHOW_TEST_RESULT_IN_ANDROID_TEST_SUITE_VIEW>false</com.android.tools.idea.testartifacts.testsuite.SHOW_TEST_RESULT_IN_ANDROID_TEST_SUITE_VIEW>
+      <android.execution.deploysToLocalDevice>true</android.execution.deploysToLocalDevice>
+    </EXTENSION>
+    <DebugAllEnabled>false</DebugAllEnabled>
+    <RunAsTest>false</RunAsTest>
+    <GradleProfilingDisabled>false</GradleProfilingDisabled>
+    <GradleCoverageDisabled>false</GradleCoverageDisabled>
+    <method v="2" />
+  </configuration>
+</component>

+ 15 - 1
ai-todo.txt

@@ -1,8 +1,22 @@
 # This file contains project tasks for ai
+# # at the line beginning means no task no command just a note
 # * at the line beginning means repeatable task every build run
 # - at the line beginning means task to be done once, task can be multiline
 
 * collect reports from reports folder and clean up code where it is non-invasive,
   and also check build can complete after update
 * update dependencies from report in reports folder, on every updated library check
-  that builds run and or ommit/revert update if impossible
+  that builds run and or ommit/revert update if impossible
+
+- bottom desktop panel have non valid x coordinate, bad computation when in nested,
+  when it should be shown, by moving mouse pointer down to screen, and or it have bad height,
+  but usualy is shown out of nested window, may be nested window position is not starting 0,0 and
+  that breaks computation?
+
+- handles that are on sliding windows 12dp please, there is 9dp i think yet.
+
+- in log when building theres a lot of warnings and errors probably, i want to have
+  clean log  if possible
+
+# unimportant changes if possible:
+# control center, when shown should hide menu and bottom bar if it is visible

+ 27 - 0
androidApp/src/main/kotlin/org/mjdev/desktop/components/OrientatedView.kt

@@ -0,0 +1,27 @@
+package org.mjdev.desktop.components
+
+import android.content.res.Configuration.ORIENTATION_PORTRAIT
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+
+@Preview
+@Composable
+fun OrientatedView(
+    modifier: Modifier = Modifier,
+    landscape: @Composable BoxScope.() -> Unit = {},
+    portrait: @Composable BoxScope.() -> Unit = {},
+) = withDesktopContext {
+    Box(
+        modifier = modifier
+    ) {
+        if (context.platformContext?.resources?.configuration?.orientation == ORIENTATION_PORTRAIT) {
+            portrait()
+        } else {
+            landscape()
+        }
+    }
+}

+ 74 - 40
androidApp/src/main/kotlin/org/mjdev/desktop/main/MainView.kt

@@ -16,6 +16,7 @@ import androidx.compose.ui.tooling.preview.Devices
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.max
+import org.mjdev.desktop.components.OrientatedView
 import org.mjdev.desktop.components.appsmenu.AppsMenu
 import org.mjdev.desktop.components.appsmenu.AppsMenuState.Companion.rememberAppsMenuState
 import org.mjdev.desktop.components.controlcenter.ControlCenter
@@ -65,13 +66,9 @@ fun MainView(onBackgroundChange: (Color) -> Unit = {}) =
             println("Showing tooltip : $item")
             tooltipState.show(item)
         }
-        // todo not showing nothing
-        Box(
-            modifier = Modifier.fillMaxSize(),
-            contentAlignment = Alignment.Center,
-        ) {
-            // todo, something prevent preview
-            if (!isDesign) {
+        OrientatedView(
+            portrait = {
+                // todo menu & etc
                 Desktop(
                     tooltipState = tooltipState,
                     onTooltip = onTooltip,
@@ -99,40 +96,77 @@ fun MainView(onBackgroundChange: (Color) -> Unit = {}) =
 //                contextMenuState.show()
                     },
                 )
-            }
-            DesktopPanel(panelState = panelState, onTooltip = onTooltip, onMenuIconClicked = {
-                runAsync {
-                    appsMenuState.show()
+            },
+            landscape = {
+                Box(
+                    modifier = Modifier.fillMaxSize(),
+                    contentAlignment = Alignment.Center,
+                ) {
+                    Desktop(
+                        tooltipState = tooltipState,
+                        onTooltip = onTooltip,
+                        padding =
+                            PaddingValues(
+                                bottom = bottomPadding,
+                            ),
+                        widgets = {
+                            MemoryChart(
+                                modifier =
+                                    Modifier
+                                        .size(350.dp, 300.dp)
+                                        .align(Alignment.BottomEnd),
+                            )
+                        },
+                        onLeftMouseClick = {
+                            runAsync {
+                                panelState.hide()
+                                appsMenuState.hide()
+                                controlCenterState.hide()
+                            }
+                        },
+                        onBackgroundChange = onBackgroundChange,
+                        onRightMouseClick = {
+//                          contextMenuState.show()
+                        },
+                    )
+                    DesktopPanel(
+                        panelState = panelState,
+                        onTooltip = onTooltip,
+                        onMenuIconClicked = {
+                            runAsync {
+                                appsMenuState.show()
+                            }
+                        })
+                    ControlCenter(
+                        modifier =
+                            Modifier
+                                .fillMaxHeight()
+                                .align(Alignment.TopEnd),
+                        controlCenterState = controlCenterState,
+                        onTooltip = onTooltip,
+                    )
+                    AppsMenu(
+                        modifier =
+                            Modifier
+                                .wrapContentSize()
+                                .align(Alignment.BottomStart),
+                        appsMenuState = appsMenuState,
+                        panelState = panelState,
+                        onAppClick = { app ->
+                            runAsync {
+                                app.start()
+                                appsMenuState.hide()
+                            }
+                        },
+                        onAppContextMenuClick = {},
+                        onCategoryContextMenuClick = {},
+                        onUserAvatarClick = {},
+                        onActionClick = {},
+                        onTooltip = onTooltip,
+                    )
                 }
-            })
-            ControlCenter(
-                modifier =
-                    Modifier
-                        .fillMaxHeight()
-                        .align(Alignment.TopEnd),
-                controlCenterState = controlCenterState,
-                onTooltip = onTooltip,
-            )
-            AppsMenu(
-                modifier =
-                    Modifier
-                        .wrapContentSize()
-                        .align(Alignment.BottomStart),
-                appsMenuState = appsMenuState,
-                panelState = panelState,
-                onAppClick = { app ->
-                    runAsync {
-                        app.start()
-                        appsMenuState.hide()
-                    }
-                },
-                onAppContextMenuClick = {},
-                onCategoryContextMenuClick = {},
-                onUserAvatarClick = {},
-                onActionClick = {},
-                onTooltip = onTooltip,
-            )
-        }
+            }
+        )
     }
 
 @Preview(device = Devices.TABLET)

+ 1 - 0
build.gradle.kts

@@ -10,6 +10,7 @@ plugins {
     alias(libs.plugins.compose.compiler) apply false
     alias(libs.plugins.kotlin.jvm) apply false
     alias(libs.plugins.kotlin.multiplatform) apply false
+    alias(libs.plugins.kotlin.serialization) apply false
     alias(libs.plugins.app.icon.generator) apply false
     alias(libs.plugins.qodana)
 //    alias(libs.plugins.kover)

+ 300 - 0
compositor/compositor.gradle.kts

@@ -0,0 +1,300 @@
+/*
+ * Copyright (c) Milan Jurkulák 2026.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+import org.jetbrains.kotlin.gradle.tasks.CInteropProcess
+
+plugins {
+    // kotlin + serialization come from the root build classpath (declared apply-false there)
+    kotlin("multiplatform")
+    kotlin("plugin.serialization")
+}
+
+// identity from the shared version catalog (gradle/libs.versions.toml)
+val catalog = extensions.getByType<VersionCatalogsExtension>().named("libs")
+group = catalog.findVersion("app-pkg-name").get().requiredVersion
+version = catalog.findVersion("app-pkg-version").get().requiredVersion
+
+val nativeDir = project.file("native")
+val protocolsDir = layout.buildDirectory.dir("generated/protocols")
+val shimDir = layout.buildDirectory.dir("shim")
+
+// pkg-config is queried lazily and never fails configuration,
+// so the JVM desktop build stays usable on machines without wlroots
+fun pkgConfig(vararg args: String): List<String> = runCatching {
+    val process = ProcessBuilder(listOf("pkg-config") + args)
+        .redirectErrorStream(false)
+        .start()
+    val out = process.inputStream.bufferedReader().readText()
+    if (process.waitFor() != 0) emptyList()
+    else out.trim().split(Regex("\\s+")).filter { it.isNotBlank() }
+}.getOrElse { emptyList() }
+
+val wlrPackages = arrayOf("wlroots-0.18", "wayland-server", "xkbcommon", "pixman-1")
+val wlrCflags: List<String>
+    get() = pkgConfig("--cflags", *wlrPackages)
+val wlrLibs: List<String>
+    get() = pkgConfig("--libs", *wlrPackages).ifEmpty {
+        listOf("-lwlroots-0.18", "-lwayland-server", "-lxkbcommon")
+    }
+
+// the kotlin/native bundled linker does not search the system lib dirs
+val systemLibDirs = listOf(
+    "/usr/lib/x86_64-linux-gnu",
+    "/usr/lib",
+    "/usr/local/lib",
+).filter { file(it).isDirectory }.map { "-L$it" }
+
+val generateProtocols = tasks.register<Exec>("generateProtocols") {
+    group = "mjdev"
+    description = "Generates wayland protocol headers used by the compositor shim."
+    val outDir = protocolsDir.get().asFile
+    val xdgShellXml = "/usr/share/wayland-protocols/stable/xdg-shell/xdg-shell.xml"
+    inputs.files(xdgShellXml).optional()
+    outputs.dir(outDir)
+    commandLine(
+        "bash", "-c",
+        "mkdir -p '$outDir' && " +
+                "wayland-scanner server-header '$xdgShellXml' '$outDir/xdg-shell-protocol.h'"
+    )
+}
+
+val compileShim = tasks.register<Exec>("compileShim") {
+    group = "mjdev"
+    description = "Compiles the C shim over wlroots into a static library."
+    dependsOn(generateProtocols)
+    // capture everything as local values at configuration time so the doFirst closure
+    // never reaches back into the build script (required for configuration-cache safety)
+    val shimC = nativeDir.resolve("shim.c")
+    val shimH = nativeDir.resolve("shim.h")
+    val outDir = shimDir.get().asFile
+    val protoDir = protocolsDir.get().asFile
+    val includeDir = nativeDir
+    val cflags = wlrCflags.joinToString(" ")
+    inputs.files(shimC, shimH)
+    outputs.file(outDir.resolve("libmjcshim.a"))
+    doFirst {
+        commandLine(
+            "bash", "-c",
+            "mkdir -p '$outDir' && " +
+                    "cc -O2 -fPIC -DWLR_USE_UNSTABLE -std=c11 " +
+                    "-I'$protoDir' -I'$includeDir' $cflags " +
+                    "-c '$shimC' -o '$outDir/shim.o' && " +
+                    "ar rcs '$outDir/libmjcshim.a' '$outDir/shim.o'"
+        )
+    }
+    // placeholder, replaced in doFirst (Exec requires a commandLine at configuration)
+    commandLine("true")
+}
+
+kotlin {
+    linuxX64 {
+        compilations.getByName("main") {
+            cinterops.create("shim") {
+                defFile(nativeDir.resolve("shim.def"))
+                packageName("mjdev.compositor.shim")
+                includeDirs(nativeDir)
+            }
+        }
+        binaries {
+            executable("mjdevc") {
+                entryPoint = "eu.mjdev.compositor.main"
+                // link against the system glibc instead of the old bundled
+                // sysroot, system libs (wlroots & co) need modern symbols
+                val gccVersion = file("/usr/lib/gcc/x86_64-linux-gnu")
+                    .listFiles()?.map { it.name }?.maxByOrNull { it.toIntOrNull() ?: 0 }
+                if (gccVersion != null) {
+                    freeCompilerArgs += listOf(
+                        "-Xoverride-konan-properties=" +
+                                "targetSysRoot.linux_x64=/;" +
+                                "libGcc.linux_x64=/usr/lib/gcc/x86_64-linux-gnu/$gccVersion;" +
+                                "crtFilesLocation.linux_x64=usr/lib/x86_64-linux-gnu"
+                    )
+                }
+                linkerOpts(systemLibDirs)
+                linkerOpts(shimDir.get().asFile.resolve("libmjcshim.a").absolutePath)
+                linkerOpts(wlrLibs)
+            }
+        }
+    }
+
+    sourceSets {
+        getByName("linuxX64Main") {
+            dependencies {
+                implementation(catalog.findLibrary("kotlinx-serialization-json").get())
+            }
+        }
+    }
+}
+
+// the static shim library must exist before linking the executable; it is passed via
+// linkerOpts (an absolute path), which Kotlin/Native does not track — so register it as an
+// explicit input, otherwise editing shim.c rebuilds the .a but never relinks the binary
+// (you would keep running a stale executable).
+tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinNativeLink>().configureEach {
+    dependsOn(compileShim)
+    inputs.file(shimDir.map { it.file("libmjcshim.a") })
+        .withPropertyName("mjcShimLibrary")
+}
+// note: cinterop parses only the self contained shim.h,
+// generated wayland protocol headers are needed just for compileShim
+
+tasks.register<Copy>("stageSession") {
+    group = "mjdev"
+    description = "Stages the compositor binary and session files for installation."
+    dependsOn("linkMjdevcReleaseExecutableLinuxX64")
+    from(layout.buildDirectory.file("bin/linuxX64/mjdevcReleaseExecutable/mjdevc.kexe")) {
+        rename { "mjdevc" }
+    }
+    from(rootProject.file("session/mjdev-session"))
+    from(rootProject.file("session/mjdev.desktop"))
+    into(layout.buildDirectory.dir("session-install"))
+}
+
+// Privileged install steps for the compositor + wayland session. Installs the binary, the
+// session launcher and the wayland-sessions entry, and removes the stale 2024 X11
+// "mjdev-desktop" session that shadows ours (same display name) and breaks login. Lines are
+// built at configuration time (staged paths are static) so the Exec stays config-cache safe.
+// built at configuration time (staged paths are static) so referencing the result from doLast
+// stays configuration-cache safe (the function itself is never captured in the task action).
+fun sessionInstallLines(staged: File): List<String> = listOf(
+    "#!/bin/sh",
+    "set -e",
+    "install -Dm755 '${staged.resolve("mjdevc")}' /usr/local/bin/mjdevc",
+    "install -Dm755 '${staged.resolve("mjdev-session")}' /usr/local/bin/mjdev-session",
+    "install -Dm644 '${staged.resolve("mjdev.desktop")}' /usr/share/wayland-sessions/mjdev.desktop",
+    "rm -f /usr/share/xsessions/mjdev-desktop.desktop " +
+            "/usr/share/gnome-session/sessions/mjdev-desktop.session " +
+            "/usr/share/applications/mjdev-desktop-mjdev-desktop.desktop",
+)
+
+// sudo can't read a password from the Gradle daemon (no TTY), so these tasks don't exec it —
+// they stage everything, write a self-contained install script under build/, and print the one
+// sudo command for the user to run in their own terminal.
+tasks.register("installSession") {
+    group = "mjdev"
+    description = "Stages the compositor + wayland session, prints the sudo install command."
+    dependsOn("stageSession")
+    val staged = layout.buildDirectory.dir("session-install").get().asFile
+    val lines = sessionInstallLines(staged)
+    doLast {
+        val script = staged.resolve("install.sh")
+        script.writeText(lines.joinToString("\n") + "\n")
+        println("Session staged. Install it (single sudo prompt) with:")
+        println("  sudo sh '${script.absolutePath}'")
+    }
+}
+
+// Complete (re)install: compositor + wayland session + the desktop app .deb (the session's
+// shell, /opt/mjdev-desktop), and the stale-session cleanup — one script run as root via a
+// pkexec (polkit) authentication dialog. pkexec needs no TTY (unlike sudo), so it works from
+// the detached Gradle daemon and prompts graphically; if it's missing we print the sudo command.
+tasks.register<Exec>("installDesktop") {
+    group = "mjdev"
+    description = "Builds compositor + session + app .deb and installs it via a pkexec authentication dialog."
+    dependsOn("stageSession", ":desktopApp:packageReleaseDeb")
+    val staged = layout.buildDirectory.dir("session-install").get().asFile
+    val lines = sessionInstallLines(staged)
+    // desktopApp overrides compose outputBaseDir to <root>/packages, so the .deb is written
+    // to packages/main-release/deb (not desktopApp/build/compose/binaries/…)
+    val debDir = rootProject.rootDir.resolve("packages/main-release/deb")
+    isIgnoreExitValue = false
+    doFirst {
+        val deb = debDir.listFiles { f -> f.extension == "deb" }?.firstOrNull()
+            ?: error("desktop .deb not found in $debDir — run :desktopApp:packageReleaseDeb")
+        val script = staged.resolve("install-desktop.sh")
+        script.writeText((lines + "dpkg -i '${deb.absolutePath}'").joinToString("\n") + "\n")
+        // pkexec pops a graphical polkit auth dialog and runs the script as root; needs a polkit
+        // agent in the session but no terminal. Fall back to a printed sudo command if absent.
+        val pkexec = listOf("/usr/bin/pkexec", "/usr/local/bin/pkexec")
+            .firstOrNull { File(it).canExecute() }
+        if (pkexec != null) {
+            println("Installing the mjdev desktop — approve the authentication dialog…")
+            commandLine(pkexec, "sh", script.absolutePath)
+        } else {
+            println("pkexec not found. Install the desktop (single sudo prompt) with:")
+            println("  sudo sh '${script.absolutePath}'")
+            commandLine("true")
+        }
+    }
+    commandLine("true")
+}
+
+tasks.register<Exec>("runNested") {
+    group = "mjdev"
+    description = "Runs the compositor nested inside the current Wayland session."
+    dependsOn("linkMjdevcDebugExecutableLinuxX64")
+    val shellCmd = (project.findProperty("shellCmd") as String?)
+    val binary = layout.buildDirectory
+        .file("bin/linuxX64/mjdevcDebugExecutable/mjdevc.kexe")
+        .get().asFile.absolutePath
+    // x11 backend -> the host window manager decorates the nested window (frame + close +
+    // move); 16:9 nested output (the compositor locks it against resize)
+    environment("WLR_BACKENDS", "x11")
+    environment("MJDEVC_OUTPUT", "1280x720")
+    doFirst {
+        // host X11 auth for the nested wlroots x11 backend, resolved at execution time:
+        // the detached Gradle daemon often lacks a valid XAUTHORITY, and mutter writes a
+        // per-session, randomly-suffixed auth file under XDG_RUNTIME_DIR
+        environment("DISPLAY", System.getenv("DISPLAY") ?: ":0")
+        val xauthCandidates = buildList {
+            System.getenv("XAUTHORITY")?.let { add(File(it)) }
+            System.getenv("XDG_RUNTIME_DIR")?.let { dir ->
+                File(dir).listFiles()?.forEach { f ->
+                    if (f.name.startsWith(".mutter-Xwaylandauth.")) add(f)
+                }
+            }
+            add(File(System.getProperty("user.home"), ".Xauthority"))
+        }
+        xauthCandidates.firstOrNull { it.isFile }?.let { environment("XAUTHORITY", it.absolutePath) }
+        val args = mutableListOf(binary)
+        if (shellCmd != null) {
+            args += listOf("--shell-cmd", shellCmd)
+        }
+        commandLine(args)
+    }
+    commandLine("true")
+}
+
+// Runs the Compose desktop app as the shell client inside the nested compositor.
+// The desktop app is packaged with its own JRE (createDistributable) and launched via
+// XWayland, so it shows up as a window managed by mjdevc (fixed 16:9, movable, closable).
+tasks.register<Exec>("runNestedDesktop") {
+    group = "mjdev"
+    description = "Runs the mjdev desktop app as a client inside the nested compositor."
+    dependsOn("linkMjdevcDebugExecutableLinuxX64", ":desktopApp:createDistributable")
+    val binary = layout.buildDirectory
+        .file("bin/linuxX64/mjdevcDebugExecutable/mjdevc.kexe")
+        .get().asFile.absolutePath
+    // desktopApp overrides outputBaseDir to <root>/packages, so createDistributable
+    // writes the app image to packages/main/app/<name>/bin/<name> (not build/compose/…)
+    val appDir = rootProject.rootDir.resolve("packages/main/app")
+    // x11 backend -> host-decorated nested window (frame + close + move); fixed 16:9 output
+    environment("WLR_BACKENDS", "x11")
+    environment("MJDEVC_OUTPUT", "1280x720")
+    doFirst {
+        // host X11 auth for the nested wlroots x11 backend, resolved at execution time:
+        // the detached Gradle daemon often lacks a valid XAUTHORITY, and mutter writes a
+        // per-session, randomly-suffixed auth file under XDG_RUNTIME_DIR
+        environment("DISPLAY", System.getenv("DISPLAY") ?: ":0")
+        val xauthCandidates = buildList {
+            System.getenv("XAUTHORITY")?.let { add(File(it)) }
+            System.getenv("XDG_RUNTIME_DIR")?.let { dir ->
+                File(dir).listFiles()?.forEach { f ->
+                    if (f.name.startsWith(".mutter-Xwaylandauth.")) add(f)
+                }
+            }
+            add(File(System.getProperty("user.home"), ".Xauthority"))
+        }
+        xauthCandidates.firstOrNull { it.isFile }?.let { environment("XAUTHORITY", it.absolutePath) }
+        val launcher = appDir.walkTopDown()
+            .firstOrNull { it.isFile && it.parentFile?.name == "bin" && it.canExecute() }
+            ?: error("desktop launcher not found under $appDir — run :desktopApp:createDistributable")
+        commandLine(binary, "--shell-cmd", launcher.absolutePath)
+    }
+    commandLine("true")
+}

+ 1706 - 0
compositor/native/shim.c

@@ -0,0 +1,1706 @@
+/*
+ * Copyright (c) Milan Jurkulák 2026.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ *
+ * wlroots 0.18 based compositor core, exposed to Kotlin/Native
+ * through the flat API in shim.h. Policy (layers, focus order,
+ * IPC) lives on the Kotlin side.
+ */
+
+#define _POSIX_C_SOURCE 200809L
+
+#include <assert.h>
+#include <signal.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+#include <wayland-server-core.h>
+#include <wlr/backend.h>
+#include <wlr/backend/session.h>
+#include <wlr/render/allocator.h>
+#include <wlr/render/wlr_renderer.h>
+#include <wlr/types/wlr_compositor.h>
+#include <wlr/types/wlr_subcompositor.h>
+#include <wlr/types/wlr_cursor.h>
+#include <wlr/types/wlr_data_device.h>
+#include <wlr/types/wlr_input_device.h>
+#include <wlr/types/wlr_keyboard.h>
+#include <wlr/types/wlr_output.h>
+#include <wlr/types/wlr_output_layout.h>
+#include <wlr/types/wlr_pointer.h>
+#include <wlr/types/wlr_scene.h>
+#include <wlr/types/wlr_seat.h>
+#include <wlr/types/wlr_xcursor_manager.h>
+#include <wlr/types/wlr_xdg_decoration_v1.h>
+#include <wlr/types/wlr_xdg_shell.h>
+#include <wlr/util/log.h>
+#include <wlr/xwayland.h>
+#include <xkbcommon/xkbcommon.h>
+
+#include "shim.h"
+
+enum mjc_cursor_mode {
+    MJC_CURSOR_PASSTHROUGH,
+    MJC_CURSOR_MOVE,
+    MJC_CURSOR_RESIZE,
+};
+
+struct mjc_server {
+    struct wl_display *display;
+    struct wl_event_loop *loop;
+    struct wlr_backend *backend;
+    struct wlr_session *session;
+    struct wlr_renderer *renderer;
+    struct wlr_allocator *allocator;
+    struct wlr_compositor *compositor;
+    struct wlr_scene *scene;
+    struct wlr_scene_output_layout *scene_layout;
+    struct wlr_output_layout *output_layout;
+    struct wlr_scene_tree *layers[5];
+
+    struct wlr_xdg_shell *xdg_shell;
+    struct wlr_xdg_decoration_manager_v1 *xdg_decoration_mgr;
+    struct wlr_xwayland *xwayland;
+
+    struct wlr_cursor *cursor;
+    struct wlr_xcursor_manager *cursor_mgr;
+    struct wlr_seat *seat;
+
+    struct wl_list views;     /* mjc_view.link */
+    struct wl_list keyboards; /* mjc_keyboard.link */
+    struct wl_list fd_sources;/* mjc_fd_source.link */
+
+    struct mjc_view *focused_view;
+    mjc_callbacks cbs;
+    void *ud;
+    const char *socket;
+    uint64_t next_view_id;
+
+    /* nested-output size (16:9 default); used when the backend has no mode list
+     * (wayland/headless) and to lock the window against host-driven resizes */
+    int output_w, output_h;
+    /* throttle timestamp (msec) for the pointer callback */
+    uint32_t last_pointer_ms;
+
+    enum mjc_cursor_mode cursor_mode;
+    struct mjc_view *grabbed_view;
+    double grab_x, grab_y;
+    struct wlr_box grab_geobox;
+    uint32_t resize_edges;
+
+    struct wl_event_source *sigchld_source;
+
+    /* pids spawned via mjc_spawn; sigchld must reap only these,
+     * other children (Xwayland) belong to wlroots */
+    pid_t spawned_pids[64];
+    int spawned_count;
+
+    struct wl_listener new_output;
+    struct wl_listener new_xdg_toplevel;
+    struct wl_listener new_xdg_popup;
+    struct wl_listener new_toplevel_decoration;
+    struct wl_listener new_xwayland_surface;
+    struct wl_listener xwayland_ready;
+    struct wl_listener cursor_motion;
+    struct wl_listener cursor_motion_absolute;
+    struct wl_listener cursor_button;
+    struct wl_listener cursor_axis;
+    struct wl_listener cursor_frame;
+    struct wl_listener new_input;
+    struct wl_listener request_cursor;
+    struct wl_listener request_set_selection;
+};
+
+struct mjc_view {
+    struct wl_list link;
+    struct mjc_server *server;
+    uint64_t id;
+    bool is_xwayland;
+    bool mapped;
+    bool minimized;
+    bool maximized;
+    /* re-center once the client commits its post-unmaximize size */
+    bool pending_center;
+    bool focusable;
+    mjc_layer layer;
+    struct wlr_scene_tree *scene_tree;
+    /* floating geometry remembered while maximized (x/y are scene coords) */
+    struct wlr_box saved_geo;
+
+    /* xdg shell */
+    struct wlr_xdg_toplevel *xdg_toplevel;
+
+    /* xwayland */
+    struct wlr_xwayland_surface *xsurface;
+
+    struct wl_listener map;
+    struct wl_listener unmap;
+    struct wl_listener commit;
+    struct wl_listener destroy;
+    struct wl_listener request_move;
+    struct wl_listener request_resize;
+    struct wl_listener request_maximize;
+    struct wl_listener request_minimize;
+    struct wl_listener request_fullscreen;
+    struct wl_listener set_title;
+    struct wl_listener set_app_id; /* set_class for xwayland */
+    struct wl_listener associate;
+    struct wl_listener dissociate;
+    struct wl_listener request_configure;
+    struct wl_listener set_geometry;
+};
+
+struct mjc_popup {
+    struct wlr_xdg_popup *xdg_popup;
+    struct wl_listener commit;
+    struct wl_listener destroy;
+};
+
+struct mjc_decoration {
+    struct wlr_xdg_toplevel_decoration_v1 *decoration;
+    struct wl_listener request_mode;
+    struct wl_listener destroy;
+};
+
+struct mjc_keyboard {
+    struct wl_list link;
+    struct mjc_server *server;
+    struct wlr_keyboard *wlr_keyboard;
+    struct wl_listener modifiers;
+    struct wl_listener key;
+    struct wl_listener destroy;
+};
+
+struct mjc_output {
+    struct wl_list link;
+    struct mjc_server *server;
+    struct wlr_output *wlr_output;
+    struct wl_listener frame;
+    struct wl_listener request_state;
+    struct wl_listener destroy;
+};
+
+struct mjc_fd_source {
+    struct wl_list link;
+    struct mjc_server *server;
+    int fd;
+    struct wl_event_source *source;
+};
+
+/* ------------------------------------------------------------------ */
+/* helpers                                                             */
+/* ------------------------------------------------------------------ */
+
+static struct wlr_surface *view_surface(struct mjc_view *view) {
+    if (view->is_xwayland) {
+        return view->xsurface != NULL ? view->xsurface->surface : NULL;
+    }
+    return view->xdg_toplevel->base->surface;
+}
+
+static void emit_focus_change(struct mjc_server *server) {
+    if (server->cbs.focus_change != NULL) {
+        server->cbs.focus_change(server->ud, server->focused_view);
+    }
+}
+
+static void view_deactivate(struct mjc_view *view) {
+    if (view->is_xwayland) {
+        if (view->xsurface != NULL) {
+            wlr_xwayland_surface_activate(view->xsurface, false);
+        }
+    } else {
+        wlr_xdg_toplevel_set_activated(view->xdg_toplevel, false);
+    }
+}
+
+void mjc_view_focus(struct mjc_view *view) {
+    if (view == NULL || !view->focusable || view->minimized) {
+        return;
+    }
+    struct mjc_server *server = view->server;
+    struct wlr_seat *seat = server->seat;
+    if (server->focused_view == view) {
+        return;
+    }
+    if (server->focused_view != NULL) {
+        view_deactivate(server->focused_view);
+    }
+    if (view->scene_tree != NULL) {
+        wlr_scene_node_raise_to_top(&view->scene_tree->node);
+    }
+    if (view->is_xwayland) {
+        wlr_xwayland_surface_activate(view->xsurface, true);
+        wlr_xwayland_surface_restack(view->xsurface, NULL, XCB_STACK_MODE_ABOVE);
+    } else {
+        wlr_xdg_toplevel_set_activated(view->xdg_toplevel, true);
+    }
+    struct wlr_surface *surface = view_surface(view);
+    struct wlr_keyboard *keyboard = wlr_seat_get_keyboard(seat);
+    if (surface != NULL) {
+        if (keyboard != NULL) {
+            wlr_seat_keyboard_notify_enter(seat, surface,
+                keyboard->keycodes, keyboard->num_keycodes, &keyboard->modifiers);
+        } else {
+            wlr_seat_keyboard_notify_enter(seat, surface, NULL, 0, NULL);
+        }
+    }
+    server->focused_view = view;
+    emit_focus_change(server);
+}
+
+static void clear_focus_if(struct mjc_server *server, struct mjc_view *view) {
+    if (server->focused_view == view) {
+        server->focused_view = NULL;
+        emit_focus_change(server);
+    }
+    if (server->grabbed_view == view) {
+        server->grabbed_view = NULL;
+        server->cursor_mode = MJC_CURSOR_PASSTHROUGH;
+    }
+}
+
+static struct mjc_view *desktop_view_at(struct mjc_server *server,
+        double lx, double ly, struct wlr_surface **surface,
+        double *sx, double *sy) {
+    struct wlr_scene_node *node =
+        wlr_scene_node_at(&server->scene->tree.node, lx, ly, sx, sy);
+    if (node == NULL || node->type != WLR_SCENE_NODE_BUFFER) {
+        return NULL;
+    }
+    struct wlr_scene_buffer *scene_buffer = wlr_scene_buffer_from_node(node);
+    struct wlr_scene_surface *scene_surface =
+        wlr_scene_surface_try_from_buffer(scene_buffer);
+    if (scene_surface == NULL) {
+        return NULL;
+    }
+    *surface = scene_surface->surface;
+    struct wlr_scene_tree *tree = node->parent;
+    while (tree != NULL && tree->node.data == NULL) {
+        tree = tree->node.parent;
+    }
+    return tree != NULL ? tree->node.data : NULL;
+}
+
+/* ------------------------------------------------------------------ */
+/* outputs                                                             */
+/* ------------------------------------------------------------------ */
+
+static void output_frame(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_output *output = wl_container_of(listener, output, frame);
+    struct wlr_scene_output *scene_output = wlr_scene_get_scene_output(
+        output->server->scene, output->wlr_output);
+    if (scene_output == NULL) {
+        return;
+    }
+    wlr_scene_output_commit(scene_output, NULL);
+    struct timespec now;
+    clock_gettime(CLOCK_MONOTONIC, &now);
+    wlr_scene_output_send_frame_done(scene_output, &now);
+}
+
+static void output_request_state(struct wl_listener *listener, void *data) {
+    struct mjc_output *output = wl_container_of(listener, output, request_state);
+    (void) data; /* ignore the host-requested size: keep the nested output locked
+                  * to its fixed 16:9 mode so the window cannot be resized
+                  * (moving and closing still work via the host decoration) */
+    struct wlr_output_state state;
+    wlr_output_state_init(&state);
+    wlr_output_state_set_custom_mode(&state,
+        output->server->output_w, output->server->output_h, 0);
+    wlr_output_commit_state(output->wlr_output, &state);
+    wlr_output_state_finish(&state);
+}
+
+static void output_destroy(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_output *output = wl_container_of(listener, output, destroy);
+    struct mjc_server *server = output->server;
+    wl_list_remove(&output->frame.link);
+    wl_list_remove(&output->request_state.link);
+    wl_list_remove(&output->destroy.link);
+    wl_list_remove(&output->link);
+    free(output);
+    /* nested backends (x11/wayland) have no session; closing the host window
+     * destroys the output, so quit the compositor and let the run action finish.
+     * a real (drm) session keeps running when an output is unplugged. */
+    if (server->session == NULL) {
+        wl_display_terminate(server->display);
+    }
+}
+
+static void server_new_output(struct wl_listener *listener, void *data) {
+    struct mjc_server *server = wl_container_of(listener, server, new_output);
+    struct wlr_output *wlr_output = data;
+
+    wlr_output_init_render(wlr_output, server->allocator, server->renderer);
+
+    struct wlr_output_state state;
+    wlr_output_state_init(&state);
+    wlr_output_state_set_enabled(&state, true);
+    struct wlr_output_mode *mode = wlr_output_preferred_mode(wlr_output);
+    if (mode != NULL) {
+        wlr_output_state_set_mode(&state, mode);
+    } else {
+        /* wayland / headless backends expose no mode list, so preferred_mode is
+         * NULL; without a mode the output has no dimensions and renders black.
+         * pin it to the configured 16:9 size. */
+        wlr_output_state_set_custom_mode(&state,
+            server->output_w, server->output_h, 0);
+    }
+    wlr_output_commit_state(wlr_output, &state);
+    wlr_output_state_finish(&state);
+
+    struct mjc_output *output = calloc(1, sizeof(*output));
+    output->wlr_output = wlr_output;
+    output->server = server;
+
+    output->frame.notify = output_frame;
+    wl_signal_add(&wlr_output->events.frame, &output->frame);
+    output->request_state.notify = output_request_state;
+    wl_signal_add(&wlr_output->events.request_state, &output->request_state);
+    output->destroy.notify = output_destroy;
+    wl_signal_add(&wlr_output->events.destroy, &output->destroy);
+
+    wl_list_init(&output->link);
+
+    struct wlr_output_layout_output *l_output =
+        wlr_output_layout_add_auto(server->output_layout, wlr_output);
+    struct wlr_scene_output *scene_output =
+        wlr_scene_output_create(server->scene, wlr_output);
+    wlr_scene_output_layout_add_output(server->scene_layout, l_output, scene_output);
+}
+
+/* ------------------------------------------------------------------ */
+/* keyboard & input                                                    */
+/* ------------------------------------------------------------------ */
+
+static void keyboard_handle_modifiers(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_keyboard *keyboard = wl_container_of(listener, keyboard, modifiers);
+    wlr_seat_set_keyboard(keyboard->server->seat, keyboard->wlr_keyboard);
+    wlr_seat_keyboard_notify_modifiers(keyboard->server->seat,
+        &keyboard->wlr_keyboard->modifiers);
+}
+
+static void keyboard_handle_key(struct wl_listener *listener, void *data) {
+    struct mjc_keyboard *keyboard = wl_container_of(listener, keyboard, key);
+    struct mjc_server *server = keyboard->server;
+    struct wlr_keyboard_key_event *event = data;
+
+    uint32_t keycode = event->keycode + 8;
+    const xkb_keysym_t *syms;
+    int nsyms = xkb_state_key_get_syms(
+        keyboard->wlr_keyboard->xkb_state, keycode, &syms);
+    uint32_t modifiers = wlr_keyboard_get_modifiers(keyboard->wlr_keyboard);
+    bool pressed = event->state == WL_KEYBOARD_KEY_STATE_PRESSED;
+
+    bool handled = false;
+    for (int i = 0; i < nsyms; i++) {
+        /* VT switching always works */
+        if (pressed && syms[i] >= XKB_KEY_XF86Switch_VT_1 &&
+                syms[i] <= XKB_KEY_XF86Switch_VT_12) {
+            if (server->session != NULL) {
+                wlr_session_change_vt(server->session,
+                    syms[i] - XKB_KEY_XF86Switch_VT_1 + 1);
+            }
+            handled = true;
+            continue;
+        }
+        if (server->cbs.key != NULL &&
+                server->cbs.key(server->ud, syms[i], modifiers, pressed)) {
+            handled = true;
+        }
+    }
+
+    if (!handled) {
+        wlr_seat_set_keyboard(server->seat, keyboard->wlr_keyboard);
+        wlr_seat_keyboard_notify_key(server->seat,
+            event->time_msec, event->keycode, event->state);
+    }
+}
+
+static void keyboard_handle_destroy(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_keyboard *keyboard = wl_container_of(listener, keyboard, destroy);
+    wl_list_remove(&keyboard->modifiers.link);
+    wl_list_remove(&keyboard->key.link);
+    wl_list_remove(&keyboard->destroy.link);
+    wl_list_remove(&keyboard->link);
+    free(keyboard);
+}
+
+static void server_new_keyboard(struct mjc_server *server,
+        struct wlr_input_device *device) {
+    struct wlr_keyboard *wlr_keyboard = wlr_keyboard_from_input_device(device);
+
+    struct mjc_keyboard *keyboard = calloc(1, sizeof(*keyboard));
+    keyboard->server = server;
+    keyboard->wlr_keyboard = wlr_keyboard;
+
+    struct xkb_context *context = xkb_context_new(XKB_CONTEXT_NO_FLAGS);
+    struct xkb_keymap *keymap = xkb_keymap_new_from_names(context, NULL,
+        XKB_KEYMAP_COMPILE_NO_FLAGS);
+    wlr_keyboard_set_keymap(wlr_keyboard, keymap);
+    xkb_keymap_unref(keymap);
+    xkb_context_unref(context);
+    wlr_keyboard_set_repeat_info(wlr_keyboard, 25, 600);
+
+    keyboard->modifiers.notify = keyboard_handle_modifiers;
+    wl_signal_add(&wlr_keyboard->events.modifiers, &keyboard->modifiers);
+    keyboard->key.notify = keyboard_handle_key;
+    wl_signal_add(&wlr_keyboard->events.key, &keyboard->key);
+    keyboard->destroy.notify = keyboard_handle_destroy;
+    wl_signal_add(&device->events.destroy, &keyboard->destroy);
+
+    wlr_seat_set_keyboard(server->seat, wlr_keyboard);
+    wl_list_insert(&server->keyboards, &keyboard->link);
+}
+
+static void server_new_pointer(struct mjc_server *server,
+        struct wlr_input_device *device) {
+    wlr_cursor_attach_input_device(server->cursor, device);
+}
+
+static void server_new_input(struct wl_listener *listener, void *data) {
+    struct mjc_server *server = wl_container_of(listener, server, new_input);
+    struct wlr_input_device *device = data;
+    switch (device->type) {
+    case WLR_INPUT_DEVICE_KEYBOARD:
+        server_new_keyboard(server, device);
+        break;
+    case WLR_INPUT_DEVICE_POINTER:
+        server_new_pointer(server, device);
+        break;
+    default:
+        break;
+    }
+    uint32_t caps = WL_SEAT_CAPABILITY_POINTER;
+    if (!wl_list_empty(&server->keyboards)) {
+        caps |= WL_SEAT_CAPABILITY_KEYBOARD;
+    }
+    wlr_seat_set_capabilities(server->seat, caps);
+}
+
+static void seat_request_cursor(struct wl_listener *listener, void *data) {
+    struct mjc_server *server = wl_container_of(listener, server, request_cursor);
+    struct wlr_seat_pointer_request_set_cursor_event *event = data;
+    struct wlr_seat_client *focused_client =
+        server->seat->pointer_state.focused_client;
+    if (focused_client == event->seat_client) {
+        wlr_cursor_set_surface(server->cursor, event->surface,
+            event->hotspot_x, event->hotspot_y);
+    }
+}
+
+static void seat_request_set_selection(struct wl_listener *listener, void *data) {
+    struct mjc_server *server =
+        wl_container_of(listener, server, request_set_selection);
+    struct wlr_seat_request_set_selection_event *event = data;
+    wlr_seat_set_selection(server->seat, event->source, event->serial);
+}
+
+/* ------------------------------------------------------------------ */
+/* cursor                                                              */
+/* ------------------------------------------------------------------ */
+
+static void view_current_geometry(struct mjc_view *view, struct wlr_box *box);
+
+static void process_cursor_move(struct mjc_server *server) {
+    struct mjc_view *view = server->grabbed_view;
+    if (view == NULL || view->scene_tree == NULL) {
+        return;
+    }
+    int x = (int)(server->cursor->x - server->grab_x);
+    int y = (int)(server->cursor->y - server->grab_y);
+    wlr_scene_node_set_position(&view->scene_tree->node, x, y);
+    if (view->is_xwayland && view->xsurface != NULL) {
+        wlr_xwayland_surface_configure(view->xsurface,
+            (int16_t) x, (int16_t) y,
+            (uint16_t) view->xsurface->width, (uint16_t) view->xsurface->height);
+    }
+}
+
+static void process_cursor_resize(struct mjc_server *server) {
+    struct mjc_view *view = server->grabbed_view;
+    if (view == NULL || view->scene_tree == NULL) {
+        return;
+    }
+    double border_x = server->cursor->x - server->grab_x;
+    double border_y = server->cursor->y - server->grab_y;
+    int new_left = server->grab_geobox.x;
+    int new_right = server->grab_geobox.x + server->grab_geobox.width;
+    int new_top = server->grab_geobox.y;
+    int new_bottom = server->grab_geobox.y + server->grab_geobox.height;
+
+    if (server->resize_edges & WLR_EDGE_TOP) {
+        new_top = (int) border_y;
+        if (new_top >= new_bottom) {
+            new_top = new_bottom - 1;
+        }
+    } else if (server->resize_edges & WLR_EDGE_BOTTOM) {
+        new_bottom = (int) border_y;
+        if (new_bottom <= new_top) {
+            new_bottom = new_top + 1;
+        }
+    }
+    if (server->resize_edges & WLR_EDGE_LEFT) {
+        new_left = (int) border_x;
+        if (new_left >= new_right) {
+            new_left = new_right - 1;
+        }
+    } else if (server->resize_edges & WLR_EDGE_RIGHT) {
+        new_right = (int) border_x;
+        if (new_right <= new_left) {
+            new_right = new_left + 1;
+        }
+    }
+
+    int new_width = new_right - new_left;
+    int new_height = new_bottom - new_top;
+
+    if (view->is_xwayland && view->xsurface != NULL) {
+        wlr_scene_node_set_position(&view->scene_tree->node, new_left, new_top);
+        wlr_xwayland_surface_configure(view->xsurface,
+            (int16_t) new_left, (int16_t) new_top,
+            (uint16_t) new_width, (uint16_t) new_height);
+    } else {
+        struct wlr_box geo_box;
+        wlr_xdg_surface_get_geometry(view->xdg_toplevel->base, &geo_box);
+        wlr_scene_node_set_position(&view->scene_tree->node,
+            new_left - geo_box.x, new_top - geo_box.y);
+        wlr_xdg_toplevel_set_size(view->xdg_toplevel, new_width, new_height);
+    }
+}
+
+static void process_cursor_motion(struct mjc_server *server, uint32_t time) {
+    if (server->cursor_mode == MJC_CURSOR_MOVE) {
+        process_cursor_move(server);
+        return;
+    } else if (server->cursor_mode == MJC_CURSOR_RESIZE) {
+        process_cursor_resize(server);
+        return;
+    }
+
+    double sx, sy;
+    struct wlr_seat *seat = server->seat;
+    struct wlr_surface *surface = NULL;
+    desktop_view_at(server, server->cursor->x, server->cursor->y,
+        &surface, &sx, &sy);
+    if (surface == NULL) {
+        wlr_cursor_set_xcursor(server->cursor, server->cursor_mgr, "default");
+    }
+    if (surface != NULL) {
+        wlr_seat_pointer_notify_enter(seat, surface, sx, sy);
+        wlr_seat_pointer_notify_motion(seat, time, sx, sy);
+    } else {
+        wlr_seat_pointer_clear_focus(seat);
+    }
+
+    // broadcast the pointer position to ipc clients (throttled), so the shell can do
+    // edge detection without the AWT global pointer which is unreliable under nested XWayland
+    if (server->cbs.pointer != NULL && (time - server->last_pointer_ms) >= 30u) {
+        server->last_pointer_ms = time;
+        server->cbs.pointer(server->ud,
+            (int) server->cursor->x, (int) server->cursor->y);
+    }
+}
+
+static void server_cursor_motion(struct wl_listener *listener, void *data) {
+    struct mjc_server *server = wl_container_of(listener, server, cursor_motion);
+    struct wlr_pointer_motion_event *event = data;
+    wlr_cursor_move(server->cursor, &event->pointer->base,
+        event->delta_x, event->delta_y);
+    process_cursor_motion(server, event->time_msec);
+}
+
+static void server_cursor_motion_absolute(struct wl_listener *listener, void *data) {
+    struct mjc_server *server =
+        wl_container_of(listener, server, cursor_motion_absolute);
+    struct wlr_pointer_motion_absolute_event *event = data;
+    wlr_cursor_warp_absolute(server->cursor, &event->pointer->base,
+        event->x, event->y);
+    process_cursor_motion(server, event->time_msec);
+}
+
+static void server_cursor_button(struct wl_listener *listener, void *data) {
+    struct mjc_server *server = wl_container_of(listener, server, cursor_button);
+    struct wlr_pointer_button_event *event = data;
+    wlr_seat_pointer_notify_button(server->seat,
+        event->time_msec, event->button, event->state);
+
+    if (event->state == WL_POINTER_BUTTON_STATE_RELEASED) {
+        server->cursor_mode = MJC_CURSOR_PASSTHROUGH;
+        server->grabbed_view = NULL;
+    } else {
+        double sx, sy;
+        struct wlr_surface *surface = NULL;
+        struct mjc_view *view = desktop_view_at(server,
+            server->cursor->x, server->cursor->y, &surface, &sx, &sy);
+        if (view != NULL && view->focusable) {
+            mjc_view_focus(view);
+        }
+    }
+}
+
+static void server_cursor_axis(struct wl_listener *listener, void *data) {
+    struct mjc_server *server = wl_container_of(listener, server, cursor_axis);
+    struct wlr_pointer_axis_event *event = data;
+    wlr_seat_pointer_notify_axis(server->seat,
+        event->time_msec, event->orientation, event->delta,
+        event->delta_discrete, event->source, event->relative_direction);
+}
+
+static void server_cursor_frame(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_server *server = wl_container_of(listener, server, cursor_frame);
+    wlr_seat_pointer_notify_frame(server->seat);
+}
+
+/* ------------------------------------------------------------------ */
+/* interactive move/resize                                             */
+/* ------------------------------------------------------------------ */
+
+static void view_current_geometry(struct mjc_view *view, struct wlr_box *box) {
+    if (view->is_xwayland && view->xsurface != NULL) {
+        box->x = view->scene_tree != NULL ? view->scene_tree->node.x : view->xsurface->x;
+        box->y = view->scene_tree != NULL ? view->scene_tree->node.y : view->xsurface->y;
+        box->width = view->xsurface->width;
+        box->height = view->xsurface->height;
+    } else {
+        struct wlr_box geo;
+        wlr_xdg_surface_get_geometry(view->xdg_toplevel->base, &geo);
+        box->x = view->scene_tree->node.x + geo.x;
+        box->y = view->scene_tree->node.y + geo.y;
+        box->width = geo.width;
+        box->height = geo.height;
+    }
+}
+
+static void begin_interactive(struct mjc_view *view,
+        enum mjc_cursor_mode mode, uint32_t edges) {
+    struct mjc_server *server = view->server;
+    if (view_surface(view) !=
+            server->seat->pointer_state.focused_surface) {
+        /* only react to requests from the focused client */
+        return;
+    }
+    server->grabbed_view = view;
+    server->cursor_mode = mode;
+
+    struct wlr_box geo_box;
+    view_current_geometry(view, &geo_box);
+
+    if (mode == MJC_CURSOR_MOVE) {
+        server->grab_x = server->cursor->x - view->scene_tree->node.x;
+        server->grab_y = server->cursor->y - view->scene_tree->node.y;
+    } else {
+        double border_x = geo_box.x +
+            ((edges & WLR_EDGE_RIGHT) ? geo_box.width : 0);
+        double border_y = geo_box.y +
+            ((edges & WLR_EDGE_BOTTOM) ? geo_box.height : 0);
+        server->grab_x = server->cursor->x - border_x;
+        server->grab_y = server->cursor->y - border_y;
+        server->grab_geobox = geo_box;
+        server->resize_edges = edges;
+    }
+}
+
+/* ------------------------------------------------------------------ */
+/* view common                                                         */
+/* ------------------------------------------------------------------ */
+
+/* places a freshly mapped floating view in the middle of the output */
+static void view_center(struct mjc_view *view) {
+    struct mjc_server *server = view->server;
+    if (view->scene_tree == NULL || view->maximized) {
+        return;
+    }
+    struct wlr_box out;
+    wlr_output_layout_get_box(server->output_layout, NULL, &out);
+    struct wlr_box geo;
+    view_current_geometry(view, &geo);
+    if (out.width <= 0 || geo.width <= 0) {
+        return;
+    }
+    int x = out.x + (out.width - geo.width) / 2;
+    int y = out.y + (out.height - geo.height) / 2;
+    if (x < out.x) x = out.x;
+    if (y < out.y) y = out.y;
+    if (view->is_xwayland) {
+        mjc_view_set_position(view, x, y);
+    } else {
+        struct wlr_box off;
+        wlr_xdg_surface_get_geometry(view->xdg_toplevel->base, &off);
+        wlr_scene_node_set_position(&view->scene_tree->node, x - off.x, y - off.y);
+    }
+}
+
+static void view_emit_new(struct mjc_view *view) {
+    if (view->server->cbs.view_new != NULL) {
+        view->server->cbs.view_new(view->server->ud, view, view->is_xwayland);
+    }
+}
+
+static void view_emit_map(struct mjc_view *view) {
+    if (view->server->cbs.view_map != NULL) {
+        view->server->cbs.view_map(view->server->ud, view);
+    }
+}
+
+static void view_emit_unmap(struct mjc_view *view) {
+    if (view->server->cbs.view_unmap != NULL) {
+        view->server->cbs.view_unmap(view->server->ud, view);
+    }
+}
+
+static void view_emit_destroy(struct mjc_view *view) {
+    if (view->server->cbs.view_destroy != NULL) {
+        view->server->cbs.view_destroy(view->server->ud, view);
+    }
+}
+
+static struct mjc_view *view_create(struct mjc_server *server, bool is_xwayland) {
+    struct mjc_view *view = calloc(1, sizeof(*view));
+    view->server = server;
+    view->id = ++server->next_view_id;
+    view->is_xwayland = is_xwayland;
+    view->focusable = true;
+    view->layer = MJC_LAYER_NORMAL;
+    wl_list_insert(&server->views, &view->link);
+    return view;
+}
+
+static void view_free(struct mjc_view *view) {
+    clear_focus_if(view->server, view);
+    view_emit_destroy(view);
+    wl_list_remove(&view->link);
+    free(view);
+}
+
+/* ------------------------------------------------------------------ */
+/* xdg shell views                                                     */
+/* ------------------------------------------------------------------ */
+
+static void xdg_toplevel_map(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_view *view = wl_container_of(listener, view, map);
+    view->mapped = true;
+    view_center(view);
+    view_emit_map(view);
+    mjc_view_focus(view);
+}
+
+static void xdg_toplevel_unmap(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_view *view = wl_container_of(listener, view, unmap);
+    view->mapped = false;
+    clear_focus_if(view->server, view);
+    view_emit_unmap(view);
+}
+
+static void xdg_toplevel_commit(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_view *view = wl_container_of(listener, view, commit);
+    if (view->xdg_toplevel->base->initial_commit) {
+        if (view->xdg_toplevel->requested.maximized) {
+            mjc_view_set_maximized(view, true);
+        } else {
+            /* 0x0 lets the client pick its own (or remembered) size */
+            wlr_xdg_toplevel_set_size(view->xdg_toplevel, 0, 0);
+        }
+        return;
+    }
+    if (view->pending_center && view->mapped && !view->maximized &&
+            !view->xdg_toplevel->current.maximized) {
+        struct wlr_box geo;
+        wlr_xdg_surface_get_geometry(view->xdg_toplevel->base, &geo);
+        if (geo.width > 0) {
+            view->pending_center = false;
+            if (view->saved_geo.width > 0 && view->scene_tree != NULL) {
+                /* saved coords are visible coords, geo.x/y the offsets */
+                wlr_scene_node_set_position(&view->scene_tree->node,
+                    view->saved_geo.x - geo.x, view->saved_geo.y - geo.y);
+            } else {
+                view_center(view);
+            }
+        }
+    }
+}
+
+static void xdg_toplevel_destroy(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_view *view = wl_container_of(listener, view, destroy);
+    wl_list_remove(&view->map.link);
+    wl_list_remove(&view->unmap.link);
+    wl_list_remove(&view->commit.link);
+    wl_list_remove(&view->destroy.link);
+    wl_list_remove(&view->request_move.link);
+    wl_list_remove(&view->request_resize.link);
+    wl_list_remove(&view->request_maximize.link);
+    wl_list_remove(&view->request_minimize.link);
+    wl_list_remove(&view->request_fullscreen.link);
+    wl_list_remove(&view->set_title.link);
+    wl_list_remove(&view->set_app_id.link);
+    view_free(view);
+}
+
+static void xdg_toplevel_request_move(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_view *view = wl_container_of(listener, view, request_move);
+    begin_interactive(view, MJC_CURSOR_MOVE, 0);
+}
+
+static void xdg_toplevel_request_resize(struct wl_listener *listener, void *data) {
+    struct wlr_xdg_toplevel_resize_event *event = data;
+    struct mjc_view *view = wl_container_of(listener, view, request_resize);
+    begin_interactive(view, MJC_CURSOR_RESIZE, event->edges);
+}
+
+static void xdg_toplevel_request_maximize(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_view *view = wl_container_of(listener, view, request_maximize);
+    if (view->xdg_toplevel->base->initialized) {
+        mjc_view_set_maximized(view, view->xdg_toplevel->requested.maximized);
+        wlr_xdg_surface_schedule_configure(view->xdg_toplevel->base);
+    }
+}
+
+static void xdg_toplevel_request_minimize(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_view *view = wl_container_of(listener, view, request_minimize);
+    mjc_view_set_minimized(view, true);
+}
+
+static void xdg_toplevel_request_fullscreen(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_view *view = wl_container_of(listener, view, request_fullscreen);
+    if (view->xdg_toplevel->base->initialized) {
+        wlr_xdg_surface_schedule_configure(view->xdg_toplevel->base);
+    }
+}
+
+static void xdg_toplevel_set_title(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_view *view = wl_container_of(listener, view, set_title);
+    if (view->server->cbs.view_title != NULL) {
+        view->server->cbs.view_title(view->server->ud, view,
+            view->xdg_toplevel->title);
+    }
+}
+
+static void xdg_toplevel_set_app_id(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_view *view = wl_container_of(listener, view, set_app_id);
+    if (view->server->cbs.view_app_id != NULL) {
+        view->server->cbs.view_app_id(view->server->ud, view,
+            view->xdg_toplevel->app_id);
+    }
+}
+
+static void server_new_xdg_toplevel(struct wl_listener *listener, void *data) {
+    struct mjc_server *server =
+        wl_container_of(listener, server, new_xdg_toplevel);
+    struct wlr_xdg_toplevel *xdg_toplevel = data;
+
+    struct mjc_view *view = view_create(server, false);
+    view->xdg_toplevel = xdg_toplevel;
+    view->scene_tree = wlr_scene_xdg_surface_create(
+        server->layers[MJC_LAYER_NORMAL], xdg_toplevel->base);
+    view->scene_tree->node.data = view;
+    xdg_toplevel->base->data = view->scene_tree;
+
+    view->map.notify = xdg_toplevel_map;
+    wl_signal_add(&xdg_toplevel->base->surface->events.map, &view->map);
+    view->unmap.notify = xdg_toplevel_unmap;
+    wl_signal_add(&xdg_toplevel->base->surface->events.unmap, &view->unmap);
+    view->commit.notify = xdg_toplevel_commit;
+    wl_signal_add(&xdg_toplevel->base->surface->events.commit, &view->commit);
+    view->destroy.notify = xdg_toplevel_destroy;
+    wl_signal_add(&xdg_toplevel->events.destroy, &view->destroy);
+
+    view->request_move.notify = xdg_toplevel_request_move;
+    wl_signal_add(&xdg_toplevel->events.request_move, &view->request_move);
+    view->request_resize.notify = xdg_toplevel_request_resize;
+    wl_signal_add(&xdg_toplevel->events.request_resize, &view->request_resize);
+    view->request_maximize.notify = xdg_toplevel_request_maximize;
+    wl_signal_add(&xdg_toplevel->events.request_maximize, &view->request_maximize);
+    view->request_minimize.notify = xdg_toplevel_request_minimize;
+    wl_signal_add(&xdg_toplevel->events.request_minimize, &view->request_minimize);
+    view->request_fullscreen.notify = xdg_toplevel_request_fullscreen;
+    wl_signal_add(&xdg_toplevel->events.request_fullscreen, &view->request_fullscreen);
+    view->set_title.notify = xdg_toplevel_set_title;
+    wl_signal_add(&xdg_toplevel->events.set_title, &view->set_title);
+    view->set_app_id.notify = xdg_toplevel_set_app_id;
+    wl_signal_add(&xdg_toplevel->events.set_app_id, &view->set_app_id);
+
+    view_emit_new(view);
+}
+
+/* popups */
+
+static void xdg_popup_commit(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_popup *popup = wl_container_of(listener, popup, commit);
+    if (popup->xdg_popup->base->initial_commit) {
+        wlr_xdg_surface_schedule_configure(popup->xdg_popup->base);
+    }
+}
+
+static void xdg_popup_destroy(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_popup *popup = wl_container_of(listener, popup, destroy);
+    wl_list_remove(&popup->commit.link);
+    wl_list_remove(&popup->destroy.link);
+    free(popup);
+}
+
+static void server_new_xdg_popup(struct wl_listener *listener, void *data) {
+    (void) listener;
+    struct wlr_xdg_popup *xdg_popup = data;
+
+    struct wlr_xdg_surface *parent =
+        wlr_xdg_surface_try_from_wlr_surface(xdg_popup->parent);
+    if (parent == NULL || parent->data == NULL) {
+        return;
+    }
+    struct wlr_scene_tree *parent_tree = parent->data;
+    xdg_popup->base->data =
+        wlr_scene_xdg_surface_create(parent_tree, xdg_popup->base);
+
+    struct mjc_popup *popup = calloc(1, sizeof(*popup));
+    popup->xdg_popup = xdg_popup;
+    popup->commit.notify = xdg_popup_commit;
+    wl_signal_add(&xdg_popup->base->surface->events.commit, &popup->commit);
+    popup->destroy.notify = xdg_popup_destroy;
+    wl_signal_add(&xdg_popup->events.destroy, &popup->destroy);
+}
+
+/* decorations - clients draw their own buttons (CSD), the compositor
+ * renders no frames, so server side mode is never granted */
+
+static void decoration_request_mode(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_decoration *deco = wl_container_of(listener, deco, request_mode);
+    if (deco->decoration->toplevel->base->initialized) {
+        wlr_xdg_toplevel_decoration_v1_set_mode(deco->decoration,
+            WLR_XDG_TOPLEVEL_DECORATION_V1_MODE_CLIENT_SIDE);
+    }
+}
+
+static void decoration_destroy(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_decoration *deco = wl_container_of(listener, deco, destroy);
+    wl_list_remove(&deco->request_mode.link);
+    wl_list_remove(&deco->destroy.link);
+    free(deco);
+}
+
+static void server_new_toplevel_decoration(struct wl_listener *listener,
+        void *data) {
+    (void) listener;
+    struct wlr_xdg_toplevel_decoration_v1 *decoration = data;
+    struct mjc_decoration *deco = calloc(1, sizeof(*deco));
+    deco->decoration = decoration;
+    deco->request_mode.notify = decoration_request_mode;
+    wl_signal_add(&decoration->events.request_mode, &deco->request_mode);
+    deco->destroy.notify = decoration_destroy;
+    wl_signal_add(&decoration->events.destroy, &deco->destroy);
+    decoration_request_mode(&deco->request_mode, NULL);
+}
+
+/* ------------------------------------------------------------------ */
+/* xwayland views                                                      */
+/* ------------------------------------------------------------------ */
+
+static void xwayland_surface_map(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_view *view = wl_container_of(listener, view, map);
+    struct mjc_server *server = view->server;
+    struct wlr_xwayland_surface *xsurface = view->xsurface;
+
+    mjc_layer layer = xsurface->override_redirect
+        ? MJC_LAYER_OVERLAY : view->layer;
+    view->scene_tree = wlr_scene_tree_create(server->layers[layer]);
+    view->scene_tree->node.data = view;
+    wlr_scene_surface_create(view->scene_tree, xsurface->surface);
+    wlr_scene_node_set_position(&view->scene_tree->node,
+        xsurface->x, xsurface->y);
+
+    view->mapped = true;
+    /* X clients that did not place themselves land at 0,0 - center those */
+    if (!xsurface->override_redirect && xsurface->x == 0 && xsurface->y == 0) {
+        view_center(view);
+    }
+    view_emit_map(view);
+    if (!xsurface->override_redirect) {
+        mjc_view_focus(view);
+    }
+}
+
+static void xwayland_surface_unmap(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_view *view = wl_container_of(listener, view, unmap);
+    view->mapped = false;
+    clear_focus_if(view->server, view);
+    /* emit while the scene node still exists so the policy side can
+     * read the last window geometry */
+    view_emit_unmap(view);
+    if (view->scene_tree != NULL) {
+        wlr_scene_node_destroy(&view->scene_tree->node);
+        view->scene_tree = NULL;
+    }
+}
+
+static void xwayland_surface_associate(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_view *view = wl_container_of(listener, view, associate);
+    view->map.notify = xwayland_surface_map;
+    wl_signal_add(&view->xsurface->surface->events.map, &view->map);
+    view->unmap.notify = xwayland_surface_unmap;
+    wl_signal_add(&view->xsurface->surface->events.unmap, &view->unmap);
+}
+
+static void xwayland_surface_dissociate(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_view *view = wl_container_of(listener, view, dissociate);
+    wl_list_remove(&view->map.link);
+    wl_list_remove(&view->unmap.link);
+}
+
+static void xwayland_surface_destroy(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_view *view = wl_container_of(listener, view, destroy);
+    wl_list_remove(&view->associate.link);
+    wl_list_remove(&view->dissociate.link);
+    wl_list_remove(&view->destroy.link);
+    wl_list_remove(&view->request_configure.link);
+    wl_list_remove(&view->request_move.link);
+    wl_list_remove(&view->request_resize.link);
+    wl_list_remove(&view->request_minimize.link);
+    wl_list_remove(&view->request_maximize.link);
+    wl_list_remove(&view->set_title.link);
+    wl_list_remove(&view->set_app_id.link);
+    wl_list_remove(&view->set_geometry.link);
+    if (view->scene_tree != NULL) {
+        wlr_scene_node_destroy(&view->scene_tree->node);
+        view->scene_tree = NULL;
+    }
+    view_free(view);
+}
+
+static void xwayland_surface_request_configure(struct wl_listener *listener,
+        void *data) {
+    struct mjc_view *view = wl_container_of(listener, view, request_configure);
+    struct wlr_xwayland_surface_configure_event *event = data;
+    wlr_xwayland_surface_configure(view->xsurface,
+        event->x, event->y, event->width, event->height);
+    if (view->scene_tree != NULL) {
+        wlr_scene_node_set_position(&view->scene_tree->node, event->x, event->y);
+    }
+}
+
+static void xwayland_surface_set_geometry(struct wl_listener *listener,
+        void *data) {
+    (void) data;
+    struct mjc_view *view = wl_container_of(listener, view, set_geometry);
+    if (view->scene_tree != NULL && view->xsurface != NULL) {
+        wlr_scene_node_set_position(&view->scene_tree->node,
+            view->xsurface->x, view->xsurface->y);
+    }
+}
+
+static void xwayland_surface_request_move(struct wl_listener *listener,
+        void *data) {
+    (void) data;
+    struct mjc_view *view = wl_container_of(listener, view, request_move);
+    begin_interactive(view, MJC_CURSOR_MOVE, 0);
+}
+
+static void xwayland_surface_request_resize(struct wl_listener *listener,
+        void *data) {
+    struct mjc_view *view = wl_container_of(listener, view, request_resize);
+    struct wlr_xwayland_resize_event *event = data;
+    begin_interactive(view, MJC_CURSOR_RESIZE, event->edges);
+}
+
+static void xwayland_surface_request_minimize(struct wl_listener *listener,
+        void *data) {
+    (void) data;
+    struct mjc_view *view = wl_container_of(listener, view, request_minimize);
+    mjc_view_set_minimized(view, true);
+}
+
+static void xwayland_surface_request_maximize(struct wl_listener *listener,
+        void *data) {
+    (void) data;
+    struct mjc_view *view = wl_container_of(listener, view, request_maximize);
+    struct wlr_xwayland_surface *xsurface = view->xsurface;
+    if (xsurface == NULL) {
+        return;
+    }
+    /* the client announces the state it wants via _NET_WM_STATE */
+    mjc_view_set_maximized(view,
+        xsurface->maximized_vert && xsurface->maximized_horz);
+}
+
+static void xwayland_surface_set_title(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_view *view = wl_container_of(listener, view, set_title);
+    if (view->server->cbs.view_title != NULL) {
+        view->server->cbs.view_title(view->server->ud, view,
+            view->xsurface->title);
+    }
+}
+
+static void xwayland_surface_set_class(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_view *view = wl_container_of(listener, view, set_app_id);
+    if (view->server->cbs.view_app_id != NULL) {
+        view->server->cbs.view_app_id(view->server->ud, view,
+            view->xsurface->class);
+    }
+}
+
+static void server_new_xwayland_surface(struct wl_listener *listener, void *data) {
+    struct mjc_server *server =
+        wl_container_of(listener, server, new_xwayland_surface);
+    struct wlr_xwayland_surface *xsurface = data;
+
+    struct mjc_view *view = view_create(server, true);
+    view->xsurface = xsurface;
+    if (xsurface->override_redirect) {
+        view->focusable = false;
+    }
+    xsurface->data = view;
+
+    view->associate.notify = xwayland_surface_associate;
+    wl_signal_add(&xsurface->events.associate, &view->associate);
+    view->dissociate.notify = xwayland_surface_dissociate;
+    wl_signal_add(&xsurface->events.dissociate, &view->dissociate);
+    view->destroy.notify = xwayland_surface_destroy;
+    wl_signal_add(&xsurface->events.destroy, &view->destroy);
+    view->request_configure.notify = xwayland_surface_request_configure;
+    wl_signal_add(&xsurface->events.request_configure, &view->request_configure);
+    view->request_move.notify = xwayland_surface_request_move;
+    wl_signal_add(&xsurface->events.request_move, &view->request_move);
+    view->request_resize.notify = xwayland_surface_request_resize;
+    wl_signal_add(&xsurface->events.request_resize, &view->request_resize);
+    view->request_minimize.notify = xwayland_surface_request_minimize;
+    wl_signal_add(&xsurface->events.request_minimize, &view->request_minimize);
+    view->request_maximize.notify = xwayland_surface_request_maximize;
+    wl_signal_add(&xsurface->events.request_maximize, &view->request_maximize);
+    view->set_title.notify = xwayland_surface_set_title;
+    wl_signal_add(&xsurface->events.set_title, &view->set_title);
+    view->set_app_id.notify = xwayland_surface_set_class;
+    wl_signal_add(&xsurface->events.set_class, &view->set_app_id);
+    view->set_geometry.notify = xwayland_surface_set_geometry;
+    wl_signal_add(&xsurface->events.set_geometry, &view->set_geometry);
+
+    view_emit_new(view);
+}
+
+static void server_xwayland_ready(struct wl_listener *listener, void *data) {
+    (void) data;
+    struct mjc_server *server = wl_container_of(listener, server, xwayland_ready);
+    wlr_xwayland_set_seat(server->xwayland, server->seat);
+}
+
+/* ------------------------------------------------------------------ */
+/* sigchld                                                             */
+/* ------------------------------------------------------------------ */
+
+static int handle_sigchld(int signal_number, void *data) {
+    (void) signal_number;
+    struct mjc_server *server = data;
+    for (int i = 0; i < server->spawned_count;) {
+        int status;
+        pid_t pid = waitpid(server->spawned_pids[i], &status, WNOHANG);
+        if (pid > 0) {
+            server->spawned_pids[i] =
+                server->spawned_pids[--server->spawned_count];
+            if (server->cbs.child_exit != NULL) {
+                server->cbs.child_exit(server->ud, (int) pid, status);
+            }
+        } else {
+            i++;
+        }
+    }
+    return 0;
+}
+
+/* ------------------------------------------------------------------ */
+/* public API                                                          */
+/* ------------------------------------------------------------------ */
+
+mjc_server *mjc_create(void) {
+    struct mjc_server *server = calloc(1, sizeof(*server));
+    wl_list_init(&server->views);
+    wl_list_init(&server->keyboards);
+    wl_list_init(&server->fd_sources);
+    return server;
+}
+
+bool mjc_start(mjc_server *server, const mjc_callbacks *callbacks, void *userdata) {
+    server->cbs = *callbacks;
+    server->ud = userdata;
+
+    wlr_log_init(WLR_INFO, NULL);
+
+    /* nested-output size, 16:9 default; override with MJDEVC_OUTPUT=WIDTHxHEIGHT */
+    server->output_w = 1280;
+    server->output_h = 720;
+    const char *out_env = getenv("MJDEVC_OUTPUT");
+    if (out_env != NULL) {
+        int w = 0, h = 0;
+        if (sscanf(out_env, "%dx%d", &w, &h) == 2 && w > 0 && h > 0) {
+            server->output_w = w;
+            server->output_h = h;
+        }
+    }
+
+    server->display = wl_display_create();
+    server->loop = wl_display_get_event_loop(server->display);
+    server->backend = wlr_backend_autocreate(server->loop, &server->session);
+    if (server->backend == NULL) {
+        fprintf(stderr, "mjdevc: failed to create wlr_backend\n");
+        return false;
+    }
+
+    server->renderer = wlr_renderer_autocreate(server->backend);
+    if (server->renderer == NULL) {
+        fprintf(stderr, "mjdevc: failed to create wlr_renderer\n");
+        return false;
+    }
+    wlr_renderer_init_wl_display(server->renderer, server->display);
+
+    server->allocator = wlr_allocator_autocreate(server->backend, server->renderer);
+    if (server->allocator == NULL) {
+        fprintf(stderr, "mjdevc: failed to create wlr_allocator\n");
+        return false;
+    }
+
+    server->compositor = wlr_compositor_create(server->display, 5, server->renderer);
+    wlr_subcompositor_create(server->display);
+    wlr_data_device_manager_create(server->display);
+
+    server->output_layout = wlr_output_layout_create(server->display);
+    server->new_output.notify = server_new_output;
+    wl_signal_add(&server->backend->events.new_output, &server->new_output);
+
+    server->scene = wlr_scene_create();
+    server->scene_layout =
+        wlr_scene_attach_output_layout(server->scene, server->output_layout);
+    for (int i = 0; i < 5; i++) {
+        server->layers[i] = wlr_scene_tree_create(&server->scene->tree);
+    }
+
+    server->xdg_shell = wlr_xdg_shell_create(server->display, 3);
+    server->new_xdg_toplevel.notify = server_new_xdg_toplevel;
+    wl_signal_add(&server->xdg_shell->events.new_toplevel, &server->new_xdg_toplevel);
+    server->new_xdg_popup.notify = server_new_xdg_popup;
+    wl_signal_add(&server->xdg_shell->events.new_popup, &server->new_xdg_popup);
+
+    server->xdg_decoration_mgr = wlr_xdg_decoration_manager_v1_create(server->display);
+    server->new_toplevel_decoration.notify = server_new_toplevel_decoration;
+    wl_signal_add(&server->xdg_decoration_mgr->events.new_toplevel_decoration,
+        &server->new_toplevel_decoration);
+
+    server->cursor = wlr_cursor_create();
+    wlr_cursor_attach_output_layout(server->cursor, server->output_layout);
+    server->cursor_mgr = wlr_xcursor_manager_create(NULL, 24);
+    server->cursor_mode = MJC_CURSOR_PASSTHROUGH;
+    server->cursor_motion.notify = server_cursor_motion;
+    wl_signal_add(&server->cursor->events.motion, &server->cursor_motion);
+    server->cursor_motion_absolute.notify = server_cursor_motion_absolute;
+    wl_signal_add(&server->cursor->events.motion_absolute,
+        &server->cursor_motion_absolute);
+    server->cursor_button.notify = server_cursor_button;
+    wl_signal_add(&server->cursor->events.button, &server->cursor_button);
+    server->cursor_axis.notify = server_cursor_axis;
+    wl_signal_add(&server->cursor->events.axis, &server->cursor_axis);
+    server->cursor_frame.notify = server_cursor_frame;
+    wl_signal_add(&server->cursor->events.frame, &server->cursor_frame);
+
+    server->new_input.notify = server_new_input;
+    wl_signal_add(&server->backend->events.new_input, &server->new_input);
+    server->seat = wlr_seat_create(server->display, "seat0");
+    server->request_cursor.notify = seat_request_cursor;
+    wl_signal_add(&server->seat->events.request_set_cursor, &server->request_cursor);
+    server->request_set_selection.notify = seat_request_set_selection;
+    wl_signal_add(&server->seat->events.request_set_selection,
+        &server->request_set_selection);
+
+    const char *socket = wl_display_add_socket_auto(server->display);
+    if (socket == NULL) {
+        fprintf(stderr, "mjdevc: failed to create wayland socket\n");
+        return false;
+    }
+    server->socket = socket;
+
+    server->xwayland = wlr_xwayland_create(server->display, server->compositor, true);
+    if (server->xwayland != NULL) {
+        server->new_xwayland_surface.notify = server_new_xwayland_surface;
+        wl_signal_add(&server->xwayland->events.new_surface,
+            &server->new_xwayland_surface);
+        server->xwayland_ready.notify = server_xwayland_ready;
+        wl_signal_add(&server->xwayland->events.ready, &server->xwayland_ready);
+    }
+
+    server->sigchld_source = wl_event_loop_add_signal(server->loop,
+        SIGCHLD, handle_sigchld, server);
+
+    if (!wlr_backend_start(server->backend)) {
+        fprintf(stderr, "mjdevc: failed to start backend\n");
+        return false;
+    }
+
+    setenv("WAYLAND_DISPLAY", socket, true);
+    if (server->xwayland != NULL) {
+        setenv("DISPLAY", server->xwayland->display_name, true);
+    }
+
+    if (server->cbs.ready != NULL) {
+        server->cbs.ready(server->ud);
+    }
+    return true;
+}
+
+void mjc_run(mjc_server *server) {
+    wl_display_run(server->display);
+}
+
+void mjc_terminate(mjc_server *server) {
+    wl_display_terminate(server->display);
+}
+
+void mjc_destroy(mjc_server *server) {
+    if (server->xwayland != NULL) {
+        wlr_xwayland_destroy(server->xwayland);
+    }
+    wl_display_destroy_clients(server->display);
+    wlr_scene_node_destroy(&server->scene->tree.node);
+    wlr_xcursor_manager_destroy(server->cursor_mgr);
+    wlr_cursor_destroy(server->cursor);
+    wlr_allocator_destroy(server->allocator);
+    wlr_renderer_destroy(server->renderer);
+    wlr_backend_destroy(server->backend);
+    wl_display_destroy(server->display);
+    free(server);
+}
+
+const char *mjc_socket_name(mjc_server *server) {
+    return server->socket;
+}
+
+const char *mjc_xwayland_display(mjc_server *server) {
+    return server->xwayland != NULL ? server->xwayland->display_name : NULL;
+}
+
+void mjc_output_size(mjc_server *server, int *width, int *height) {
+    struct wlr_box box;
+    wlr_output_layout_get_box(server->output_layout, NULL, &box);
+    *width = box.width;
+    *height = box.height;
+}
+
+int mjc_spawn(mjc_server *server, const char *command) {
+    pid_t pid = fork();
+    if (pid < 0) {
+        return -1;
+    }
+    if (pid == 0) {
+        setsid();
+        sigset_t set;
+        sigemptyset(&set);
+        sigprocmask(SIG_SETMASK, &set, NULL);
+        execl("/bin/sh", "/bin/sh", "-c", command, (void *) NULL);
+        _exit(127);
+    }
+    if (server->spawned_count <
+            (int) (sizeof(server->spawned_pids) / sizeof(server->spawned_pids[0]))) {
+        server->spawned_pids[server->spawned_count++] = pid;
+    }
+    return (int) pid;
+}
+
+/* unix socket helpers */
+
+#include <fcntl.h>
+#include <sys/socket.h>
+#include <sys/un.h>
+
+int mjc_unix_listen(const char *path, int backlog) {
+    int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_NONBLOCK | SOCK_CLOEXEC, 0);
+    if (fd < 0) {
+        return -1;
+    }
+    struct sockaddr_un addr = {0};
+    addr.sun_family = AF_UNIX;
+    if (strlen(path) >= sizeof(addr.sun_path)) {
+        close(fd);
+        return -1;
+    }
+    strncpy(addr.sun_path, path, sizeof(addr.sun_path) - 1);
+    unlink(path);
+    if (bind(fd, (struct sockaddr *) &addr, sizeof(addr)) != 0 ||
+            listen(fd, backlog) != 0) {
+        close(fd);
+        return -1;
+    }
+    return fd;
+}
+
+int mjc_unix_accept(int server_fd) {
+    int fd = accept(server_fd, NULL, NULL);
+    if (fd >= 0) {
+        fcntl(fd, F_SETFL, O_NONBLOCK);
+    }
+    return fd;
+}
+
+/* fd integration */
+
+static int handle_fd_event(int fd, uint32_t mask, void *data) {
+    struct mjc_fd_source *source = data;
+    if (source->server->cbs.fd_event != NULL) {
+        return source->server->cbs.fd_event(source->server->ud, fd, mask);
+    }
+    return 0;
+}
+
+bool mjc_loop_add_fd(mjc_server *server, int fd, uint32_t mask) {
+    struct mjc_fd_source *source = calloc(1, sizeof(*source));
+    source->server = server;
+    source->fd = fd;
+    source->source = wl_event_loop_add_fd(server->loop, fd, mask,
+        handle_fd_event, source);
+    if (source->source == NULL) {
+        free(source);
+        return false;
+    }
+    wl_list_insert(&server->fd_sources, &source->link);
+    return true;
+}
+
+void mjc_loop_remove_fd(mjc_server *server, int fd) {
+    struct mjc_fd_source *source, *tmp;
+    wl_list_for_each_safe(source, tmp, &server->fd_sources, link) {
+        if (source->fd == fd) {
+            wl_event_source_remove(source->source);
+            wl_list_remove(&source->link);
+            free(source);
+            return;
+        }
+    }
+}
+
+/* views */
+
+uint64_t mjc_view_id(mjc_view *view) {
+    return view->id;
+}
+
+int mjc_view_pid(mjc_view *view) {
+    if (view->is_xwayland) {
+        return view->xsurface != NULL ? (int) view->xsurface->pid : 0;
+    }
+    pid_t pid = 0;
+    struct wlr_surface *surface = view_surface(view);
+    if (surface != NULL) {
+        struct wl_client *client = wl_resource_get_client(surface->resource);
+        wl_client_get_credentials(client, &pid, NULL, NULL);
+    }
+    return (int) pid;
+}
+
+const char *mjc_view_title(mjc_view *view) {
+    if (view->is_xwayland) {
+        return view->xsurface != NULL ? view->xsurface->title : NULL;
+    }
+    return view->xdg_toplevel->title;
+}
+
+const char *mjc_view_app_id(mjc_view *view) {
+    if (view->is_xwayland) {
+        return view->xsurface != NULL ? view->xsurface->class : NULL;
+    }
+    return view->xdg_toplevel->app_id;
+}
+
+bool mjc_view_is_xwayland(mjc_view *view) {
+    return view->is_xwayland;
+}
+
+bool mjc_view_is_mapped(mjc_view *view) {
+    return view->mapped;
+}
+
+bool mjc_view_is_minimized(mjc_view *view) {
+    return view->minimized;
+}
+
+bool mjc_view_is_maximized(mjc_view *view) {
+    return view->maximized;
+}
+
+bool mjc_view_is_focused(mjc_view *view) {
+    return view->server->focused_view == view;
+}
+
+void mjc_view_get_geometry(mjc_view *view, int *x, int *y, int *width, int *height) {
+    struct wlr_box box = {0};
+    if (view->scene_tree != NULL || !view->is_xwayland) {
+        view_current_geometry(view, &box);
+    }
+    *x = box.x;
+    *y = box.y;
+    *width = box.width;
+    *height = box.height;
+}
+
+void mjc_view_close(mjc_view *view) {
+    if (view->is_xwayland) {
+        if (view->xsurface != NULL) {
+            wlr_xwayland_surface_close(view->xsurface);
+        }
+    } else {
+        wlr_xdg_toplevel_send_close(view->xdg_toplevel);
+    }
+}
+
+void mjc_view_set_minimized(mjc_view *view, bool minimized) {
+    if (view->minimized == minimized) {
+        return;
+    }
+    view->minimized = minimized;
+    if (view->scene_tree != NULL) {
+        wlr_scene_node_set_enabled(&view->scene_tree->node, !minimized);
+    }
+    if (view->is_xwayland && view->xsurface != NULL) {
+        wlr_xwayland_surface_set_minimized(view->xsurface, minimized);
+    }
+    if (minimized) {
+        clear_focus_if(view->server, view);
+    } else {
+        mjc_view_focus(view);
+    }
+}
+
+void mjc_view_set_maximized(mjc_view *view, bool maximized) {
+    struct mjc_server *server = view->server;
+    if (view->maximized == maximized) {
+        return;
+    }
+    struct wlr_box box;
+    wlr_output_layout_get_box(server->output_layout, NULL, &box);
+    if (maximized && view->mapped && view->scene_tree != NULL) {
+        /* remember the floating geometry (visible coords) for restore */
+        view_current_geometry(view, &view->saved_geo);
+    }
+    view->maximized = maximized;
+    if (view->is_xwayland) {
+        if (view->xsurface == NULL) {
+            return;
+        }
+        wlr_xwayland_surface_set_maximized(view->xsurface, maximized);
+        if (maximized) {
+            wlr_xwayland_surface_configure(view->xsurface,
+                (int16_t) box.x, (int16_t) box.y,
+                (uint16_t) box.width, (uint16_t) box.height);
+            if (view->scene_tree != NULL) {
+                wlr_scene_node_set_position(&view->scene_tree->node, box.x, box.y);
+            }
+        } else if (view->saved_geo.width > 0) {
+            wlr_xwayland_surface_configure(view->xsurface,
+                (int16_t) view->saved_geo.x, (int16_t) view->saved_geo.y,
+                (uint16_t) view->saved_geo.width, (uint16_t) view->saved_geo.height);
+            if (view->scene_tree != NULL) {
+                wlr_scene_node_set_position(&view->scene_tree->node,
+                    view->saved_geo.x, view->saved_geo.y);
+            }
+        }
+    } else {
+        wlr_xdg_toplevel_set_maximized(view->xdg_toplevel, maximized);
+        if (maximized) {
+            wlr_xdg_toplevel_set_size(view->xdg_toplevel, box.width, box.height);
+            if (view->scene_tree != NULL) {
+                wlr_scene_node_set_position(&view->scene_tree->node, box.x, box.y);
+            }
+        } else {
+            /* 0x0 when no remembered size - the client picks its own;
+             * placement happens on the commit that carries the new size,
+             * when the decoration offsets are known again */
+            wlr_xdg_toplevel_set_size(view->xdg_toplevel,
+                view->saved_geo.width, view->saved_geo.height);
+            view->pending_center = true;
+        }
+    }
+}
+
+void mjc_view_set_layer(mjc_view *view, mjc_layer layer) {
+    view->layer = layer;
+    if (view->scene_tree != NULL) {
+        wlr_scene_node_reparent(&view->scene_tree->node,
+            view->server->layers[layer]);
+    }
+}
+
+void mjc_view_set_focusable(mjc_view *view, bool focusable) {
+    view->focusable = focusable;
+    if (!focusable) {
+        clear_focus_if(view->server, view);
+    }
+}
+
+void mjc_view_set_position(mjc_view *view, int x, int y) {
+    if (view->scene_tree != NULL) {
+        wlr_scene_node_set_position(&view->scene_tree->node, x, y);
+    }
+    if (view->is_xwayland && view->xsurface != NULL) {
+        wlr_xwayland_surface_configure(view->xsurface,
+            (int16_t) x, (int16_t) y,
+            (uint16_t) view->xsurface->width, (uint16_t) view->xsurface->height);
+    }
+}
+
+void mjc_view_set_geometry(mjc_view *view, int x, int y, int width, int height) {
+    if (width <= 0 || height <= 0) {
+        return;
+    }
+    if (view->maximized) {
+        /* keep it as the restore target for the next unmaximize */
+        view->saved_geo.x = x;
+        view->saved_geo.y = y;
+        view->saved_geo.width = width;
+        view->saved_geo.height = height;
+        return;
+    }
+    if (view->is_xwayland) {
+        if (view->xsurface != NULL) {
+            wlr_xwayland_surface_configure(view->xsurface,
+                (int16_t) x, (int16_t) y, (uint16_t) width, (uint16_t) height);
+        }
+        if (view->scene_tree != NULL) {
+            wlr_scene_node_set_position(&view->scene_tree->node, x, y);
+        }
+    } else {
+        wlr_xdg_toplevel_set_size(view->xdg_toplevel, width, height);
+        if (view->scene_tree != NULL) {
+            struct wlr_box off;
+            wlr_xdg_surface_get_geometry(view->xdg_toplevel->base, &off);
+            wlr_scene_node_set_position(&view->scene_tree->node,
+                x - off.x, y - off.y);
+        }
+    }
+}

+ 2 - 0
compositor/native/shim.def

@@ -0,0 +1,2 @@
+headers = shim.h
+package = mjdev.compositor.shim

+ 120 - 0
compositor/native/shim.h

@@ -0,0 +1,120 @@
+/*
+ * Copyright (c) Milan Jurkulák 2026.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ *
+ * Flat C API over wlroots for the mjdev compositor.
+ * This header is self contained (no wlroots includes) so it can be
+ * processed by Kotlin/Native cinterop directly.
+ */
+
+#ifndef MJDEV_COMPOSITOR_SHIM_H
+#define MJDEV_COMPOSITOR_SHIM_H
+
+#include <stdint.h>
+#include <stdbool.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct mjc_server mjc_server;
+typedef struct mjc_view mjc_view;
+
+typedef enum mjc_layer {
+    MJC_LAYER_BACKGROUND = 0,
+    MJC_LAYER_BOTTOM = 1,
+    MJC_LAYER_NORMAL = 2,
+    MJC_LAYER_TOP = 3,
+    MJC_LAYER_OVERLAY = 4,
+} mjc_layer;
+
+/* mask values for fd events, mirror wayland WL_EVENT_* */
+#define MJC_EVENT_READABLE 0x01
+#define MJC_EVENT_WRITABLE 0x02
+#define MJC_EVENT_HANGUP   0x04
+#define MJC_EVENT_ERROR    0x08
+
+/* keyboard modifiers, mirror wlroots WLR_MODIFIER_* */
+#define MJC_MOD_SHIFT 0x01
+#define MJC_MOD_CAPS  0x02
+#define MJC_MOD_CTRL  0x04
+#define MJC_MOD_ALT   0x08
+#define MJC_MOD_MOD2  0x10
+#define MJC_MOD_MOD3  0x20
+#define MJC_MOD_LOGO  0x40
+#define MJC_MOD_MOD5  0x80
+
+typedef struct mjc_callbacks {
+    /* view lifecycle */
+    void (*view_new)(void *ud, mjc_view *view, bool xwayland);
+    void (*view_map)(void *ud, mjc_view *view);
+    void (*view_unmap)(void *ud, mjc_view *view);
+    void (*view_destroy)(void *ud, mjc_view *view);
+    void (*view_title)(void *ud, mjc_view *view, const char *title);
+    void (*view_app_id)(void *ud, mjc_view *view, const char *app_id);
+    /* focus changed; view may be NULL when nothing is focused */
+    void (*focus_change)(void *ud, mjc_view *view);
+    /* keyboard binding hook, return true when the combo was consumed */
+    bool (*key)(void *ud, uint32_t keysym, uint32_t modifiers, bool pressed);
+    /* fd registered via mjc_loop_add_fd became ready */
+    int (*fd_event)(void *ud, int fd, uint32_t mask);
+    /* a child process spawned via mjc_spawn exited */
+    void (*child_exit)(void *ud, int pid, int status);
+    /* backend started, sockets exported - safe to spawn clients */
+    void (*ready)(void *ud);
+    /* pointer moved, in compositor layout coordinates (throttled) */
+    void (*pointer)(void *ud, int x, int y);
+} mjc_callbacks;
+
+/* lifecycle */
+mjc_server *mjc_create(void);
+bool mjc_start(mjc_server *server, const mjc_callbacks *callbacks, void *userdata);
+void mjc_run(mjc_server *server);
+void mjc_terminate(mjc_server *server);
+void mjc_destroy(mjc_server *server);
+
+/* environment */
+const char *mjc_socket_name(mjc_server *server);      /* WAYLAND_DISPLAY */
+const char *mjc_xwayland_display(mjc_server *server); /* DISPLAY or NULL */
+void mjc_output_size(mjc_server *server, int *width, int *height);
+
+/* process spawning (inherits WAYLAND_DISPLAY/DISPLAY), returns pid or -1 */
+int mjc_spawn(mjc_server *server, const char *command);
+
+/* event loop integration for the IPC server */
+bool mjc_loop_add_fd(mjc_server *server, int fd, uint32_t mask);
+void mjc_loop_remove_fd(mjc_server *server, int fd);
+
+/* unix socket helpers (sockaddr_un is unavailable in kotlin/native posix) */
+int mjc_unix_listen(const char *path, int backlog); /* nonblocking fd or -1 */
+int mjc_unix_accept(int server_fd);                 /* nonblocking fd or -1 */
+
+/* views */
+uint64_t mjc_view_id(mjc_view *view);
+int mjc_view_pid(mjc_view *view);
+const char *mjc_view_title(mjc_view *view);
+const char *mjc_view_app_id(mjc_view *view);
+bool mjc_view_is_xwayland(mjc_view *view);
+bool mjc_view_is_mapped(mjc_view *view);
+bool mjc_view_is_minimized(mjc_view *view);
+bool mjc_view_is_maximized(mjc_view *view);
+bool mjc_view_is_focused(mjc_view *view);
+void mjc_view_get_geometry(mjc_view *view, int *x, int *y, int *width, int *height);
+
+void mjc_view_focus(mjc_view *view);
+void mjc_view_close(mjc_view *view);
+void mjc_view_set_minimized(mjc_view *view, bool minimized);
+void mjc_view_set_maximized(mjc_view *view, bool maximized);
+void mjc_view_set_layer(mjc_view *view, mjc_layer layer);
+void mjc_view_set_focusable(mjc_view *view, bool focusable);
+void mjc_view_set_position(mjc_view *view, int x, int y);
+void mjc_view_set_geometry(mjc_view *view, int x, int y, int width, int height);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* MJDEV_COMPOSITOR_SHIM_H */

+ 44 - 0
compositor/src/linuxX64Main/kotlin/eu/mjdev/compositor/Clog.kt

@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) Milan Jurkulák 2026.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+@file:OptIn(ExperimentalForeignApi::class)
+
+package eu.mjdev.compositor
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.toKString
+import platform.posix.getenv
+
+/**
+ * Tiny tagged logger for the compositor. Verbose by default so a session/login can be
+ * diagnosed from the journal; silence with MJDEVC_VERBOSE=0. High-frequency pointer events
+ * are only sampled (medium verbose) so they never drown the window/ipc lifecycle output.
+ */
+object Clog {
+    private val verbose: Boolean =
+        (getenv("MJDEVC_VERBOSE")?.toKString() ?: "1") != "0"
+    private var pointerCount = 0L
+
+    /** always printed (lifecycle milestones, errors) */
+    fun log(message: String) = println("mjdevc: $message")
+
+    /** printed only in verbose mode (per window / per ipc request detail) */
+    fun v(message: String) {
+        if (verbose) println("mjdevc: $message")
+    }
+
+    /** medium-verbose: pointer is sampled, the rest is dropped to keep logs readable */
+    fun pointer(x: Int, y: Int) {
+        if (!verbose) return
+        if (pointerCount++ % POINTER_SAMPLE == 0L) {
+            println("mjdevc: pointer ($x, $y) [#$pointerCount, 1/$POINTER_SAMPLE sampled]")
+        }
+    }
+
+    private const val POINTER_SAMPLE = 60L
+}

+ 130 - 0
compositor/src/linuxX64Main/kotlin/eu/mjdev/compositor/GeometryStore.kt

@@ -0,0 +1,130 @@
+/*
+ * Copyright (c) Milan Jurkulák 2026.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+@file:OptIn(ExperimentalForeignApi::class)
+
+package eu.mjdev.compositor
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.IntVar
+import kotlinx.cinterop.alloc
+import kotlinx.cinterop.allocArray
+import kotlinx.cinterop.ByteVar
+import kotlinx.cinterop.memScoped
+import kotlinx.cinterop.ptr
+import kotlinx.cinterop.toKString
+import kotlinx.cinterop.value
+import kotlinx.serialization.builtins.ListSerializer
+import kotlinx.serialization.builtins.MapSerializer
+import kotlinx.serialization.builtins.serializer
+import kotlinx.serialization.json.Json
+import mjdev.compositor.shim.mjc_view_get_geometry
+import mjdev.compositor.shim.mjc_view_is_maximized
+import mjdev.compositor.shim.mjc_view_set_geometry
+import platform.posix.fclose
+import platform.posix.fgets
+import platform.posix.fopen
+import platform.posix.fputs
+import platform.posix.getenv
+import platform.posix.mkdir
+
+/**
+ * Remembers the last user set geometry of app windows (keyed by app id)
+ * across compositor restarts, so windows reopen where the user left them.
+ */
+class GeometryStore {
+
+    private val json = Json { ignoreUnknownKeys = true }
+    private val serializer =
+        MapSerializer(String.serializer(), ListSerializer(Int.serializer()))
+    private val entries = mutableMapOf<String, List<Int>>()
+    private val path: String
+
+    init {
+        val home = getenv("HOME")?.toKString() ?: "/tmp"
+        val stateBase = getenv("XDG_STATE_HOME")?.toKString()
+            ?: "$home/.local/state"
+        val dir = "$stateBase/mjdev"
+        mkdir(stateBase, DIR_MODE)
+        mkdir(dir, DIR_MODE)
+        path = "$dir/window-geometry.json"
+        load()
+    }
+
+    /**
+     * applies the remembered geometry to a freshly mapped window;
+     * for windows that start maximized the shim keeps it as the
+     * restore target of the next unmaximize
+     */
+    fun restore(info: WindowInfo) {
+        val geo = entries[keyOf(info) ?: return] ?: return
+        if (geo.size == 4) {
+            mjc_view_set_geometry(info.ptr, geo[0], geo[1], geo[2], geo[3])
+        }
+    }
+
+    /** captures the current geometry of a window that is going away */
+    fun remember(info: WindowInfo) {
+        val key = keyOf(info) ?: return
+        if (mjc_view_is_maximized(info.ptr)) {
+            return
+        }
+        memScoped {
+            val x = alloc<IntVar>()
+            val y = alloc<IntVar>()
+            val w = alloc<IntVar>()
+            val h = alloc<IntVar>()
+            mjc_view_get_geometry(info.ptr, x.ptr, y.ptr, w.ptr, h.ptr)
+            if (w.value <= 0 || h.value <= 0) {
+                return
+            }
+            val geo = listOf(x.value, y.value, w.value, h.value)
+            if (entries[key] != geo) {
+                entries[key] = geo
+                save()
+            }
+        }
+    }
+
+    private fun keyOf(info: WindowInfo): String? =
+        info.appId?.lowercase()?.takeIf { it.isNotBlank() }
+
+    private fun load() {
+        val file = fopen(path, "r") ?: return
+        try {
+            val content = buildString {
+                memScoped {
+                    val buffer = allocArray<ByteVar>(READ_CHUNK)
+                    while (true) {
+                        val line = fgets(buffer, READ_CHUNK, file) ?: break
+                        append(line.toKString())
+                    }
+                }
+            }
+            if (content.isNotBlank()) {
+                entries.putAll(json.decodeFromString(serializer, content))
+            }
+        } catch (_: Exception) {
+            // corrupted store starts over, windows just lose their positions
+            entries.clear()
+        } finally {
+            fclose(file)
+        }
+    }
+
+    private fun save() {
+        val file = fopen(path, "w") ?: return
+        fputs(json.encodeToString(serializer, entries), file)
+        fclose(file)
+    }
+
+    companion object {
+        private const val READ_CHUNK = 4096
+        private val DIR_MODE = 493u // 0755
+    }
+}

+ 300 - 0
compositor/src/linuxX64Main/kotlin/eu/mjdev/compositor/Ipc.kt

@@ -0,0 +1,300 @@
+/*
+ * Copyright (c) Milan Jurkulák 2026.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+@file:OptIn(ExperimentalForeignApi::class)
+
+package eu.mjdev.compositor
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.addressOf
+import kotlinx.cinterop.convert
+import kotlinx.cinterop.usePinned
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.JsonArray
+import kotlinx.serialization.json.JsonNull
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import kotlinx.serialization.json.long
+import kotlinx.serialization.json.put
+import mjdev.compositor.shim.MJC_EVENT_ERROR
+import mjdev.compositor.shim.MJC_EVENT_HANGUP
+import mjdev.compositor.shim.MJC_EVENT_READABLE
+import mjdev.compositor.shim.mjc_loop_add_fd
+import mjdev.compositor.shim.mjc_loop_remove_fd
+import mjdev.compositor.shim.mjc_view_focus
+import mjdev.compositor.shim.mjc_view_close
+import mjdev.compositor.shim.mjc_view_is_maximized
+import mjdev.compositor.shim.mjc_view_set_maximized
+import mjdev.compositor.shim.mjc_view_set_minimized
+import mjdev.compositor.shim.mjc_unix_accept
+import mjdev.compositor.shim.mjc_unix_listen
+import platform.posix.EAGAIN
+import platform.posix.EWOULDBLOCK
+import platform.posix.close
+import platform.posix.errno
+import platform.posix.read
+import platform.posix.unlink
+import platform.posix.write
+
+/**
+ * Line based JSON api on a unix socket.
+ *
+ * Requests:
+ *   {"cmd":"list-windows"}
+ *   {"cmd":"windows-for-app","app_id":"firefox"}
+ *   {"cmd":"activate","id":3}
+ *   {"cmd":"close","id":3}
+ *   {"cmd":"minimize","id":3,"minimized":true}
+ *   {"cmd":"maximize","id":3}
+ *   {"cmd":"subscribe"}
+ *
+ * Events (only for subscribed clients):
+ *   {"event":"window-opened","window":{...}}
+ *   {"event":"window-closed","window":{...}}
+ *   {"event":"window-title","window":{...}}
+ *   {"event":"focus-changed","window":{...}|null}
+ */
+class IpcServer(private val c: Compositor) {
+
+    private class Client(val fd: Int) {
+        val buffer = StringBuilder()
+        var subscribed = false
+    }
+
+    private val json = Json { ignoreUnknownKeys = true }
+    private var serverFd = -1
+    private val clients = mutableMapOf<Int, Client>()
+
+    fun start() {
+        val path = c.config.socketPath
+        serverFd = mjc_unix_listen(path, 16)
+        if (serverFd < 0) {
+            println("mjdevc: cannot bind ipc socket $path")
+            return
+        }
+        mjc_loop_add_fd(c.server, serverFd, MJC_EVENT_READABLE.convert())
+        println("mjdevc: ipc listening on $path")
+    }
+
+    fun stop() {
+        clients.keys.toList().forEach { dropClient(it) }
+        if (serverFd >= 0) {
+            mjc_loop_remove_fd(c.server, serverFd)
+            close(serverFd)
+            unlink(c.config.socketPath)
+            serverFd = -1
+        }
+    }
+
+    fun handleFd(fd: Int, mask: UInt): Int {
+        if (fd == serverFd) {
+            acceptClients()
+            return 0
+        }
+        val errorMask = (MJC_EVENT_HANGUP or MJC_EVENT_ERROR).convert<UInt>()
+        if (mask and errorMask != 0u) {
+            dropClient(fd)
+            return 0
+        }
+        readClient(fd)
+        return 0
+    }
+
+    private fun acceptClients() {
+        while (true) {
+            val fd = mjc_unix_accept(serverFd)
+            if (fd < 0) {
+                return
+            }
+            clients[fd] = Client(fd)
+            Clog.v("ipc client connected fd=$fd (clients=${clients.size})")
+            mjc_loop_add_fd(
+                c.server, fd,
+                (MJC_EVENT_READABLE or MJC_EVENT_HANGUP or MJC_EVENT_ERROR).convert()
+            )
+        }
+    }
+
+    private fun readClient(fd: Int) {
+        val client = clients[fd] ?: return
+        val chunk = ByteArray(4096)
+        while (true) {
+            val n = chunk.usePinned { pinned ->
+                read(fd, pinned.addressOf(0), chunk.size.convert()).toInt()
+            }
+            when {
+                n > 0 -> client.buffer.append(chunk.decodeToString(0, n))
+                n == 0 -> {
+                    dropClient(fd)
+                    return
+                }
+
+                else -> {
+                    if (errno != EAGAIN && errno != EWOULDBLOCK) {
+                        dropClient(fd)
+                    }
+                    break
+                }
+            }
+        }
+        processBuffer(client)
+    }
+
+    private fun processBuffer(client: Client) {
+        while (true) {
+            val text = client.buffer.toString()
+            val newline = text.indexOf('\n')
+            if (newline < 0) {
+                return
+            }
+            val line = text.substring(0, newline).trim()
+            client.buffer.deleteRange(0, newline + 1)
+            if (line.isNotEmpty()) {
+                val response = runCatching { handleRequest(client, line) }
+                    .getOrElse { error ->
+                        buildJsonObject {
+                            put("ok", false)
+                            put("error", error.message ?: "invalid request")
+                        }
+                    }
+                send(client.fd, response)
+            }
+        }
+    }
+
+    private fun handleRequest(client: Client, line: String): JsonObject {
+        val request = json.parseToJsonElement(line).jsonObject
+        val cmd = request["cmd"]?.jsonPrimitive?.content
+            ?: return error("missing cmd")
+        Clog.v("ipc <- fd=${client.fd}: $line")
+        return when (cmd) {
+            "list-windows" -> {
+                val all = request["all"]?.jsonPrimitive?.content?.toBoolean() == true
+                val windows = if (all) {
+                    c.windows.all().filter { it.mapped }
+                } else {
+                    c.windows.listed()
+                }
+                ok { put("windows", JsonArray(windows.map { it.toJson() })) }
+            }
+
+            "windows-for-app" -> {
+                val appId = request["app_id"]?.jsonPrimitive?.content
+                    ?: return error("missing app_id")
+                ok {
+                    put("windows", JsonArray(
+                        c.windows.listed().filter {
+                            it.appId?.contains(appId, ignoreCase = true) == true
+                        }.map { it.toJson() }
+                    ))
+                }
+            }
+
+            "activate" -> withWindow(request) { info ->
+                if (info.minimized) {
+                    mjc_view_set_minimized(info.ptr, false)
+                    info.minimized = false
+                }
+                mjc_view_focus(info.ptr)
+            }
+
+            "close" -> withWindow(request) { mjc_view_close(it.ptr) }
+
+            "minimize" -> withWindow(request) { info ->
+                val minimized = request["minimized"]
+                    ?.jsonPrimitive?.content?.toBoolean() ?: true
+                mjc_view_set_minimized(info.ptr, minimized)
+                info.minimized = minimized
+            }
+
+            "maximize" -> withWindow(request) { info ->
+                val maximized = request["maximized"]
+                    ?.jsonPrimitive?.content?.toBoolean()
+                    ?: !mjc_view_is_maximized(info.ptr)
+                mjc_view_set_maximized(info.ptr, maximized)
+            }
+
+            "subscribe" -> {
+                client.subscribed = true
+                ok { }
+            }
+
+            else -> error("unknown cmd $cmd")
+        }
+    }
+
+    private inline fun withWindow(
+        request: JsonObject,
+        block: (WindowInfo) -> Unit
+    ): JsonObject {
+        val id = request["id"]?.jsonPrimitive?.long ?: return error("missing id")
+        val info = c.windows.byId(id) ?: return error("no window $id")
+        block(info)
+        return ok { }
+    }
+
+    private inline fun ok(block: kotlinx.serialization.json.JsonObjectBuilder.() -> Unit) =
+        buildJsonObject {
+            put("ok", true)
+            block()
+        }
+
+    private fun error(message: String) = buildJsonObject {
+        put("ok", false)
+        put("error", message)
+    }
+
+    fun broadcastEvent(event: String, info: WindowInfo?) {
+        val subscribers = clients.values.count { it.subscribed }
+        if (subscribers == 0) {
+            return
+        }
+        Clog.v("ipc -> $event to $subscribers subscriber(s): ${info?.describe() ?: "null"}")
+        val payload = buildJsonObject {
+            put("event", event)
+            put("window", info?.toJson() ?: JsonNull)
+        }
+        clients.values.filter { it.subscribed }.forEach { send(it.fd, payload) }
+    }
+
+    fun broadcastPointer(x: Int, y: Int) {
+        if (clients.values.none { it.subscribed }) {
+            return
+        }
+        val payload = buildJsonObject {
+            put("event", "pointer")
+            put("x", x)
+            put("y", y)
+        }
+        clients.values.filter { it.subscribed }.forEach { send(it.fd, payload) }
+    }
+
+    private fun send(fd: Int, payload: JsonObject) {
+        val data = (payload.toString() + "\n").encodeToByteArray()
+        data.usePinned { pinned ->
+            var offset = 0
+            while (offset < data.size) {
+                val n = write(fd, pinned.addressOf(offset), (data.size - offset).convert()).toInt()
+                if (n <= 0) {
+                    break
+                }
+                offset += n
+            }
+        }
+    }
+
+    private fun dropClient(fd: Int) {
+        if (clients.remove(fd) != null) {
+            Clog.v("ipc client disconnected fd=$fd (clients=${clients.size})")
+            mjc_loop_remove_fd(c.server, fd)
+            close(fd)
+        }
+    }
+}

+ 268 - 0
compositor/src/linuxX64Main/kotlin/eu/mjdev/compositor/Main.kt

@@ -0,0 +1,268 @@
+/*
+ * Copyright (c) Milan Jurkulák 2026.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+@file:OptIn(ExperimentalForeignApi::class)
+
+package eu.mjdev.compositor
+
+import cnames.structs.mjc_server
+import cnames.structs.mjc_view
+import kotlinx.cinterop.COpaquePointer
+import kotlinx.cinterop.CPointer
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.StableRef
+import kotlinx.cinterop.alloc
+import kotlinx.cinterop.asStableRef
+import kotlinx.cinterop.memScoped
+import kotlinx.cinterop.ptr
+import kotlinx.cinterop.staticCFunction
+import kotlinx.cinterop.toKString
+import mjdev.compositor.shim.mjc_callbacks
+import mjdev.compositor.shim.mjc_create
+import mjdev.compositor.shim.mjc_destroy
+import mjdev.compositor.shim.mjc_run
+import mjdev.compositor.shim.mjc_start
+import mjdev.compositor.shim.mjc_terminate
+import platform.posix.SIGPIPE
+import platform.posix.SIG_IGN
+import platform.posix.getenv
+import platform.posix.signal
+
+class Config(
+    val shellCmd: String?,
+    val startupCmds: List<String>,
+    val sessionMode: Boolean,
+    val socketPath: String,
+) {
+    companion object {
+        fun parse(args: Array<String>): Config {
+            var shellCmd: String? = null
+            val startup = mutableListOf<String>()
+            var session = false
+            var socket: String? = null
+            var i = 0
+            while (i < args.size) {
+                when (args[i]) {
+                    "--shell-cmd" -> shellCmd = args[++i]
+                    "--startup" -> startup += args[++i]
+                    "--session" -> session = true
+                    "--socket" -> socket = args[++i]
+                    "--help", "-h" -> {
+                        println(
+                            "mjdevc [--shell-cmd CMD] [--startup CMD]... [--session] [--socket PATH]"
+                        )
+                        platform.posix.exit(0)
+                    }
+                }
+                i++
+            }
+            val runtimeDir = getenv("XDG_RUNTIME_DIR")?.toKString() ?: "/tmp"
+            return Config(
+                shellCmd = shellCmd,
+                startupCmds = startup,
+                sessionMode = session,
+                socketPath = socket ?: "$runtimeDir/mjdev-compositor.sock",
+            )
+        }
+    }
+}
+
+class Compositor(val config: Config) {
+    val server: CPointer<mjc_server> = mjc_create()
+        ?: error("mjdevc: cannot allocate server")
+    val windows = WindowModel()
+    val policy = Policy(this)
+    val ipc = IpcServer(this)
+    val session = Session(this)
+    val geometry = GeometryStore()
+
+    private var self: StableRef<Compositor>? = null
+
+    fun run(): Boolean {
+        signal(SIGPIPE, SIG_IGN)
+        val ref = StableRef.create(this)
+        self = ref
+        val started = memScoped {
+            val cbs = alloc<mjc_callbacks>()
+            cbs.view_new = staticCFunction(::cbViewNew)
+            cbs.view_map = staticCFunction(::cbViewMap)
+            cbs.view_unmap = staticCFunction(::cbViewUnmap)
+            cbs.view_destroy = staticCFunction(::cbViewDestroy)
+            cbs.view_title = staticCFunction(::cbViewTitle)
+            cbs.view_app_id = staticCFunction(::cbViewAppId)
+            cbs.focus_change = staticCFunction(::cbFocusChange)
+            cbs.key = staticCFunction(::cbKey)
+            cbs.fd_event = staticCFunction(::cbFdEvent)
+            cbs.child_exit = staticCFunction(::cbChildExit)
+            cbs.ready = staticCFunction(::cbReady)
+            cbs.pointer = staticCFunction(::cbPointer)
+            mjc_start(server, cbs.ptr, ref.asCPointer())
+        }
+        if (!started) {
+            Clog.log("backend failed to start")
+            ref.dispose()
+            return false
+        }
+        Clog.log("backend started (session=${config.sessionMode}, socket=${config.socketPath})")
+        ipc.start()
+        mjc_run(server)
+        Clog.log("event loop ended, shutting down")
+        ipc.stop()
+        mjc_destroy(server)
+        ref.dispose()
+        self = null
+        return true
+    }
+
+    fun terminate() {
+        Clog.log("terminate requested")
+        mjc_terminate(server)
+    }
+
+    // callback dispatch ------------------------------------------------
+
+    fun onViewNew(view: CPointer<mjc_view>, xwayland: Boolean) {
+        val info = windows.add(view, xwayland)
+        Clog.v("view new: ${info.describe()}")
+    }
+
+    fun onViewMap(view: CPointer<mjc_view>) {
+        val info = windows.get(view) ?: return
+        info.refresh()
+        info.mapped = true
+        policy.apply(info)
+        if (!info.shell) {
+            geometry.restore(info)
+        }
+        Clog.v("view mapped: ${info.describe()} role=${info.role ?: "-"}")
+        ipc.broadcastEvent("window-opened", info)
+    }
+
+    fun onViewUnmap(view: CPointer<mjc_view>) {
+        val info = windows.get(view) ?: return
+        info.mapped = false
+        if (!info.shell) {
+            geometry.remember(info)
+        }
+        Clog.v("view unmapped: ${info.describe()}")
+        ipc.broadcastEvent("window-closed", info)
+    }
+
+    fun onViewDestroy(view: CPointer<mjc_view>) {
+        val info = windows.remove(view) ?: return
+        Clog.v("view destroyed: ${info.describe()}")
+        if (info.mapped) {
+            ipc.broadcastEvent("window-closed", info)
+        }
+    }
+
+    fun onViewTitle(view: CPointer<mjc_view>, title: String?) {
+        val info = windows.get(view) ?: return
+        info.title = title
+        policy.apply(info)
+        if (info.mapped) {
+            ipc.broadcastEvent("window-title", info)
+        }
+    }
+
+    fun onViewAppId(view: CPointer<mjc_view>, appId: String?) {
+        val info = windows.get(view) ?: return
+        info.appId = appId
+        policy.apply(info)
+    }
+
+    fun onFocusChange(view: CPointer<mjc_view>?) {
+        windows.all().forEach { it.focused = false }
+        val info = view?.let { windows.get(it) }
+        info?.focused = true
+        Clog.v("focus changed -> ${info?.describe() ?: "none"}")
+        ipc.broadcastEvent("focus-changed", info)
+    }
+
+    fun onKey(keysym: UInt, modifiers: UInt, pressed: Boolean): Boolean =
+        policy.handleKey(keysym, modifiers, pressed)
+
+    fun onFdEvent(fd: Int, mask: UInt): Int = ipc.handleFd(fd, mask)
+
+    fun onChildExit(pid: Int, status: Int) = session.onChildExit(pid, status)
+
+    fun onReady() = session.onReady()
+
+    fun onPointer(x: Int, y: Int) {
+        Clog.pointer(x, y)
+        ipc.broadcastPointer(x, y)
+    }
+}
+
+private fun ctx(ud: COpaquePointer?): Compositor =
+    ud!!.asStableRef<Compositor>().get()
+
+private fun cbViewNew(ud: COpaquePointer?, view: CPointer<mjc_view>?, xwayland: Boolean) {
+    ctx(ud).onViewNew(view ?: return, xwayland)
+}
+
+private fun cbViewMap(ud: COpaquePointer?, view: CPointer<mjc_view>?) {
+    ctx(ud).onViewMap(view ?: return)
+}
+
+private fun cbViewUnmap(ud: COpaquePointer?, view: CPointer<mjc_view>?) {
+    ctx(ud).onViewUnmap(view ?: return)
+}
+
+private fun cbViewDestroy(ud: COpaquePointer?, view: CPointer<mjc_view>?) {
+    ctx(ud).onViewDestroy(view ?: return)
+}
+
+private fun cbViewTitle(
+    ud: COpaquePointer?,
+    view: CPointer<mjc_view>?,
+    title: CPointer<kotlinx.cinterop.ByteVar>?
+) {
+    ctx(ud).onViewTitle(view ?: return, title?.toKString())
+}
+
+private fun cbViewAppId(
+    ud: COpaquePointer?,
+    view: CPointer<mjc_view>?,
+    appId: CPointer<kotlinx.cinterop.ByteVar>?
+) {
+    ctx(ud).onViewAppId(view ?: return, appId?.toKString())
+}
+
+private fun cbFocusChange(ud: COpaquePointer?, view: CPointer<mjc_view>?) {
+    ctx(ud).onFocusChange(view)
+}
+
+private fun cbKey(
+    ud: COpaquePointer?,
+    keysym: UInt,
+    modifiers: UInt,
+    pressed: Boolean
+): Boolean = ctx(ud).onKey(keysym, modifiers, pressed)
+
+private fun cbFdEvent(ud: COpaquePointer?, fd: Int, mask: UInt): Int =
+    ctx(ud).onFdEvent(fd, mask)
+
+private fun cbChildExit(ud: COpaquePointer?, pid: Int, status: Int) {
+    ctx(ud).onChildExit(pid, status)
+}
+
+private fun cbReady(ud: COpaquePointer?) {
+    ctx(ud).onReady()
+}
+
+private fun cbPointer(ud: COpaquePointer?, x: Int, y: Int) {
+    ctx(ud).onPointer(x, y)
+}
+
+fun main(args: Array<String>) {
+    val compositor = Compositor(Config.parse(args))
+    if (!compositor.run()) {
+        platform.posix.exit(1)
+    }
+}

+ 112 - 0
compositor/src/linuxX64Main/kotlin/eu/mjdev/compositor/Policy.kt

@@ -0,0 +1,112 @@
+/*
+ * Copyright (c) Milan Jurkulák 2026.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+@file:OptIn(ExperimentalForeignApi::class)
+
+package eu.mjdev.compositor
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import mjdev.compositor.shim.MJC_LAYER_BACKGROUND
+import mjdev.compositor.shim.MJC_LAYER_TOP
+import mjdev.compositor.shim.MJC_MOD_ALT
+import mjdev.compositor.shim.mjc_view_close
+import mjdev.compositor.shim.mjc_view_focus
+import mjdev.compositor.shim.mjc_view_set_focusable
+import mjdev.compositor.shim.mjc_view_set_layer
+
+/**
+ * Window management policy:
+ * - windows of the desktop shell app (recognized by WM_CLASS/app_id, or
+ *   a "mjdev::<role>" title prefix) are placed into dedicated layers
+ * - everything else is a normal app window
+ */
+class Policy(private val c: Compositor) {
+
+    private val shellClasses = setOf(
+        "mjdev-desktop",
+        "org.mjdev.desktop",
+        "eu.mjdev.desktop",
+        // default AWT WM_CLASS of the Compose desktop main class
+        "eu-mjdev-desktop-mainkt",
+    )
+
+    fun apply(info: WindowInfo) {
+        val appId = info.appId?.lowercase().orEmpty()
+        val titleRole = info.title
+            ?.takeIf { it.startsWith(TITLE_PREFIX) }
+            ?.removePrefix(TITLE_PREFIX)
+            ?.substringBefore(' ')
+            ?.lowercase()
+        val isShell = appId in shellClasses || titleRole != null
+        info.shell = isShell
+        if (!isShell) {
+            return
+        }
+        info.role = titleRole ?: "window"
+        when (info.role) {
+            // ChromeWindow names: DesktopWindow/FullScreenWindow carry the wallpaper
+            "wallpaper", "background", "desktop",
+            "desktopwindow", "fullscreenwindow" -> {
+                mjc_view_set_layer(info.ptr, MJC_LAYER_BACKGROUND)
+                mjc_view_set_focusable(info.ptr, false)
+            }
+
+            // the panel is click only - keeping it unfocusable means opening
+            // it never steals keyboard focus from the menu or from apps
+            "desktoppanel", "panel", "tooltip" -> {
+                mjc_view_set_layer(info.ptr, MJC_LAYER_TOP)
+                mjc_view_set_focusable(info.ptr, false)
+            }
+
+            // menus, control center & co stay above app windows
+            else -> {
+                mjc_view_set_layer(info.ptr, MJC_LAYER_TOP)
+            }
+        }
+    }
+
+    fun handleKey(keysym: UInt, modifiers: UInt, pressed: Boolean): Boolean {
+        if (!pressed) {
+            return false
+        }
+        val alt = modifiers and MJC_MOD_ALT.toUInt() != 0u
+        return when {
+            alt && keysym == XKB_KEY_TAB -> {
+                focusNext()
+                true
+            }
+
+            alt && keysym == XKB_KEY_F4 -> {
+                c.windows.all().firstOrNull { it.focused }?.let {
+                    mjc_view_close(it.ptr)
+                }
+                true
+            }
+
+            else -> false
+        }
+    }
+
+    private fun focusNext() {
+        val cycle = c.windows.all().filter {
+            it.mapped && !it.minimized && !it.shell
+        }
+        if (cycle.isEmpty()) {
+            return
+        }
+        val current = cycle.indexOfFirst { it.focused }
+        val next = cycle[(current + 1).mod(cycle.size)]
+        mjc_view_focus(next.ptr)
+    }
+
+    companion object {
+        const val TITLE_PREFIX = "mjdev::"
+        val XKB_KEY_TAB = 0xff09u
+        val XKB_KEY_F4 = 0xffc1u
+    }
+}

+ 81 - 0
compositor/src/linuxX64Main/kotlin/eu/mjdev/compositor/Session.kt

@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) Milan Jurkulák 2026.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+@file:OptIn(ExperimentalForeignApi::class)
+
+package eu.mjdev.compositor
+
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.toKString
+import mjdev.compositor.shim.mjc_socket_name
+import mjdev.compositor.shim.mjc_spawn
+import mjdev.compositor.shim.mjc_xwayland_display
+import platform.posix.setenv
+
+/**
+ * Session bootstrap: exports environment to dbus, starts the
+ * xdg desktop portals (full session only), the desktop shell and
+ * optional extra startup commands. Ends the compositor when the
+ * shell exits cleanly or keeps restarting it after crashes.
+ */
+class Session(private val c: Compositor) {
+
+    private var shellPid = -1
+    private var crashRestarts = 0
+
+    fun onReady() {
+        val wayland = mjc_socket_name(c.server)?.toKString()
+        val x11 = mjc_xwayland_display(c.server)?.toKString()
+        println("mjdevc: WAYLAND_DISPLAY=$wayland DISPLAY=${x11 ?: "-"}")
+
+        setenv("XDG_CURRENT_DESKTOP", "mjdev", 1)
+        setenv("XDG_SESSION_TYPE", "wayland", 1)
+
+        mjc_spawn(
+            c.server,
+            "dbus-update-activation-environment --systemd " +
+                    "WAYLAND_DISPLAY DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE " +
+                    "2>/dev/null || true"
+        )
+
+        if (c.config.sessionMode) {
+            // portals deliver live gtk-theme/color-scheme changes to gtk apps;
+            // never started in nested mode where the host session owns them
+            mjc_spawn(c.server, "/usr/libexec/xdg-desktop-portal-gtk")
+            mjc_spawn(c.server, "sleep 1; exec /usr/libexec/xdg-desktop-portal")
+        }
+
+        c.config.startupCmds.forEach { mjc_spawn(c.server, it) }
+        startShell()
+    }
+
+    private fun startShell() {
+        val cmd = c.config.shellCmd ?: return
+        shellPid = mjc_spawn(c.server, cmd)
+        println("mjdevc: shell started pid=$shellPid: $cmd")
+    }
+
+    fun onChildExit(pid: Int, status: Int) {
+        if (pid != shellPid || shellPid < 0) {
+            return
+        }
+        val crashed = status != 0
+        if (crashed && crashRestarts < MAX_SHELL_RESTARTS) {
+            crashRestarts++
+            println("mjdevc: shell crashed (status=$status), restart $crashRestarts")
+            startShell()
+        } else {
+            println("mjdevc: shell exited (status=$status), terminating")
+            c.terminate()
+        }
+    }
+
+    companion object {
+        private const val MAX_SHELL_RESTARTS = 3
+    }
+}

+ 116 - 0
compositor/src/linuxX64Main/kotlin/eu/mjdev/compositor/WindowModel.kt

@@ -0,0 +1,116 @@
+/*
+ * Copyright (c) Milan Jurkulák 2026.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+@file:OptIn(ExperimentalForeignApi::class)
+
+package eu.mjdev.compositor
+
+import cnames.structs.mjc_view
+import kotlinx.cinterop.CPointer
+import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.cinterop.IntVar
+import kotlinx.cinterop.alloc
+import kotlinx.cinterop.memScoped
+import kotlinx.cinterop.ptr
+import kotlinx.cinterop.toKString
+import kotlinx.cinterop.value
+import kotlinx.serialization.json.JsonObject
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+import mjdev.compositor.shim.mjc_view_app_id
+import mjdev.compositor.shim.mjc_view_get_geometry
+import mjdev.compositor.shim.mjc_view_id
+import mjdev.compositor.shim.mjc_view_is_maximized
+import mjdev.compositor.shim.mjc_view_pid
+import mjdev.compositor.shim.mjc_view_title
+
+class WindowInfo(
+    val ptr: CPointer<mjc_view>,
+    val id: ULong,
+    val xwayland: Boolean,
+) {
+    var appId: String? = null
+    var title: String? = null
+    var pid: Int = 0
+    var mapped: Boolean = false
+    var minimized: Boolean = false
+    var focused: Boolean = false
+    var shell: Boolean = false
+    var role: String? = null
+
+    /* pulls current strings/pid from the native view */
+    fun refresh() {
+        appId = mjc_view_app_id(ptr)?.toKString() ?: appId
+        title = mjc_view_title(ptr)?.toKString() ?: title
+        pid = mjc_view_pid(ptr)
+    }
+
+    /** current geometry as "x,y WxH" pulled live from the native view, for logging */
+    fun geometry(): String = memScoped {
+        val x = alloc<IntVar>()
+        val y = alloc<IntVar>()
+        val w = alloc<IntVar>()
+        val h = alloc<IntVar>()
+        mjc_view_get_geometry(ptr, x.ptr, y.ptr, w.ptr, h.ptr)
+        "${x.value},${y.value} ${w.value}x${h.value}"
+    }
+
+    /** short identity for log lines */
+    fun describe(): String =
+        "id=$id app=${appId ?: "-"} title=\"${title ?: "-"}\" pid=$pid " +
+            "shell=$shell xwayland=$xwayland geom=[${geometry()}]"
+
+    fun toJson(): JsonObject = buildJsonObject {
+        put("id", id.toLong())
+        put("app_id", appId)
+        put("title", title)
+        put("pid", pid)
+        put("xwayland", xwayland)
+        put("mapped", mapped)
+        put("minimized", minimized)
+        put("maximized", mjc_view_is_maximized(ptr))
+        memScoped {
+            val x = alloc<IntVar>()
+            val y = alloc<IntVar>()
+            val w = alloc<IntVar>()
+            val h = alloc<IntVar>()
+            mjc_view_get_geometry(ptr, x.ptr, y.ptr, w.ptr, h.ptr)
+            put("x", x.value)
+            put("y", y.value)
+            put("width", w.value)
+            put("height", h.value)
+        }
+        put("focused", focused)
+        put("shell", shell)
+    }
+}
+
+class WindowModel {
+    private val byPtr = LinkedHashMap<Long, WindowInfo>()
+
+    fun add(view: CPointer<mjc_view>, xwayland: Boolean): WindowInfo {
+        val info = WindowInfo(view, mjc_view_id(view), xwayland)
+        byPtr[view.rawValue.toLong()] = info
+        return info
+    }
+
+    fun get(view: CPointer<mjc_view>): WindowInfo? =
+        byPtr[view.rawValue.toLong()]
+
+    fun remove(view: CPointer<mjc_view>): WindowInfo? =
+        byPtr.remove(view.rawValue.toLong())
+
+    fun byId(id: Long): WindowInfo? =
+        byPtr.values.firstOrNull { it.id.toLong() == id }
+
+    fun all(): List<WindowInfo> = byPtr.values.toList()
+
+    /* windows visible to clients of the IPC api (no shell internals) */
+    fun listed(): List<WindowInfo> =
+        byPtr.values.filter { it.mapped && !it.shell }
+}

+ 10 - 0
desktopApp/desktopApp.gradle.kts

@@ -27,6 +27,16 @@ compose.desktop {
     application {
         jvmArgs("-Dprism.order=sw")
         mainClass = "org.mjdev.desktop.main.MainKt"
+        // jpackage (createDistributable/packageDeb) needs a full JDK; the IDE's bundled JBR
+        // (e.g. Android Studio's) ships without jpackage, so resolve a jpackage-capable JDK
+        // explicitly. Override with JPACKAGE_HOME or JAVA_HOME; falls back to system JDKs.
+        sequenceOf(
+            System.getenv("JPACKAGE_HOME"),
+            System.getenv("JAVA_HOME"),
+            "/usr/lib/jvm/java-21-openjdk-amd64",
+            "/usr/lib/jvm/java-17-openjdk-amd64",
+        ).filterNotNull().firstOrNull { rootProject.file("$it/bin/jpackage").canExecute() }
+            ?.let { javaHome = it }
         nativeDistributions {
             packageName = libs.versions.app.name.get()
             packageVersion = libs.versions.app.pkg.version.get()

BIN
fix-it/Snímek obrazovky z 2026-06-14 22-01-09.png


+ 1 - 0
gradle/libs.versions.toml

@@ -184,6 +184,7 @@ compose = { id = "org.jetbrains.compose", version.ref = "compose-plugin" }
 compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
 kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
 kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
+kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
 kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" }
 qodana = { id = "org.jetbrains.qodana", version.ref = "qodana" }
 intellij-platform = { id = "org.jetbrains.intellij.platform", version.ref = "intelliJPlatform" }

+ 35 - 0
session/mjdev-session

@@ -0,0 +1,35 @@
+#!/bin/sh
+# mjdev desktop wayland session
+# started by the display manager via /usr/share/wayland-sessions/mjdev.desktop
+
+export XDG_CURRENT_DESKTOP=mjdev
+export XDG_SESSION_TYPE=wayland
+export MOZ_ENABLE_WAYLAND=1
+export QT_QPA_PLATFORM="wayland;xcb"
+export _JAVA_AWT_WM_NONREPARENTING=1
+
+# desktop shell binary - deb/appimage install location, gradle dev fallback
+SHELL_CMD="/opt/mjdev-desktop/bin/mjdev-desktop"
+if [ ! -x "$SHELL_CMD" ]; then
+    SHELL_CMD="$HOME/.local/bin/mjdev-desktop"
+fi
+
+# compositor binary - /usr/bin (deb) or /usr/local/bin (manual install)
+MJDEVC="$(command -v mjdevc 2>/dev/null)"
+if [ -z "$MJDEVC" ]; then
+    MJDEVC="/usr/bin/mjdevc"
+fi
+
+# clean up the activation environment the compositor exported into the
+# persistent systemd --user manager. with KillUserProcesses=no (the distro
+# default) that manager survives logout, so leaving a stale WAYLAND_DISPLAY /
+# DISPLAY behind poisons the next login - of any session, not just ours.
+# run on every exit path (normal exit, crash, signal) - no exec, so we get here.
+cleanup() {
+    systemctl --user unset-environment \
+        WAYLAND_DISPLAY DISPLAY XDG_CURRENT_DESKTOP XDG_SESSION_TYPE \
+        2>/dev/null || true
+}
+trap cleanup EXIT HUP INT TERM
+
+"$MJDEVC" --session --shell-cmd "$SHELL_CMD"

+ 6 - 0
session/mjdev.desktop

@@ -0,0 +1,6 @@
+[Desktop Entry]
+Name=Mjdev Desktop
+Comment=Mjdev desktop environment (wayland)
+Exec=mjdev-session
+Type=Application
+DesktopNames=mjdev

+ 1 - 0
settings.gradle.kts

@@ -81,6 +81,7 @@ dependencyResolutionManagement {
 include(":shared")
 include(":androidApp")
 include(":desktopApp")
+include(":compositor")
 //</editor-fold>------------------------------------------------------------------------------------
 
 //<editor-fold desc="rename gradle build files">----------------------------------------------------

+ 2 - 0
shared/shared.gradle.kts

@@ -277,6 +277,8 @@ kotlin {
                 implementation(kotlin("reflect"))
                 // preview
                 implementation(compose.components.uiToolingPreview)
+                // preview rendering in the IDE (org.jetbrains.compose.ui:ui-tooling)
+                implementation(compose.uiTooling)
                 // activity
                 implementation(libs.androidx.activity.compose)
                 // network client

+ 4 - 1
shared/src/androidMain/kotlin/org/mjdev/desktop/data/User.kt

@@ -33,7 +33,10 @@ class User(
     override val picture: Any
         get() = AccountCircle // todo
     override val homeDir: Path
-        get() = Environment.getExternalStorageDirectory().path.toPath()
+        get() =
+            runCatching {
+                Environment.getExternalStorageDirectory().path.toPath()
+            }.getOrNull() ?: "".toPath()
     override val isLoggedIn
         get() = isLoggedIn(userName) // todo
     override val userDirs: IUserDirs

+ 3 - 1
shared/src/androidMain/kotlin/org/mjdev/desktop/helpers/system/meminfo/MemInfoAndroid.kt

@@ -12,7 +12,9 @@ class MemInfoAndroid(
     val aContext: Context?
         get() = (context as? DesktopContext)?.context
     val activityManager: ActivityManager? by lazy {
-        aContext?.getSystemService(ACTIVITY_SERVICE) as ActivityManager
+        runCatching {
+            aContext?.getSystemService(ACTIVITY_SERVICE) as ActivityManager
+        }.getOrNull()
     }
     private val meminfo: ActivityManager.MemoryInfo
         get() =

+ 2 - 2
shared/src/commonMain/kotlin/org/mjdev/desktop/components/desktop/Desktop.kt

@@ -109,8 +109,8 @@ fun Desktop(
                                 16.dp,
                                 16.dp,
                                 16.dp,
-                                16.dp
-                            )
+                                16.dp,
+                            ),
                         ),
             )
         }

+ 4 - 4
shared/src/commonMain/kotlin/org/mjdev/desktop/components/sliding/base/VisibilityState.kt

@@ -53,14 +53,14 @@ open class VisibilityState(
     }
 
     open suspend fun show() {
-//        println("Show called.")
+        Log.d("Sliding: show (was=${visibleState.value}) size=${sizeState.value}")
         hideJob?.cancel()
         visibleState.value = true
     }
 
     open suspend fun hide(force: Boolean = false) {
         if (enabledState.value || force) {
-//            println("Hide called.")
+            Log.d("Sliding: hide (was=${visibleState.value}, force=$force, delay=$hideDelay) size=${sizeState.value}")
             hideJob?.cancel()
             if (hideDelay == 0L) {
                 visibleState.value = false
@@ -79,14 +79,14 @@ open class VisibilityState(
         onVisibilityChange: (visible: Boolean) -> Unit = {},
     ) {
         if (enabledState.value || force) {
-            println("Toggle visible state from: ${visibleState.value} to ${!visibleState.value}")
+            Log.d("Sliding: toggle ${visibleState.value} -> ${!visibleState.value} size=${sizeState.value}")
             visibleState.toggle()
             onVisibilityChange(isVisible)
         }
     }
 
     open suspend fun updateSize(size: DpSize) {
-//        println("Size update: $size")
+        Log.d("Sliding: size update ${sizeState.value} -> $size")
         this.sizeState.value = size
     }
 

+ 1 - 1
shared/src/commonMain/kotlin/org/mjdev/desktop/managers/palette/Palette.kt

@@ -31,6 +31,7 @@ class Palette(
     val initialColor: Color = Color.SuperDarkGray,
     val borderFactor: Float = 0.1f,
     val textFactor: Float = 0.6f,
+    val cutPercent: Int = 6,
 ) : IPalette {
     val backgroundColorState: MutableState<Color> = mutableStateOf(initialColor)
 
@@ -110,7 +111,6 @@ class Palette(
             context.loadPicture(src)?.let { image ->
                 val width = image.width
                 val height = image.height
-                val cutPercent = 5
                 val cutWidth = (width / 100) * cutPercent
                 val cutHeight = (height / 100) * cutPercent
                 val leftTopPart = image.cut(0, 0, cutWidth, cutHeight)

BIN
shared/src/commonMain/resources/drawable/icon.png


+ 0 - 21
shared/src/commonMain/resources/drawable/icon.svg

@@ -1,21 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
-<svg version="1.1"
-    xmlns="http://www.w3.org/2000/svg"
-    xmlns:xlink="http://www.w3.org/1999/xlink"
-    x="0px"
-    y="0px"
-    viewBox="0 0 256 256"
-    enable-background="new 0 0 256 256"
-    xml:space="preserve">
-<metadata> Svg Vector Icons : http://www.onlinewebfonts.com/icon </metadata>
-    <g><g><g><path fill="#000000" d="M73.8,48.5c-1.6,0.6-3.8,2.1-4.7,3.4c-1.6,2.2-1.8,3.8-2.1,18.7l-0.3,16.2H50.2c-15.4,0-16.7,0.1-18.9,1.8l-2.2,1.7v17.9v17.8l-7.7,0.3C9,127,10,123.5,10,166.3c0,19.8,0.1,36.6,0.1,37.4c0.1,0.9,0.9,2.2,2,3.3c1.8,1.7,2.7,1.7,52.2,1.7c49.6,0,50.4,0,52.6-1.8c2.2-1.7,2.2-1.8,2.2-13.7v-11.9h9.5h9.6l-0.6,3.3c-0.9,6.1-2,7.6-6.4,9.9c-5.1,2.6-6.9,5-6.9,8.8c0,5.3,0.4,5.4,33.2,5.4c28.3,0,29.5-0.1,31.1-1.7c3.9-3.9,1.3-9.2-5.9-12.3c-4.9-2.1-5.2-2.4-6.4-6.7c-0.8-2.5-1.4-5.1-1.4-5.7c0-0.9,6.1-1.1,31.7-1.1c35.3,0,35.5,0,38.1-5.5c1.3-2.7,1.4-8.6,1.2-62.2c-0.3-58.5-0.3-59.2-2.1-61.6c-0.9-1.3-3.1-2.8-4.7-3.4C235,47,78,47,73.8,48.5z M239.1,53.9l1.9,1.3v47.2v47.1l-60.7-0.2l-60.7-0.3l-0.3-29.3l-0.2-29.3l-2.2-1.7c-2.1-1.7-3.3-1.8-23.6-1.8H72V71.4C72,57,72,55.8,73.7,54.2c1.7-1.7,2.8-1.7,82.6-1.7C228.1,52.5,237.4,52.7,239.1,53.9z M114.4,147.7v55.3l-28.1,0.3l-28.1,0.2v-35.9v-36l-2.1-2.4c-2.1-2.3-2.3-2.4-12.4-2.7l-10.4-0.3v-16.5c0-9.1,0.3-16.8,0.6-17.1c0.3-0.3,18.5-0.5,40.5-0.4l40,0.3V147.7z M52.9,167.7l0.2,35.8l-19-0.2l-19.1-0.3l-0.3-34.7c-0.1-19.1,0-35.3,0.3-35.9c0.3-0.9,4.5-1,19-0.9l18.7,0.3L52.9,167.7z M241,163.5c0,7.8-0.3,10.1-1.4,11.2c-1.2,1.2-7.9,1.4-60.9,1.4h-59.5v-11.2v-11.2H180H241L241,163.5L241,163.5z M170.7,186.1c1.6,7.6,2.8,9.4,8.7,12.4c2.9,1.5,5.5,3.3,5.7,3.9c0.3,0.9-5.7,1.1-27.6,1.1c-24.1,0-28-0.2-28-1.3c0-0.7,1.6-2.1,3.7-3.1c5.9-3,7.2-4.7,8.8-11.6l1.5-6.3h13h13.1L170.7,186.1z"/><path
-        fill="#000000"
-        d="M152.3,61.5c-2.6,2.6-0.8,3.9,5,3.7c4.5-0.3,5.2-0.5,5.5-2.1c0.2-1.3-0.3-2-1.7-2.4C157.5,59.9,153.6,60.2,152.3,61.5z" /><path
-        fill="#000000"
-        d="M71.1,95.7c-2.3,2.8,1.5,6.2,4.1,3.6c1.1-1.1,1.2-1.7,0.5-2.9C74.4,94.4,72.5,94.2,71.1,95.7z" /><path
-        fill="#000000"
-        d="M68,189.3c-0.9,2.3,0.9,3.1,6.6,2.8c4.8-0.3,5.5-0.4,5.5-2c0-1.5-0.7-1.7-5.8-2C69.6,188,68.4,188.2,68,189.3z" /><path
-        fill="#000000"
-        d="M27.7,191.9c-0.9,2.2,0.6,3,6.1,3c4.1,0,5.5-0.3,5.9-1.4c0.9-2.3-0.3-2.9-6-2.9C29.7,190.6,28,191,27.7,191.9z" /></g></g></g>
-</svg>

+ 128 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/helpers/mouseevents/CompositorMouseSource.kt

@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) Milan Jurkulák 2026.
+ *  Contact:
+ *  e: mimoccc@gmail.com
+ *  e: mj@mjdev.org
+ *  w: https://mjdev.org
+ */
+
+package org.mjdev.desktop.helpers.mouseevents
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import org.mjdev.desktop.log.Log
+import java.awt.Point
+import java.net.StandardProtocolFamily
+import java.net.UnixDomainSocketAddress
+import java.nio.ByteBuffer
+import java.nio.channels.SocketChannel
+import java.nio.charset.StandardCharsets
+import java.nio.file.Files
+import java.nio.file.Path
+
+/**
+ * Pointer source backed by the mjdev compositor's IPC socket.
+ *
+ * The desktop shell runs *inside* mjdevc (both nested and on a real session), and the
+ * compositor knows the true cursor position — unlike AWT's global pointer, which is stuck at
+ * screen-centre under nested XWayland. When the socket is present we subscribe and feed the
+ * broadcast `{"event":"pointer","x":..,"y":..}` lines into the same edge-detection pipeline.
+ * Each window opens its own connection, so every overlay window receives the global pointer.
+ */
+class CompositorMouseSource private constructor(
+    private val channel: SocketChannel,
+    private val scope: CoroutineScope,
+    private val onEvent: (event: Point) -> Unit,
+) {
+    private var job: Job? = null
+    private var pointerCount = 0L
+
+    private fun start() {
+        Log.d("CompositorMouseSource: subscribing to compositor pointer stream")
+        channel.write(ByteBuffer.wrap("{\"cmd\":\"subscribe\"}\n".toByteArray(StandardCharsets.UTF_8)))
+        job =
+            scope.launch(Dispatchers.IO) {
+                val buffer = ByteBuffer.allocate(8192)
+                val pending = StringBuilder()
+                runCatching {
+                    while (isActive) {
+                        buffer.clear()
+                        val read = channel.read(buffer)
+                        if (read < 0) break
+                        if (read == 0) continue
+                        buffer.flip()
+                        pending.append(StandardCharsets.UTF_8.decode(buffer))
+                        var nl = pending.indexOf("\n")
+                        while (nl >= 0) {
+                            handleLine(pending.substring(0, nl))
+                            pending.delete(0, nl + 1)
+                            nl = pending.indexOf("\n")
+                        }
+                    }
+                }.onFailure { e -> Log.w("CompositorMouseSource: stream ended: ${e.message}") }
+                Log.d("CompositorMouseSource: pointer stream closed")
+            }
+    }
+
+    // line shape is fixed: {"event":"pointer","x":<int>,"y":<int>} — parse without a json dep
+    private fun handleLine(line: String) {
+        if (!line.contains("\"event\":\"pointer\"")) return
+        val x = readInt(line, "\"x\":") ?: return
+        val y = readInt(line, "\"y\":") ?: return
+        // medium verbose: pointer is high frequency, sample 1/POINTER_SAMPLE
+        if (pointerCount++ % POINTER_SAMPLE == 0L) {
+            Log.d("CompositorMouseSource: pointer ($x, $y) [#$pointerCount]")
+        }
+        onEvent(Point(x, y))
+    }
+
+    private fun readInt(
+        source: String,
+        key: String,
+    ): Int? {
+        val at = source.indexOf(key)
+        if (at < 0) return null
+        var end = at + key.length
+        if (end < source.length && source[end] == '-') end++
+        while (end < source.length && source[end].isDigit()) end++
+        return source.substring(at + key.length, end).toIntOrNull()
+    }
+
+    fun dispose() {
+        Log.d("CompositorMouseSource: dispose")
+        job?.cancel()
+        runCatching { channel.close() }
+    }
+
+    companion object {
+        // medium verbose: pointer is high frequency, only 1 in POINTER_SAMPLE is logged
+        private const val POINTER_SAMPLE = 30L
+
+        private fun socketPath(): Path? {
+            val dir = System.getenv("XDG_RUNTIME_DIR") ?: return null
+            val path = Path.of(dir, "mjdev-compositor.sock")
+            return if (Files.exists(path)) path else null
+        }
+
+        /** Connects to a running compositor; returns null when none is running. */
+        fun connect(
+            scope: CoroutineScope,
+            onEvent: (event: Point) -> Unit,
+        ): CompositorMouseSource? {
+            val path = socketPath()
+            if (path == null) {
+                Log.d("CompositorMouseSource: no compositor socket (XDG_RUNTIME_DIR/mjdev-compositor.sock) — not connecting")
+                return null
+            }
+            return runCatching {
+                val channel = SocketChannel.open(StandardProtocolFamily.UNIX)
+                channel.connect(UnixDomainSocketAddress.of(path))
+                Log.i("CompositorMouseSource: connected to compositor at $path")
+                CompositorMouseSource(channel, scope, onEvent).also { it.start() }
+            }.onFailure { e -> Log.w("CompositorMouseSource: connect failed: ${e.message}") }.getOrNull()
+        }
+    }
+}

+ 10 - 5
shared/src/desktopMain/kotlin/org/mjdev/desktop/helpers/mouseevents/GlobalMouseListener.kt

@@ -51,14 +51,19 @@ class GlobalMouseListener(
         ) {
             DisposableEffect(Unit) {
                 val handler = MouseEventHandler(enabled, block)
+                // Prefer the compositor's pointer feed when one is running (reliable everywhere,
+                // including nested XWayland where the AWT global pointer is stuck at screen
+                // centre). Fall back to AWT polling only when no compositor is present.
+                val ipc = CompositorMouseSource.connect(scope) { event -> handler.onEvent(event) }
                 val globalHandler =
-                    GlobalMouseListener(
-                        scope = scope,
-                    ) { event ->
-                        handler.onEvent(event)
+                    if (ipc == null) {
+                        GlobalMouseListener(scope = scope) { event -> handler.onEvent(event) }
+                    } else {
+                        null
                     }
                 onDispose {
-                    globalHandler.dispose()
+                    ipc?.dispose()
+                    globalHandler?.dispose()
                 }
             }
         }

+ 9 - 11
shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/processes/ProcessManager.kt

@@ -65,14 +65,11 @@ class ProcessManager(
     }
 
     private fun cleanup() {
-        processes.iterator().apply {
-            while (hasNext()) {
-                val pw = next()
-                if (pw.processHandle?.isAlive != true) {
-                    remove()
-                    listeners.filterIsInstance<ProcessRemoveListener>().forEach { l ->
-                        l.onProcessRemoved(pw.processHandle)
-                    }
+        processes.toList().forEach { pw ->
+            if (pw.processHandle?.isAlive != true) {
+                processes.remove(pw)
+                listeners.filterIsInstance<ProcessRemoveListener>().forEach { l ->
+                    l.onProcessRemoved(pw.processHandle)
                 }
             }
         }
@@ -96,7 +93,7 @@ class ProcessManager(
             val appCmd = app?.cmd
             val appName = app?.name
             val appFullName = app?.fullAppName
-            processes.any { pw ->
+            processes.toList().any { pw ->
                 (appName != null && pw.command.contains(appName)) ||
                     (appCmd != null && pw.command.contains(appCmd)) ||
                     (appFullName != null && pw.command.contains(appFullName)) ||
@@ -111,10 +108,11 @@ class ProcessManager(
     companion object {
         fun SnapshotStateList<ProcessWrapper>.containsProcess(ph: ProcessHandle) =
             ph.pid().let { pid ->
-                any { p -> p.pid == pid }
+                toList().any { p -> p.pid == pid }
             }
 
-        fun SnapshotStateList<ProcessWrapper>.containsProcess(ph: ProcessWrapper) = any { p -> p.pid == ph.pid }
+        fun SnapshotStateList<ProcessWrapper>.containsProcess(ph: ProcessWrapper) =
+            toList().any { p -> p.pid == ph.pid }
 
         @Composable
         fun processManagerListener(onChanged: IProcessManager?.(processHandle: ProcessHandle?) -> Unit = {}) =

+ 4 - 1
shared/src/desktopMain/kotlin/org/mjdev/desktop/windows/ChromeWindow.kt

@@ -72,7 +72,10 @@ fun ChromeWindow(
             onCloseRequest = onCloseRequest,
             state = windowState,
             visible = visible,
-            title = "",
+            // Encode the window role into the title as "mjdev::<name>" so the mjdev compositor
+            // can layer shell windows correctly (background / panel / top). The window is
+            // undecorated, so the title is never visible; on a non-mjdev WM it is just ignored.
+            title = name?.let { "mjdev::$it" } ?: "",
             icon = null,
             undecorated = true,
             transparent = transparent,

+ 23 - 6
shared/src/desktopMain/kotlin/org/mjdev/desktop/windows/ChromeWindowState.kt

@@ -84,7 +84,7 @@ open class ChromeWindowState(
 
     override var position: DpOffset = position
         set(value) {
-//            Log.i("Setting position of window : ${window?.name}")
+            Log.d("ChromeWindow position: ${field} -> $value")
             field = value
             if (isCreated) {
                 scope.launch {
@@ -95,7 +95,7 @@ open class ChromeWindowState(
 
     override var size: DpSize = size
         set(value) {
-//            Log.i("Setting size of window : ${window?.name}, $size")
+            Log.d("ChromeWindow size: ${field} -> $value")
             val oldSize = field
             field = value
             if (isCreated) {
@@ -117,6 +117,23 @@ open class ChromeWindowState(
     val y: Dp
         get() = position.y
 
+    /** name + intended position/size + actual AWT bounds, for logging the window geometry */
+    private fun geom(): String {
+        val w = if (isCreated) window else null
+        val bounds = w?.let { " bounds=[${it.x},${it.y} ${it.width}x${it.height}]" } ?: ""
+        return "name=${w?.name ?: "-"} pos=$position size=$size$bounds"
+    }
+
+    override suspend fun show() {
+        Log.d("ChromeWindow show: ${geom()}")
+        super.show()
+    }
+
+    override suspend fun hide(force: Boolean) {
+        Log.d("ChromeWindow hide(force=$force): ${geom()}")
+        super.hide(force)
+    }
+
     fun onOpened(block: ChromeWindowState.() -> Unit) {
         onOpened.add(block)
     }
@@ -133,7 +150,7 @@ open class ChromeWindowState(
         x: Dp,
         y: Dp,
     ) = runCatching {
-//        Log.i("moveBy called ($x, $y)")
+        Log.d("ChromeWindow moveBy ($x, $y): ${geom()}")
         setPosition(
             DpOffset(
                 position.x - x,
@@ -144,13 +161,13 @@ open class ChromeWindowState(
 
     suspend fun setSize(size: DpSize) =
         runCatching {
-//        Log.i("Setting window size : ${window?.name} to $size")
+            Log.d("ChromeWindow setSize -> $size: ${geom()}")
             window?.setSizeSafely(size, WindowPlacement.Floating)
         }.onFailure { e -> Log.e(e) }
 
     suspend fun setPosition(position: DpOffset) =
         runCatching {
-//        Log.i("Setting window position : ${window?.name} to $position")
+            Log.d("ChromeWindow setPosition -> $position: ${geom()}")
             window?.setPosition(
                 WindowPosition.Absolute(position.x, position.y),
                 WindowPlacement.Floating,
@@ -159,7 +176,7 @@ open class ChromeWindowState(
 
     override suspend fun focus() {
         runCatching {
-//            Log.i("Requesting window focus : ${window?.name}")
+            Log.d("ChromeWindow focus: ${geom()}")
             window?.toFront()
             if (!isFocused) {
                 window?.requestFocus()