Przeglądaj źródła

dockbar fix

cursor hallucination
Milan Jurkulak 3 miesięcy temu
rodzic
commit
d11a837c4c

+ 61 - 45
.github/workflows/release.yml

@@ -76,39 +76,39 @@ jobs:
             echo "::warning::no ANDROID_KEYSTORE_BASE64 secret — APK will be debug-signed"
           fi
 
-      # ---------- linux desktop formats + android apk + reports (ubuntu) ----------
-      # One invocation with --continue so deb/rpm/AppImage/apk/reports each build to
-      # completion independently: a failure in one does NOT abort the others.
-      - name: Build linux desktop + android + reports
+      # Single apt transaction on ubuntu — avoids dpkg lock races from overlapping apt-get runs.
+      # Compositor build headers/tools (wayland-scanner, wlroots pkg-config, …) are required for
+      # :compositor:stageSession -> packageFullDeb -> collectReleases; without them deb/rpm/iso
+      # never land in releases/ even though the android APK still builds.
+      - name: Install Linux build dependencies
         if: matrix.os == 'ubuntu-latest'
         shell: bash
-        continue-on-error: true
-        env:
-          # ANDROID_KEYSTORE_FILE is exported by the decode step above (if present)
-          ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
-          ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
-          ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
         run: |
           sudo apt-get update
-          sudo apt-get install -y rpm || echo "::warning::rpm tools missing — .rpm may be skipped"
-          ./gradlew packageFullDeb :desktopApp:packageReleaseRpm packageAppImageFile \
-            :androidApp:assembleRelease postBuildCodeCheck \
-            -PdepCheck=false --continue --stacktrace \
-            || echo "::warning::one or more linux/android formats failed — others still collected"
-
-      # ---------- bootable live ISO (ubuntu, needs root) ----------
-      # Separate step because makeIso needs root (debootstrap/chroot/mksquashfs); on CI it
-      # runs via passwordless sudo. Isolated so a failed/slow ISO can't drop the other formats.
-      - name: Build live ISO
+          sudo apt-get install -y \
+            rpm debootstrap squashfs-tools xorriso mtools dpkg-dev \
+            grub-common grub-pc-bin grub-efi-amd64-bin debian-archive-keyring zip \
+            pkg-config libwayland-dev wayland-protocols libwlroots-0.18-dev \
+            libxkbcommon-dev libcairo2-dev libpixman-1-dev \
+            || echo "::warning::some apt packages missing — some formats may be skipped"
+
+      # ---------- linux + android + iso + reports (ubuntu) ----------
+      # buildAll = collectReleases (deb/rpm/AppImage/apk -> releases/) + makeIso + reports.
+      # --continue keeps going when one format fails; makeIso is warn-only so ISO never
+      # blocks the formats that already succeeded.
+      - name: Build linux + android + iso + reports
         if: matrix.os == 'ubuntu-latest'
         shell: bash
         continue-on-error: true
+        env:
+          ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
+          ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
+          ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_KEY_PASSWORD }}
         run: |
-          sudo apt-get install -y debootstrap squashfs-tools xorriso mtools dpkg-dev \
-            grub-common grub-pc-bin grub-efi-amd64-bin debian-archive-keyring \
-            || echo "::warning::iso toolchain missing — .iso may be skipped"
-          ./gradlew makeIso -PdepCheck=false --stacktrace \
-            || echo "::warning::makeIso failed — .iso skipped"
+          ./gradlew buildAll -PdepCheck=false --continue --stacktrace \
+            || echo "::warning::buildAll had failures — collecting partial artifacts"
+          echo "=== releases/ ==="
+          ls -la releases/ 2>/dev/null || echo "(empty)"
           ls -la releases/*.iso 2>/dev/null && echo "ISO produced" \
             || echo "::warning::no .iso in releases/ — makeIso skipped or failed"
 
@@ -135,39 +135,55 @@ jobs:
         run: ./gradlew :desktopApp:packageReleaseDmg -PdepCheck=false --stacktrace || echo "::warning::macOS .dmg failed — skipped"
 
       # ---------- collect whatever got produced (versioned names) ----------
-      # Copies from every known jpackage/build output dir; dirs absent on this OS are simply
-      # skipped. Mirrors the rename scheme of the :collectReleases Gradle task but tolerates
-      # partial builds so we never lose the formats that DID succeed.
+      # Primary source is releases/ (collectReleases + makeIso). Fall back to jpackage output
+      # dirs for partial builds and for platform-specific runners (exe/dmg).
       - name: Collect artifacts
         shell: bash
+        continue-on-error: true
         run: |
+          set +e
           VER='${{ steps.meta.outputs.version }}'
           APP='${{ steps.meta.outputs.appname }}'
           BASE="$APP-$VER"
           PKG=packages/main-release
           mkdir -p upload
-          copy() { # <glob-dir> <ext>
-            for f in "$1"/*."$2"; do
-              [ -e "$f" ] && cp -v "$f" "upload/$BASE.$2" && return 0
+
+          copy_pkg() {
+            local dir="$1" ext="$2"
+            for f in "$dir"/*."$ext"; do
+              if [ -e "$f" ]; then
+                cp -v "$f" "upload/$BASE.$ext"
+                return 0
+              fi
             done
             return 0
           }
-          copy "$PKG/deb" deb
-          copy "$PKG/rpm" rpm
-          copy "$PKG/appimage" AppImage
-          copy "$PKG/exe" exe
-          copy "$PKG/dmg" dmg
-          for f in androidApp/build/outputs/apk/release/*.apk; do
-            [ -e "$f" ] && cp -v "$f" "upload/$BASE.apk" && break
-          done
-          for f in releases/*.iso; do
-            [ -e "$f" ] && cp -v "$f" "upload/$BASE.iso" && break
-          done
-          # reports tree zipped into the release
+
+          if compgen -G "releases/*" > /dev/null; then
+            cp -av releases/* upload/
+          fi
+
+          copy_pkg "$PKG/exe" exe
+          copy_pkg "$PKG/dmg" dmg
+          [ -f "upload/$BASE.deb" ] || copy_pkg "$PKG/deb" deb
+          [ -f "upload/$BASE.rpm" ] || copy_pkg "$PKG/rpm" rpm
+          [ -f "upload/$BASE.AppImage" ] || copy_pkg "$PKG/appimage" AppImage
+          if [ ! -f "upload/$BASE.apk" ]; then
+            for f in androidApp/build/outputs/apk/release/*.apk; do
+              [ -e "$f" ] && cp -v "$f" "upload/$BASE.apk" && break
+            done
+          fi
+          if [ ! -f "upload/$BASE.iso" ]; then
+            for f in releases/*.iso; do
+              [ -e "$f" ] && cp -v "$f" "upload/$BASE.iso" && break
+            done
+          fi
+
           if [ -d reports ] && [ -n "$(ls -A reports 2>/dev/null)" ]; then
             (cd reports && zip -r "../upload/$BASE-reports.zip" .) || echo "::warning::reports zip failed"
           fi
-          echo "=== produced for ${{ matrix.target }} ==="; ls -la upload/ || true
+          echo "=== produced for ${{ matrix.target }} ==="
+          ls -la upload/ || true
 
       - name: Upload build artifacts
         if: always()
@@ -210,4 +226,4 @@ jobs:
           files: dist/**
           fail_on_unmatched_files: false
         env:
-          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

+ 10 - 1
build.gradle.kts

@@ -247,7 +247,12 @@ val makeIso = tasks.register("makeIso") {
             "--out", isoOutPath)
         logger.lifecycle("makeIso: ${args.joinToString(" ")}")
         val code = ProcessBuilder(args).inheritIO().start().waitFor()
-        check(code == 0) { "make-iso.sh failed (exit $code)" }
+        if (code != 0) {
+            logger.warn(
+                "::warning::makeIso: make-iso.sh failed (exit $code) — ISO skipped; " +
+                    "other distributables are still published",
+            )
+        }
     }
 }
 
@@ -262,6 +267,10 @@ tasks.register("runIsoQemu") {
     }
 }
 
+// ISO is built after collectReleases so deb/rpm/AppImage/apk land in releases/ even when
+// make-iso.sh is slow or fails; makeIso never fails the aggregate (warn-only on error).
+makeIso.configure { mustRunAfter(collectReleases) }
+
 val buildAll = tasks.register("buildAll") {
     group = "mjdev"
     description = "Builds all distributables this host can produce, collects them into releases/ (stable names), and generates reports into reports/ — like every build."

+ 1 - 0
desktopApp/desktopApp.pro

@@ -21,6 +21,7 @@
 -dontwarn com.couchbase.**
 -dontwarn com.google.**
 -dontwarn com.jogamp.**
+-dontwarn com.sun.javafx.**
 -dontwarn javafx.**
 -dontwarn community.flock.**
 -dontwarn io.github.**

+ 21 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/dockbar/DockBarMetrics.kt

@@ -0,0 +1,21 @@
+package org.mjdev.desktop.components.dockbar
+
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.DpSize
+import org.mjdev.desktop.interfaces.ITheme
+
+object DockBarMetrics {
+    fun expandedHeight(
+        theme: ITheme,
+        iconSize: DpSize,
+        iconPadding: PaddingValues,
+        iconOuterPadding: PaddingValues,
+    ): Dp =
+        iconSize.height +
+            iconPadding.calculateTopPadding() +
+            iconPadding.calculateBottomPadding() +
+            iconOuterPadding.calculateTopPadding() +
+            iconOuterPadding.calculateBottomPadding() +
+            theme.panelContentPadding * 2
+}

+ 42 - 8
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/dockbar/DockBarWindow.kt

@@ -5,6 +5,9 @@ import androidx.compose.foundation.layout.PaddingValues
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.DpOffset
 import androidx.compose.ui.unit.DpSize
@@ -49,6 +52,7 @@ fun DockBarWindow(
     menuState: ChromeWindowState = rememberChromeWindowState(),
     onMenuIconClicked: () -> Unit = {
         runAsync {
+            panelState.show()
             menuState.showOrFocus()
         }
     },
@@ -65,13 +69,17 @@ fun DockBarWindow(
     onAppContextMenuClick: (IApp) -> Unit = {},
     onLanguageClick: () -> Unit = {},
     onTooltip: (item: Any?) -> Unit = {},
+    /** True when a visible app window overlaps the dock strip — hide until hover reveal. */
+    occludedByApps: Boolean = false,
+    /** When true, skip occlusion-driven show (e.g. control center is open). */
+    suppressOcclusionAutoShow: Boolean = false,
+    /** Menu (or apps menu) open — keep the dock expanded for positioning and clicks. */
+    menuPinned: Boolean = false,
 ) = withDesktopContext {
+    var pointerInDock by remember { mutableStateOf(false) }
     val panelHeight: (visible: Boolean) -> Dp = { visible ->
         if (visible) {
-            iconSize.height +
-                iconPadding.height.times(2) +
-                iconOuterPadding.height.times(2) +
-                theme.panelContentPadding.times(2)
+            DockBarMetrics.expandedHeight(theme, iconSize, iconPadding, iconOuterPadding)
         } else {
             panelDividerWidth
         }
@@ -126,6 +134,22 @@ fun DockBarWindow(
             height = expandedHeight,
         )
     }
+    // Intelligent autohide — never call show() on every tick (that cancels hideDelay hides).
+    LaunchedEffect(occludedByApps, menuPinned, pointerInDock, suppressOcclusionAutoShow) {
+        if (!panelState.enabled) {
+            return@LaunchedEffect
+        }
+        when {
+            suppressOcclusionAutoShow -> panelState.hide(force = true)
+            menuPinned -> panelState.show()
+            !occludedByApps -> {
+                if (panelState.isNotVisible) {
+                    panelState.show()
+                }
+            }
+            !pointerInDock -> panelState.hide()
+        }
+    }
     ChromeWindow(
         name = "DockBar",
         visible = true,
@@ -162,6 +186,7 @@ fun DockBarWindow(
         onGlobalMouse = {
             onPointerEnter(mouseRange) {
                 runAsync {
+                    pointerInDock = true
                     // show() only on a real reveal (not showOrFocus): re-focusing an already-open
                     // dock churns focus-follows-mouse and flip-flops the size, drifting geometry.
                     if (menuState.isNotVisible && panelState.isNotVisible) {
@@ -169,12 +194,21 @@ fun DockBarWindow(
                     }
                 }
             }
-            // autohide when the pointer leaves the expanded dock (and no menu is open, which
-            // must keep the dock alive). Driven by pointer position, not focus, so it no longer
-            // gets stuck open over the non-focusable desktop.
+            onPointerEnter(leaveRange) {
+                runAsync {
+                    pointerInDock = true
+                }
+            }
+            // Collapse after hover-reveal only when an app window covers the dock strip.
             onPointerLeave(leaveRange) {
                 runAsync {
-                    if (panelState.isVisible && menuState.isNotVisible) {
+                    pointerInDock = false
+                    if (
+                        panelState.isVisible &&
+                        !menuPinned &&
+                        occludedByApps &&
+                        !suppressOcclusionAutoShow
+                    ) {
                         panelState.hide()
                     }
                 }

+ 42 - 4
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/main/MainWindow.kt

@@ -7,10 +7,14 @@ import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.DpSize
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.max
+import org.mjdev.desktop.components.dockbar.DockBarMetrics
+import org.mjdev.desktop.helpers.compositor.CompositorWindowTracker
 import org.mjdev.desktop.components.appsmenu.AppsMenuState.Companion.rememberAppsMenuState
 import org.mjdev.desktop.components.appsmenu.AppsMenuWindow
 import org.mjdev.desktop.components.controlcenter.ControlCenterWindow
@@ -45,9 +49,40 @@ fun MainWindow() =
         val panelState =
             rememberChromeWindowState(
                 hideDelay = panelHideDelay,
-                visible = isDesign || !panelAutoHideEnabled,
+                visible = true,
                 enabled = panelAutoHideEnabled,
             )
+        val windowTracker = remember { CompositorWindowTracker(scope) }
+        DisposableEffect(windowTracker) {
+            windowTracker.start()
+            onDispose { windowTracker.stop() }
+        }
+        val appWindows = windowTracker.appWindows
+        val dockExpandedHeight =
+            DockBarMetrics.expandedHeight(
+                theme = theme,
+                iconSize = DpSize(48.dp, 48.dp),
+                iconPadding = PaddingValues(4.dp),
+                iconOuterPadding = PaddingValues(2.dp),
+            )
+        val dockOccludedByApps by rememberCalculated(
+            containerSize.width,
+            containerSize.height,
+            dockExpandedHeight,
+            appWindows.size,
+            appWindows.joinToString("|") {
+                "${it.id},${it.x},${it.y},${it.width},${it.height},${it.minimized}"
+            },
+        ) {
+            if (!panelAutoHideEnabled) {
+                false
+            } else {
+                val screenW = containerSize.width.value.toInt()
+                val screenH = containerSize.height.value.toInt()
+                val dockTop = screenH - dockExpandedHeight.value.toInt()
+                windowTracker.isDockOccluded(screenW, screenH, dockTop)
+            }
+        }
         val menuState =
             rememberChromeWindowState(
                 visible = isDesign,
@@ -76,12 +111,12 @@ fun MainWindow() =
         // step aside (this also frees focus so the control center can actually take it).
         LaunchedEffect(controlCenterState.isVisible) {
             if (controlCenterState.isVisible) {
-                panelState.hide()
+                panelState.hide(force = true)
                 if (menuState.isVisible) {
-                    menuState.hide()
+                    menuState.hide(force = true)
                 }
                 if (appsMenuState.isVisible) {
-                    appsMenuState.hide()
+                    appsMenuState.hide(force = true)
                 }
             }
         }
@@ -143,6 +178,9 @@ fun MainWindow() =
             onTooltip = onTooltip,
             panelState = panelState,
             menuState = menuState,
+            occludedByApps = dockOccludedByApps,
+            suppressOcclusionAutoShow = controlCenterState.isVisible,
+            menuPinned = menuState.isVisible || appsMenuState.isVisible,
             // Autohide is driven purely by pointer-leave (see DockBarWindow.onGlobalMouse), NOT by
             // focus. Hiding on focus-loss flooded hide() under focus-follows-mouse (every pointer
             // flicker over a non-focused window fired a hide) and flip-flopped the dock 16<->80.

+ 46 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/helpers/compositor/CompositorAppWindow.kt

@@ -0,0 +1,46 @@
+package org.mjdev.desktop.helpers.compositor
+
+data class CompositorAppWindow(
+    val id: Long,
+    val x: Int,
+    val y: Int,
+    val width: Int,
+    val height: Int,
+    val minimized: Boolean,
+) {
+    val isVisibleApp: Boolean
+        get() = !minimized && width > 0 && height > 0
+}
+
+internal fun rectsOverlap(
+    ax: Int,
+    ay: Int,
+    aw: Int,
+    ah: Int,
+    bx: Int,
+    by: Int,
+    bw: Int,
+    bh: Int,
+): Boolean {
+    if (aw <= 0 || ah <= 0 || bw <= 0 || bh <= 0) return false
+    val aRight = ax + aw
+    val aBottom = ay + ah
+    val bRight = bx + bw
+    val bBottom = by + bh
+    return ax < bRight && aRight > bx && ay < bBottom && aBottom > by
+}
+
+internal fun anyAppOccludesDockZone(
+    windows: List<CompositorAppWindow>,
+    screenWidth: Int,
+    screenHeight: Int,
+    dockZoneTopY: Int,
+): Boolean {
+    if (screenWidth <= 0 || screenHeight <= 0 || dockZoneTopY >= screenHeight) return false
+    val visible = windows.filter { it.isVisibleApp }
+    if (visible.isEmpty()) return false
+    val dockH = screenHeight - dockZoneTopY
+    return visible.any { w ->
+        rectsOverlap(w.x, w.y, w.width, w.height, 0, dockZoneTopY, screenWidth, dockH)
+    }
+}

+ 163 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/helpers/compositor/CompositorWindowTracker.kt

@@ -0,0 +1,163 @@
+package org.mjdev.desktop.helpers.compositor
+
+import androidx.compose.runtime.mutableStateListOf
+import androidx.compose.runtime.snapshots.SnapshotStateList
+import com.google.gson.JsonArray
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import org.mjdev.desktop.log.Log
+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
+
+/**
+ * Tracks non-shell app windows via mjdevc IPC so the dock can autohide only when an app
+ * window actually overlaps the bottom bar (Windows-style intelligent hide).
+ */
+class CompositorWindowTracker(
+    private val scope: CoroutineScope,
+) {
+    val appWindows: SnapshotStateList<CompositorAppWindow> = mutableStateListOf()
+    private var job: Job? = null
+
+    fun start() {
+        if (job != null) return
+        job =
+            scope.launch(Dispatchers.IO) {
+                runCatching { runLoop() }.onFailure { e ->
+                    Log.w("CompositorWindowTracker: stopped: ${e.message}")
+                }
+            }
+    }
+
+    fun stop() {
+        job?.cancel()
+        job = null
+        appWindows.clear()
+    }
+
+    fun isDockOccluded(
+        screenWidth: Int,
+        screenHeight: Int,
+        dockZoneTopY: Int,
+    ): Boolean = anyAppOccludesDockZone(appWindows, screenWidth, screenHeight, dockZoneTopY)
+
+    private suspend fun runLoop() {
+        val path = socketPath() ?: return
+        SocketChannel.open(StandardProtocolFamily.UNIX).use { channel ->
+            channel.connect(UnixDomainSocketAddress.of(path))
+            channel.write(
+                ByteBuffer.wrap("{\"cmd\":\"subscribe\"}\n".toByteArray(StandardCharsets.UTF_8)),
+            )
+            Log.d("CompositorWindowTracker: connected, subscribed")
+            requestList(channel)
+            val pollJob =
+                scope.launch(Dispatchers.IO) {
+                    while (isActive) {
+                        delay(POLL_MS)
+                        runCatching { requestList(channel) }
+                    }
+                }
+            try {
+                readLines(channel) { line -> handleLine(line) }
+            } finally {
+                pollJob.cancel()
+            }
+        }
+    }
+
+    private fun requestList(channel: SocketChannel) {
+        channel.write(
+            ByteBuffer.wrap("{\"cmd\":\"list-windows\"}\n".toByteArray(StandardCharsets.UTF_8)),
+        )
+    }
+
+    private fun handleLine(line: String) {
+        val root = runCatching { JsonParser.parseString(line).asJsonObject }.getOrNull() ?: return
+        when {
+            root.has("windows") -> applyList(root.getAsJsonArray("windows"))
+            root.get("event")?.asString == "window-opened" -> {
+                root.getAsJsonObject("window")?.let { obj ->
+                    parseWindow(obj)?.let { upsert(it) }
+                }
+            }
+            root.get("event")?.asString == "window-closed" -> {
+                val id = root.getAsJsonObject("window")?.get("id")?.asLong ?: return
+                appWindows.removeAll { it.id == id }
+            }
+        }
+    }
+
+    private fun applyList(array: JsonArray) {
+        val next = buildList {
+            for (i in 0 until array.size()) {
+                parseWindow(array[i].asJsonObject)?.let { add(it) }
+            }
+        }
+        appWindows.clear()
+        appWindows.addAll(next)
+    }
+
+    private fun upsert(window: CompositorAppWindow) {
+        val idx = appWindows.indexOfFirst { it.id == window.id }
+        if (idx >= 0) {
+            appWindows[idx] = window
+        } else {
+            appWindows.add(window)
+        }
+    }
+
+    private fun parseWindow(obj: JsonObject): CompositorAppWindow? {
+        val id = obj.get("id")?.asLong ?: return null
+        return CompositorAppWindow(
+            id = id,
+            x = obj.get("x")?.asInt ?: 0,
+            y = obj.get("y")?.asInt ?: 0,
+            width = obj.get("width")?.asInt ?: 0,
+            height = obj.get("height")?.asInt ?: 0,
+            minimized = obj.get("minimized")?.asBoolean == true,
+        )
+    }
+
+    private fun readLines(
+        channel: SocketChannel,
+        onLine: (String) -> Unit,
+    ) {
+        val buffer = ByteBuffer.allocate(8192)
+        val pending = StringBuilder()
+        while (true) {
+            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) {
+                onLine(pending.substring(0, nl))
+                pending.delete(0, nl + 1)
+                nl = pending.indexOf("\n")
+            }
+        }
+    }
+
+    companion object {
+        private const val POLL_MS = 400L
+
+        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
+        }
+    }
+}

+ 115 - 70
shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/theme/linux/ThemeManagerLinux.kt

@@ -9,6 +9,7 @@
 package org.mjdev.desktop.managers.theme.linux
 
 import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.lerp
 import okio.Path
 import okio.Path.Companion.toPath
 import org.mjdev.desktop.context.IDesktopContext
@@ -73,11 +74,57 @@ class ThemeManagerLinux(
             createCssFile(gtk4CssFile)
             createCssFile(systemGtk3CssFile)
             createCssFile(systemGtk4CssFile)
+            // applyLiveGtkTheme()
         }.onFailure { e ->
             Log.e(e)
         }
     }
 
+    private fun applyLiveGtkTheme() {
+        val scheme =
+            if (palette.backgroundColor.isLightColor) {
+                COLOR_SCHEME_PREFER_LIGHT
+            } else {
+                COLOR_SCHEME_PREFER_DARK
+            }
+        runCatching {
+            // GTK_THEME env var overrides gsettings and blocks live reload — theme name must
+            // come only from gsettings so open apps pick up rewritten gtk.css on notify.
+            Shell.executeAndRead(
+                "gsettings",
+                "set",
+                "org.gnome.desktop.interface",
+                "color-scheme",
+                scheme,
+            )
+            Shell.executeAndRead(
+                "gsettings",
+                "set",
+                "org.gnome.desktop.interface",
+                "gtk-theme",
+                THEME_MJDEV,
+            )
+            // Brief hop away and back forces GTK to re-read ~/.themes/Mjdev and gtk.css.
+            Shell.executeAndRead(
+                "gsettings",
+                "set",
+                "org.gnome.desktop.interface",
+                "gtk-theme",
+                THEME_ADWAITA,
+            )
+            Shell.executeAndRead(
+                "gsettings",
+                "set",
+                "org.gnome.desktop.interface",
+                "gtk-theme",
+                THEME_MJDEV,
+            )
+            Log.d("Live GTK theme applied: $THEME_MJDEV color-scheme=$scheme")
+        }.onFailure { e ->
+            Log.w("Live GTK theme apply failed (gsettings unavailable?): ${e.message}")
+        }
+    }
+
     private fun createDesktopFile() =
         runAsync {
             runCatching {
@@ -112,6 +159,7 @@ class ThemeManagerLinux(
                 fgColor = palette.textColor
                 baseColor = palette.baseColor
                 textColor = palette.textColor
+                borderColor = palette.borderColor
                 selectedBgColor = palette.selectedBgColor
                 selectedFgColor = palette.selectedFgColor
                 tooltipBgColor = palette.tooltipBgColor
@@ -174,8 +222,20 @@ class ThemeManagerLinux(
         var buttonBgColor: Color = Color.SuperDarkGray
         var buttonFgColor: Color = Color.White
 
-        fun asString() =
-            """
+        fun asString(): String {
+            val buttonInsens = lerp(buttonBgColor, bgColor, 0.6f)
+            val backdropFg = lerp(bgColor, fgColor, 0.8f)
+            val backdropText = lerp(baseColor, textColor, 0.8f)
+            val backdropDarkFg = lerp(bgColor, fgColor, 0.75f)
+            val backdropDarkBg = lerp(bgColor, fgColor, 0.75f)
+            val backdropSelBg = bgColor.darker(0.08f)
+            return """
+            @define-color accent_color ${selectedBgColor.hexRgb};
+            @define-color accent_bg_color ${selectedBgColor.hexRgb};
+            @define-color accent_fg_color ${selectedFgColor.hexRgb};
+            @define-color destructive_color ${errorBgColor.hexRgb};
+            @define-color destructive_bg_color ${errorBgColor.hexRgb};
+            @define-color destructive_fg_color ${errorFgColor.hexRgb};
             @define-color bg_color ${bgColor.hexRgb};
             @define-color fg_color ${fgColor.hexRgb};
             @define-color base_color ${baseColor.hexRgb};
@@ -216,18 +276,18 @@ class ThemeManagerLinux(
             @define-color popover_bg_color ${bgColor.hexRgb};
             @define-color popover_fg_color ${fgColor.hexRgb};
             @define-color link_color @selected_bg_color;
-            @define-color button_bg_color shade (@bg_color, 1.02);
-            @define-color notebook_button_bg_color shade (@bg_color, 1.02);
-            @define-color button_insensitive_bg_color mix (@button_bg_color, @bg_color, 0.6);
-            @define-color backdrop_fg_color mix (@bg_color, @fg_color, 0.8);
-            @define-color backdrop_text_color mix (@base_color, @text_color, 0.8);
-            @define-color backdrop_dark_fg_color mix (@dark_bg_color, @dark_fg_color, 0.75);
-            @define-color backdrop_dark_bg_color mix (@dark_bg_color, @dark_fg_color, 0.75);
-            @define-color backdrop_selected_bg_color shade (@bg_color, 0.92);
+            @define-color button_bg_color ${buttonBgColor.hexRgb};
+            @define-color notebook_button_bg_color ${buttonBgColor.hexRgb};
+            @define-color button_insensitive_bg_color ${buttonInsens.hexRgb};
+            @define-color backdrop_fg_color ${backdropFg.hexRgb};
+            @define-color backdrop_text_color ${backdropText.hexRgb};
+            @define-color backdrop_dark_fg_color ${backdropDarkFg.hexRgb};
+            @define-color backdrop_dark_bg_color ${backdropDarkBg.hexRgb};
+            @define-color backdrop_selected_bg_color ${backdropSelBg.hexRgb};
             @define-color backdrop_selected_fg_color @fg_color;
-            @define-color focus_color alpha (@selected_bg_color, 0.5);
-            @define-color focus_bg_color alpha (@selected_bg_color, 0.1);
-            @define-color shadow_color alpha(black, 0.5);
+            @define-color focus_color ${selectedBgColor.hexRgb};
+            @define-color focus_bg_color ${bgColor.hexRgb};
+            @define-color shadow_color rgba(0, 0, 0, 0.5);
             @define-color blue_1 #99c1f1;
             @define-color blue_2 #62a0ea;
             @define-color blue_3 #3584e4;
@@ -274,89 +334,73 @@ class ThemeManagerLinux(
             @define-color dark_4 #241f31;
             @define-color dark_5 #000000;
             
+            /* App content follows the wallpaper palette (compositor draws the outer frame). */
             window {
                 background-image: none;
-                background-color: ${bgColor.hexRgb};
-            	border-radius: 8px;
-            	border-bottom-left-radius: 8px;
-            	border-bottom-right-radius: 8px;
-                border-top-right-radius: 8px;
-                border-top-left-radius: 8px;
-                border: 2px solid ${bgColor.hexRgb};
-            	border-top: none;
-                /* position: relative; */
-                box-shadow: 1px 1px 4px rgba(0, 0, 0, 0.3), 0 0 40px rgba(0, 0, 0, 0.1) inset;
+                background-color: @window_bg_color;
+                border-radius: 0;
+                box-shadow: none;
+                border: none;
+                margin: 0;
+                padding: 0;
             }
-            
-            window:before, window:after {
-                /* position:absolute; */
-                z-index: -1;
-                box-shadow: 0 0 20px rgba(0, 0, 0, 0.8);
-                top: 0;
-                bottom: 0;
-                left: 10px;
-                right: 10px;
-                border-radius: 100px / 10px;
-            }
-            
-            window:after {
-                right: 10px;
-                left: auto;
-                transform: skew(8deg) rotate(3deg);
+
+            /* Compositor draws min/max/close — hide client-side titlebuttons only. */
+            headerbar button.titlebutton,
+            headerbar .titlebutton {
+                opacity: 0;
+                min-width: 0;
+                min-height: 0;
+                padding: 0;
+                margin: 0;
+                border: none;
             }
 
-            decoration {
+            window.ssd headerbar {
+                min-height: 0;
+                padding: 0;
+                margin: 0;
+                border: none;
+                box-shadow: none;
                 background-image: none;
-            	background-color: ${bgColor.hexRgb};
-            	border-radius: 8px;
-            	border-bottom-left-radius: 8px;
-            	border-bottom-right-radius: 8px;
-            	box-shadow: 0px 0px 0px 1px ${textColor.hexRgb};
-            	border-top: none;
+                background-color: @window_bg_color;
             }
-            
+
             .titlebar, headerbar {
                 padding-top: 2px;
                 padding-bottom: 2px;
                 background-image: none;
-                background-color: ${bgColor.hexRgb};
+                background-color: @headerbar_bg_color;
             }
 
-            .titlebar:backdrop, headerbar:backdrop  {
-                background: ${bgColor.hexRgb};
-                color: ${textColor.hexRgb};
+            toolbarview,
+            .toolbarview,
+            .background {
+                background-color: @window_bg_color;
+                color: @text_color;
             }
-            
-            /* all buttons */
+
+            entry, textview, label {
+                color: @text_color;
+            }
+
             button {
-                background: ${buttonBgColor.hexRgb};
-                opacity: 0.7;
+                background-color: @button_bg_color;
+                opacity: 0.85;
                 margin: 2px;
                 padding: 4px;
                 min-width: 24px;
                 min-height: 24px;
                 text-shadow: none;
-                color:  ${buttonFgColor.hexRgb};
+                color: ${buttonFgColor.hexRgb};
                 border-radius: 8px;
             }
-            
-            /* window buttons */
-            button.minimize,
-            button.maximize,
-            button.close,
-            button.maximize:hover,
-            button.minimize:hover,
-            button.close:hover {
-                opacity: 0.7;
-            }
 
-            button:hover,
-            button.maximize:hover,
-            button.minimize:hover,
-            button.close:hover {
+            button:hover {
                 opacity: 1;
             }
             """.trimIndent()
+        }
     }
 
     fun setColorScheme(schemeName: String) {
@@ -421,6 +465,7 @@ class ThemeManagerLinux(
 
     companion object {
         const val COLOR_SCHEME_PREFER_DARK = "prefer-dark"
+        const val COLOR_SCHEME_PREFER_LIGHT = "prefer-light"
         const val COLOR_SCHEME_MJDEV = "mjdev"
         const val THEME_YARU = "Yaru"
         const val THEME_ADWAITA = "Adwaita"