Browse Source

apply control center, plugins and glass blur

Milan Jurkulak 4 days ago
parent
commit
87aaa3926e
73 changed files with 2546 additions and 115 deletions
  1. 45 0
      PLUGINS.md
  2. 13 2
      compositor/compositor.gradle.kts
  3. 92 0
      compositor/native/shim.c
  4. 9 0
      compositor/native/shim.h
  5. 41 0
      compositor/src/linuxX64Main/kotlin/eu/mjdev/compositor/IpcServer.kt
  6. 24 0
      compositor/src/linuxX64Main/kotlin/eu/mjdev/compositor/Policy.kt
  7. 5 0
      compositor/src/linuxX64Main/kotlin/eu/mjdev/compositor/WindowModel.kt
  8. 5 0
      gradle/libs.versions.toml
  9. 3 0
      shared/src/androidMain/kotlin/org/mjdev/desktop/context/DesktopContext.kt
  10. 16 10
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/blur/BlurPanel.kt
  11. 97 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/blur/GlassBackdrop.kt
  12. 9 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/blur/LocalWindowBounds.kt
  13. 12 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/blur/WindowBounds.kt
  14. 47 6
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/about/AboutPage.kt
  15. 87 8
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/bluetooth/BluetoothSettingsPage.kt
  16. 19 8
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/devices/Devices.kt
  17. 26 6
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/display/DisplaySettingsPage.kt
  18. 26 8
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/ethernet/EthSettingsPage.kt
  19. 109 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/plugins/PluginRow.kt
  20. 43 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/plugins/PluginsPageDefaults.kt
  21. 55 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/plugins/PluginsSettingsPage.kt
  22. 34 39
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/sound/SoundSettingsPage.kt
  23. 15 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/sound/SoundSettingsPageDefaults.kt
  24. 31 7
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/theme/ThemeSettingsPage.kt
  25. 89 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/settings/SettingsColorRow.kt
  26. 31 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/settings/SettingsDefaults.kt
  27. 29 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/settings/SettingsDeviceSection.kt
  28. 54 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/settings/SettingsInfoRow.kt
  29. 66 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/settings/SettingsNetDeviceSection.kt
  30. 28 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/settings/SettingsPageColumn.kt
  31. 1 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/desktop/Desktop.kt
  32. 65 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/desktop/widgets/PluginWidget.kt
  33. 20 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/components/desktop/widgets/PluginWidgets.kt
  34. 10 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/context/DesktopContextScope.kt
  35. 14 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/context/IDesktopContext.kt
  36. 6 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/data/BthDevice.kt
  37. 26 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/data/NetDetail.kt
  38. 3 1
      shared/src/commonMain/kotlin/org/mjdev/desktop/data/ProviderConfig.kt
  39. 25 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/managers/connectivity/IConnectivityManager.kt
  40. 20 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/managers/os/IOSManager.kt
  41. 3 3
      shared/src/commonMain/kotlin/org/mjdev/desktop/managers/os/base/OSManagerStub.kt
  42. 48 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/managers/plugins/IPluginManager.kt
  43. 9 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/managers/volume/AudioOutput.kt
  44. 7 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/managers/volume/IVolumeManager.kt
  45. 31 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/IDesktopPlugin.kt
  46. 128 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/MemoryWidgetPlugin.kt
  47. 25 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/PluginDefaults.kt
  48. 23 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/PluginInfo.kt
  49. 20 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/PluginMetadata.kt
  50. 30 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/remote/RemoteAlign.kt
  51. 65 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/remote/RemoteColorToken.kt
  52. 41 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/remote/RemoteDocument.kt
  53. 25 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/remote/RemoteDocumentPlayer.kt
  54. 47 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/remote/RemoteOp.kt
  55. 116 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/remote/RemoteOpPainter.kt
  56. 19 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/remote/RemoteOpType.kt
  57. 9 0
      shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/remote/RemoteVariables.kt
  58. 1 0
      shared/src/desktopMain/kotlin/org/mjdev/desktop/components/appsmenu/AppsMenuWindow.kt
  59. 1 0
      shared/src/desktopMain/kotlin/org/mjdev/desktop/components/controlcenter/ControlCenterWindow.kt
  60. 1 0
      shared/src/desktopMain/kotlin/org/mjdev/desktop/components/desktoppanel/DesktopPanelWindow.kt
  61. 7 7
      shared/src/desktopMain/kotlin/org/mjdev/desktop/components/main/MainWindow.kt
  62. 23 5
      shared/src/desktopMain/kotlin/org/mjdev/desktop/context/DeskptopContext.kt
  63. 90 2
      shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/connectivity/ConnectivityManagerLinux.kt
  64. 25 0
      shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/os/linux/OSManagerLinux.kt
  65. 48 0
      shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/plugins/PluginJarReader.kt
  66. 179 0
      shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/plugins/PluginManager.kt
  67. 12 0
      shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/plugins/PluginState.kt
  68. 31 0
      shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/volume/VolumeManager.kt
  69. 8 2
      shared/src/desktopMain/kotlin/org/mjdev/desktop/windows/ChromeWindow.kt
  70. 54 0
      shared/src/desktopMain/kotlin/org/mjdev/desktop/windows/ProvideWindowBounds.kt
  71. 4 1
      shared/src/desktopMain/kotlin/org/mjdev/desktop/windows/Window.kt
  72. 22 0
      shared/src/desktopMain/kotlin/org/mjdev/desktop/windows/WindowTitle.kt
  73. 44 0
      shared/src/desktopMain/kotlin/org/mjdev/desktop/windows/WindowTransparencyDiagnostics.kt

+ 45 - 0
PLUGINS.md

@@ -0,0 +1,45 @@
+# Desktop widget plugins
+
+Widgets are plugins. The memory chart is the built-in one; external plugins are jar files placed in
+`~/.mjdev/plugins/`. The control center **Plugins** tab lists them (image, label, on/off switch,
+expandable details) and the enabled ones are drawn on the desktop.
+
+## Jar layout
+
+```
+my-plugin.jar
+├── plugin.json                                   metadata, read WITHOUT running plugin code
+├── icon.png                                      optional image (or name it in plugin.json "icon")
+├── META-INF/services/org.mjdev.desktop.plugins.IDesktopPlugin   -> one implementation class
+└── com/example/MyPlugin.class
+```
+
+`plugin.json`:
+
+```json
+{
+  "id": "com.example.clock",
+  "name": "Clock",
+  "description": "Shows the time.",
+  "version": "1.0",
+  "author": "Example",
+  "icon": "icon.png"
+}
+```
+
+## Code
+
+Implement `org.mjdev.desktop.plugins.IDesktopPlugin`: expose a `RemoteDocument` (the look, plain
+data: `RECT`, `ARC`, `TEXT` ops with geometry normalized to 0..1) and return live
+`RemoteVariables` from `variables()` (numbers drive `sweepVar` of arcs, texts replace `{name}`
+placeholders). Colors use palette tokens (`BACKGROUND`, `TEXT`, `ICONS`, `BORDER`, `SELECTED`) so a
+widget follows the wallpaper; `#RRGGBB` / `#AARRGGBB` are also accepted. See
+`MemoryWidgetPlugin` for a complete example. `RemoteDocument.encode()` / `decode()` give the JSON form.
+
+## Security
+
+A plugin jar runs with the same permissions as the desktop once it is enabled. Metadata and image
+are read without loading any class, and nothing is loaded until the user switches the plugin on.
+Only enable plugins you trust.
+
+State (which plugins are enabled) is stored in `~/.mjdev/desktop/plugins.json`.

+ 13 - 2
compositor/compositor.gradle.kts

@@ -44,7 +44,18 @@ fun pkgConfig(vararg args: String): List<String> = runCatching {
     }
 }.getOrElse { emptyList() }
 
-val wlrPackages = arrayOf("wlroots-0.18", "wayland-server", "xkbcommon", "pixman-1")
+// scenefx adds backdrop blur and is optional: the first installed candidate from the version
+// catalog is linked in and switches the shim to its renderer (-DMJC_WITH_SCENEFX); without it the
+// shim builds exactly as before and blur requests are ignored.
+val scenefxPackage: String? =
+    libs.versions.app.compositor.scenefx.packages
+        .get()
+        .split(" ")
+        .firstOrNull { candidate -> pkgConfig("--modversion", candidate).isNotEmpty() }
+val scenefxDefine: String = if (scenefxPackage != null) "-DMJC_WITH_SCENEFX" else ""
+
+val wlrPackages =
+    listOfNotNull("wlroots-0.18", "wayland-server", "xkbcommon", "pixman-1", scenefxPackage).toTypedArray()
 val wlrCflags: List<String>
     get() = pkgConfig("--cflags", *wlrPackages)
 val wlrLibs: List<String>
@@ -89,7 +100,7 @@ val compileShim =
         val outDir = shimDir.get().asFile
         val protoDir = protocolsDir.get().asFile
         val includeDir = nativeDir
-        val cflags = wlrCflags.joinToString(" ")
+        val cflags = (wlrCflags + scenefxDefine).filter { it.isNotBlank() }.joinToString(" ")
         inputs.files(shimC, shimH)
         outputs.file(outDir.resolve("libmjcshim.a"))
         doFirst {

+ 92 - 0
compositor/native/shim.c

@@ -41,7 +41,14 @@
 #include <wlr/types/wlr_output.h>
 #include <wlr/types/wlr_output_layout.h>
 #include <wlr/types/wlr_pointer.h>
+#ifdef MJC_WITH_SCENEFX
+/* scenefx is a drop-in superset of the wlroots scene graph that adds backdrop blur. It replaces
+ * (not complements) wlr/types/wlr_scene.h, the two headers must never be included together. */
+#include <scenefx/render/fx_renderer/fx_renderer.h>
+#include <scenefx/types/wlr_scene.h>
+#else
 #include <wlr/types/wlr_scene.h>
+#endif
 #include <wlr/types/wlr_seat.h>
 #include <wlr/types/wlr_xcursor_manager.h>
 #include <wlr/types/wlr_xdg_decoration_v1.h>
@@ -70,6 +77,10 @@ struct mjc_server {
     struct wlr_scene_output_layout *scene_layout;
     struct wlr_output_layout *output_layout;
     struct wlr_scene_tree *layers[5];
+#ifdef MJC_WITH_SCENEFX
+    /* result of the software-GL probe (the scenefx renderer cannot be probed itself) */
+    bool software_gl;
+#endif
 
     struct wlr_xdg_shell *xdg_shell;
     struct wlr_xdg_decoration_manager_v1 *xdg_decoration_mgr;
@@ -141,6 +152,8 @@ struct mjc_view {
     /* re-center once the client commits its post-unmaximize size */
     bool pending_center;
     bool focusable;
+    /* backdrop blur requested; applied to the scene buffers whenever they exist */
+    bool blur;
     mjc_layer layer;
     struct wlr_scene_tree *scene_tree;
     /* floating geometry remembered while maximized (x/y are scene coords) */
@@ -169,6 +182,28 @@ struct mjc_view {
     struct wl_listener set_geometry;
 };
 
+#ifdef MJC_WITH_SCENEFX
+static void view_blur_iterator(struct wlr_scene_buffer *buffer, int sx, int sy, void *data) {
+    (void) sx;
+    (void) sy;
+    wlr_scene_buffer_set_backdrop_blur(buffer, *(bool *) data);
+}
+
+/* push the requested blur state onto every scene buffer of the view (when its scene node
+ * does not exist yet - xwayland before map - the stored flag is applied at map time) */
+static void view_apply_blur(struct mjc_view *view) {
+    if (view->scene_tree == NULL) {
+        return;
+    }
+    bool enabled = view->blur;
+    wlr_scene_node_for_each_buffer(&view->scene_tree->node, view_blur_iterator, &enabled);
+}
+#else
+static void view_apply_blur(struct mjc_view *view) {
+    (void) view;
+}
+#endif
+
 struct mjc_popup {
     struct wlr_xdg_popup *xdg_popup;
     struct wl_listener commit;
@@ -944,6 +979,7 @@ static void server_new_xdg_toplevel(struct wl_listener *listener, void *data) {
         server->layers[MJC_LAYER_NORMAL], xdg_toplevel->base);
     view->scene_tree->node.data = view;
     xdg_toplevel->base->data = view->scene_tree;
+    view_apply_blur(view);
 
     view->map.notify = xdg_toplevel_map;
     wl_signal_add(&xdg_toplevel->base->surface->events.map, &view->map);
@@ -1059,6 +1095,7 @@ static void xwayland_surface_map(struct wl_listener *listener, void *data) {
     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);
+    view_apply_blur(view);
     wlr_scene_node_set_position(&view->scene_tree->node,
         xsurface->x, xsurface->y);
 
@@ -1334,7 +1371,20 @@ bool mjc_start(mjc_server *server, const mjc_callbacks *callbacks, void *userdat
         return false;
     }
 
+#ifdef MJC_WITH_SCENEFX
+    /* The scenefx renderer is not the wlroots gles2 renderer, so the software-GL probe cannot
+     * run on it. Probe with a short-lived plain renderer first, then create the blur renderer. */
+    {
+        struct wlr_renderer *probe = wlr_renderer_autocreate(server->backend);
+        server->software_gl = probe != NULL && mjc_renderer_is_software(probe);
+        if (probe != NULL) {
+            wlr_renderer_destroy(probe);
+        }
+    }
+    server->renderer = fx_renderer_create(server->backend);
+#else
     server->renderer = wlr_renderer_autocreate(server->backend);
+#endif
     if (server->renderer == NULL) {
         fprintf(stderr, "mjdevc: failed to create wlr_renderer\n");
         return false;
@@ -1436,7 +1486,11 @@ bool mjc_start(mjc_server *server, const mjc_callbacks *callbacks, void *userdat
     /* GPU vs software decision for the Compose shell (Skiko), exported into its environment
      * before it is spawned. Software GL (VM / no GPU) -> Skiko CPU raster (else it crashes with
      * "Cannot create Linux GL context"); real GPU -> leave unset so Skiko uses hardware GL. */
+#ifdef MJC_WITH_SCENEFX
+    if (server->software_gl) {
+#else
     if (mjc_renderer_is_software(server->renderer)) {
+#endif
         setenv("SKIKO_RENDER_API", "SOFTWARE", true);
         fprintf(stderr, "mjdevc: software GL renderer -> SKIKO_RENDER_API=SOFTWARE for the shell\n");
     } else {
@@ -1737,6 +1791,44 @@ void mjc_view_set_focusable(mjc_view *view, bool focusable) {
     }
 }
 
+/* ------------------------------------------------------------------ */
+/* backdrop blur (scenefx)                                             */
+/* ------------------------------------------------------------------ */
+
+bool mjc_blur_supported(void) {
+#ifdef MJC_WITH_SCENEFX
+    return true;
+#else
+    return false;
+#endif
+}
+
+void mjc_blur_configure(mjc_server *server, int passes, int radius, float noise,
+        float brightness, float contrast, float saturation) {
+#ifdef MJC_WITH_SCENEFX
+    struct blur_data data = blur_data_get_default();
+    data.num_passes = passes;
+    data.radius = radius;
+    data.noise = noise;
+    data.brightness = brightness;
+    data.contrast = contrast;
+    data.saturation = saturation;
+    wlr_scene_set_blur_data(server->scene, data);
+#else
+    (void) server; (void) passes; (void) radius; (void) noise;
+    (void) brightness; (void) contrast; (void) saturation;
+#endif
+}
+
+void mjc_view_set_blur(mjc_view *view, bool enabled) {
+    view->blur = enabled;
+    view_apply_blur(view);
+}
+
+bool mjc_view_is_blurred(mjc_view *view) {
+    return view->blur;
+}
+
 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);

+ 9 - 0
compositor/native/shim.h

@@ -113,6 +113,15 @@ 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);
 
+/* backdrop blur: everything rendered below the view is blurred. Needs the compositor to be
+ * built with scenefx; without it mjc_blur_supported() is false and the rest are no-ops. The
+ * view must have transparent pixels (an alpha window) for the blur to be visible. */
+bool mjc_blur_supported(void);
+void mjc_blur_configure(mjc_server *server, int passes, int radius, float noise,
+    float brightness, float contrast, float saturation);
+void mjc_view_set_blur(mjc_view *view, bool enabled);
+bool mjc_view_is_blurred(mjc_view *view);
+
 /* input injection (ipc-driven, for automated/headless testing) */
 void mjc_pointer_move(mjc_server *server, int x, int y);
 void mjc_pointer_button(mjc_server *server, uint32_t button, bool pressed);

+ 41 - 0
compositor/src/linuxX64Main/kotlin/eu/mjdev/compositor/IpcServer.kt

@@ -19,6 +19,7 @@ import kotlinx.serialization.json.JsonArray
 import kotlinx.serialization.json.JsonNull
 import kotlinx.serialization.json.JsonObject
 import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.float
 import kotlinx.serialization.json.int
 import kotlinx.serialization.json.jsonObject
 import kotlinx.serialization.json.jsonPrimitive
@@ -27,6 +28,8 @@ 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_blur_configure
+import mjdev.compositor.shim.mjc_blur_supported
 import mjdev.compositor.shim.mjc_key
 import mjdev.compositor.shim.mjc_loop_add_fd
 import mjdev.compositor.shim.mjc_loop_remove_fd
@@ -37,6 +40,7 @@ import mjdev.compositor.shim.mjc_unix_listen
 import mjdev.compositor.shim.mjc_view_close
 import mjdev.compositor.shim.mjc_view_focus
 import mjdev.compositor.shim.mjc_view_is_maximized
+import mjdev.compositor.shim.mjc_view_set_blur
 import mjdev.compositor.shim.mjc_view_set_maximized
 import mjdev.compositor.shim.mjc_view_set_minimized
 import mjdev.compositor.shim.mjc_view_set_position
@@ -51,6 +55,14 @@ import platform.posix.write
 /** evdev code for the left mouse button (linux input-event-codes.h BTN_LEFT) */
 private const val BTN_LEFT = 0x110
 
+/** default blur strength used by "blur-config" when a field is omitted */
+private const val BLUR_PASSES = 3
+private const val BLUR_RADIUS = 5
+private const val BLUR_NOISE = 0.02f
+
+/** brightness, contrast and saturation value that leaves the blurred image unchanged */
+private const val BLUR_NEUTRAL = 1.0f
+
 /**
  * Line based JSON api on a unix socket.
  *
@@ -67,6 +79,8 @@ private const val BTN_LEFT = 0x110
  *   {"cmd":"click","x":100,"y":200}                // optional x/y, then press+release
  *   {"cmd":"key","code":1,"pressed":true}          // evdev code; omit pressed to tap
  *   {"cmd":"move","id":1,"x":100,"y":100}          // move a window to absolute position
+ *   {"cmd":"blur","id":3,"enabled":true}           // blur whatever is below any window
+ *   {"cmd":"blur-config","passes":3,"radius":5}    // optional: noise, brightness, contrast, saturation
  *
  * Events (only for subscribed clients):
  *   {"event":"window-opened","window":{...}}
@@ -272,6 +286,33 @@ class IpcServer(
                 ok { }
             }
 
+            // backdrop blur needs a compositor built with scenefx
+            "blur" ->
+                if (!mjc_blur_supported()) {
+                    error("blur is not available: compositor built without scenefx")
+                } else {
+                    withWindow(request) { info ->
+                        val enabled = request["enabled"]?.jsonPrimitive?.content?.toBoolean() ?: true
+                        mjc_view_set_blur(info.ptr, enabled)
+                    }
+                }
+
+            "blur-config" ->
+                if (!mjc_blur_supported()) {
+                    error("blur is not available: compositor built without scenefx")
+                } else {
+                    mjc_blur_configure(
+                        c.server,
+                        request["passes"]?.jsonPrimitive?.int ?: BLUR_PASSES,
+                        request["radius"]?.jsonPrimitive?.int ?: BLUR_RADIUS,
+                        request["noise"]?.jsonPrimitive?.float ?: BLUR_NOISE,
+                        request["brightness"]?.jsonPrimitive?.float ?: BLUR_NEUTRAL,
+                        request["contrast"]?.jsonPrimitive?.float ?: BLUR_NEUTRAL,
+                        request["saturation"]?.jsonPrimitive?.float ?: BLUR_NEUTRAL,
+                    )
+                    ok { }
+                }
+
             // input injection for automated/headless testing (drives the seat directly)
             "pointer-move" -> {
                 val x = request["x"]?.jsonPrimitive?.int ?: return error("missing x")

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

@@ -14,8 +14,10 @@ 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_blur_supported
 import mjdev.compositor.shim.mjc_view_close
 import mjdev.compositor.shim.mjc_view_focus
+import mjdev.compositor.shim.mjc_view_set_blur
 import mjdev.compositor.shim.mjc_view_set_focusable
 import mjdev.compositor.shim.mjc_view_set_layer
 
@@ -51,6 +53,7 @@ class Policy(
             return
         }
         info.role = titleRole ?: "window"
+        applyBlurFlag(info)
         when (info.role) {
             // ChromeWindow names: DesktopWindow/FullScreenWindow carry the wallpaper
             "wallpaper", "background", "desktop",
@@ -74,6 +77,24 @@ class Policy(
         }
     }
 
+    /**
+     * The shell asks for a blurred backdrop by appending a flag to the window title, e.g.
+     * "mjdev::ControlCenter blur". The shell decides which windows are blurred, the compositor
+     * only renders it. A no-op when the compositor was built without scenefx.
+     */
+    private fun applyBlurFlag(info: WindowInfo) {
+        val wantsBlur =
+            info.title
+                ?.substringAfter(' ', "")
+                ?.split(' ')
+                ?.contains(FLAG_BLUR) == true
+        if (wantsBlur == info.blurFromTitle) {
+            return
+        }
+        info.blurFromTitle = wantsBlur
+        mjc_view_set_blur(info.ptr, wantsBlur && mjc_blur_supported())
+    }
+
     fun handleKey(
         keysym: UInt,
         modifiers: UInt,
@@ -115,6 +136,9 @@ class Policy(
 
     companion object {
         const val TITLE_PREFIX = "mjdev::"
+
+        /** title flag (after the role, separated by a space) that requests a blurred backdrop */
+        const val FLAG_BLUR = "blur"
         val XKB_KEY_TAB = 0xff09u
         val XKB_KEY_F4 = 0xffc1u
     }

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

@@ -25,6 +25,7 @@ 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_blurred
 import mjdev.compositor.shim.mjc_view_is_maximized
 import mjdev.compositor.shim.mjc_view_pid
 import mjdev.compositor.shim.mjc_view_title
@@ -43,6 +44,9 @@ class WindowInfo(
     var shell: Boolean = false
     var role: String? = null
 
+    // true while the window title carries the blur flag, see Policy.FLAG_BLUR
+    var blurFromTitle: Boolean = false
+
     // pulls current strings/pid from the native view
     fun refresh() {
         appId = mjc_view_app_id(ptr)?.toKString() ?: appId
@@ -86,6 +90,7 @@ class WindowInfo(
         }
         put("focused", focused)
         put("shell", shell)
+        put("blurred", mjc_view_is_blurred(ptr))
     }
 }
 

+ 5 - 0
gradle/libs.versions.toml

@@ -15,6 +15,11 @@ app-vendor = "mjdev"
 # deb packaging joins it with ", ". A clean/GNOME box lacks these (GNOME uses mutter, not wlroots),
 # so without them mjdevc fails to load (libwlroots-0.18.so missing) and the session black-screens.
 app-compositor-runtime-deps = "libwlroots-0.18 xwayland libegl1 libgles2 libgbm1 libinput10 libseat1 libxkbcommon0 libgl1-mesa-dri dbus dbus-user-session"
+# Optional backdrop blur for compositor windows (https://github.com/wlrfx/scenefx). pkg-config names
+# tried in order, the first installed one is used (scenefx 0.2 targets wlroots 0.18). When none is
+# installed mjdevc builds without blur exactly as before; the shell asks for blur per window via the
+# "blur" title flag and the compositor then logs/ignores it.
+app-compositor-scenefx-packages = "scenefx-0.2 scenefx-0.3 scenefx"
 # Seconds apt-get waits for the dpkg lock (/var/lib/dpkg/lock-frontend) instead of failing
 # immediately when another apt/dpkg process holds it (unattended-upgrades, a parallel build, …).
 # Consumed by installDesktop/installSession (compositor.gradle.kts) and make-iso.sh.

+ 3 - 0
shared/src/androidMain/kotlin/org/mjdev/desktop/context/DesktopContext.kt

@@ -39,6 +39,7 @@ import org.mjdev.desktop.managers.os.IOSManager
 import org.mjdev.desktop.managers.os.OsManager
 import org.mjdev.desktop.managers.palette.IPalette
 import org.mjdev.desktop.managers.palette.Palette
+import org.mjdev.desktop.managers.plugins.IPluginManager
 import org.mjdev.desktop.managers.process.IProcessManager
 import org.mjdev.desktop.managers.processes.ProcessManager
 import org.mjdev.desktop.managers.theme.IThemeManager
@@ -147,6 +148,8 @@ class DesktopContext(
         IProcessManager::class -> ProcessManager(this)
         IKeyManager::class -> KeysManager(this)
         IVolumeManager::class -> VolumeManager(this)
+        // Plugins are loaded from jars on the JVM desktop only.
+        IPluginManager::class -> IPluginManager.EMPTY
         else ->
             cls.companionObject
                 ?.members

+ 16 - 10
shared/src/commonMain/kotlin/org/mjdev/desktop/components/blur/BlurPanel.kt

@@ -58,16 +58,22 @@ fun BlurPanel(
         contentAlignment = contentAlignment,
         propagateMinConstraints = propagateMinConstraints,
     ) {
-        ImageAny(
-            modifier =
-                modifier
-                    .fillMaxSize()
-                    .alpha(alpha)
-                    .background(transparentGradientBrush)
-                    .blur(blurRadius, edgeTreatment = BlurredEdgeTreatment.Unbounded),
-            src = painterResource(resourceId),
-            contentScale = if (maxHeight > maxWidth) ContentScale.FillHeight else ContentScale.FillWidth,
-        )
+        // Real frosted glass (blurred wallpaper copy) when the desktop wallpaper is known; the
+        // previous static blurred picture stays as fallback for previews and platforms without it.
+        if (context.wallpaper.value != null) {
+            GlassBackdrop(modifier = Modifier.matchParentSize())
+        } else {
+            ImageAny(
+                modifier =
+                    modifier
+                        .fillMaxSize()
+                        .alpha(alpha)
+                        .background(transparentGradientBrush)
+                        .blur(blurRadius, edgeTreatment = BlurredEdgeTreatment.Unbounded),
+                src = painterResource(resourceId),
+                contentScale = if (maxHeight > maxWidth) ContentScale.FillHeight else ContentScale.FillWidth,
+            )
+        }
         Box(
             modifier =
                 Modifier

+ 97 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/blur/GlassBackdrop.kt

@@ -0,0 +1,97 @@
+package org.mjdev.desktop.components.blur
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.BlurredEdgeTreatment
+import androidx.compose.ui.draw.blur
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.layout
+import androidx.compose.ui.layout.onGloballyPositioned
+import androidx.compose.ui.layout.positionInWindow
+import androidx.compose.ui.unit.Constraints
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+import org.mjdev.desktop.components.image.ImageAny
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+import org.mjdev.desktop.extensions.Colors.ContrastColorFilter
+import kotlin.math.roundToInt
+
+/** Constants of [GlassBackdrop]. */
+private object GlassDefaults {
+    /** Blur radius of the frosted glass. */
+    val blurRadius: Dp = 28.dp
+
+    /** Same contrast the desktop applies to the wallpaper, so glass and wallpaper match. */
+    const val WALLPAPER_CONTRAST = 1.5f
+}
+
+/**
+ * Frosted glass that works on the JVM without any compositor support. The desktop window draws the
+ * wallpaper, so every other shell window paints its own copy of it, lines it up with where the
+ * window really sits on the screen (see [LocalWindowBounds]) and blurs it. Only the wallpaper can
+ * be blurred this way, never other applications' windows. Draws nothing until a wallpaper is known.
+ */
+@Suppress("FunctionName")
+@Composable
+fun GlassBackdrop(
+    modifier: Modifier = Modifier,
+    blurRadius: Dp = GlassDefaults.blurRadius,
+    shape: Shape = RectangleShape,
+) = withDesktopContext {
+    val wallpaper = context.wallpaper.value
+    val windowPosition = LocalWindowBounds.current.position
+    // top left corner of this node inside its window, in px
+    var origin by remember { mutableStateOf(Offset.Zero) }
+    Box(
+        modifier =
+            modifier
+                .clip(shape)
+                .onGloballyPositioned { origin = it.positionInWindow() }
+                .blur(blurRadius, BlurredEdgeTreatment.Rectangle),
+    ) {
+        when (wallpaper) {
+            null -> Unit
+            is Color -> Box(Modifier.fillMaxSize().background(wallpaper))
+            else ->
+                Box(
+                    modifier =
+                        Modifier.fillMaxSize().layout { measurable, constraints ->
+                            // the copy is as big as the screen and shifted so the pixels under this
+                            // node are exactly the wallpaper pixels that are on screen there
+                            val screen =
+                                Constraints.fixed(
+                                    containerSize.width.roundToPx(),
+                                    containerSize.height.roundToPx(),
+                                )
+                            val placeable = measurable.measure(screen)
+                            layout(constraints.maxWidth, constraints.maxHeight) {
+                                placeable.place(
+                                    -(windowPosition.x.roundToPx() + origin.x.roundToInt()),
+                                    -(windowPosition.y.roundToPx() + origin.y.roundToInt()),
+                                )
+                            }
+                        },
+                ) {
+                    ImageAny(
+                        modifier = Modifier.fillMaxSize(),
+                        contentScale = ContentScale.Crop,
+                        imageLoader = imageLoader,
+                        src = wallpaper,
+                        colorFilter = ContrastColorFilter(GlassDefaults.WALLPAPER_CONTRAST),
+                    )
+                }
+        }
+    }
+}

+ 9 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/blur/LocalWindowBounds.kt

@@ -0,0 +1,9 @@
+package org.mjdev.desktop.components.blur
+
+import androidx.compose.runtime.compositionLocalOf
+
+/**
+ * Screen bounds of the current window. The desktop window provider keeps it live while the window
+ * moves or resizes; platforms that cannot tell keep the zero default, which disables glass.
+ */
+val LocalWindowBounds = compositionLocalOf { WindowBounds() }

+ 12 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/blur/WindowBounds.kt

@@ -0,0 +1,12 @@
+package org.mjdev.desktop.components.blur
+
+import androidx.compose.ui.unit.DpOffset
+import androidx.compose.ui.unit.DpSize
+
+/** Where the window hosting a composable sits on the screen, in dp. */
+data class WindowBounds(
+    /** Top left corner of the window in screen coordinates. */
+    val position: DpOffset = DpOffset.Zero,
+    /** Size of the window. */
+    val size: DpSize = DpSize.Zero,
+)

+ 47 - 6
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/about/AboutPage.kt

@@ -1,15 +1,35 @@
 package org.mjdev.desktop.components.controlcenter.pages.about
 
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
+import androidx.compose.runtime.remember
 import androidx.compose.ui.tooling.preview.Preview
 import org.mjdev.desktop.components.controlcenter.base.ControlCenterPage
+import org.mjdev.desktop.components.controlcenter.settings.SettingsInfoRow
+import org.mjdev.desktop.components.controlcenter.settings.SettingsPageColumn
+import org.mjdev.desktop.components.controlcenter.settings.SettingsSection
 import org.mjdev.desktop.context.IDesktopContext
 import org.mjdev.desktop.extensions.Compose.preview
+import org.mjdev.desktop.extensions.DoubleExt.toMemorySizeReadable
+import org.mjdev.desktop.helpers.system.meminfo.MemInfo
 import org.mjdev.desktop.icons.custom.Mjdev
 
+/** Static labels used by [AboutPage]. */
+private object AboutLabels {
+    const val SYSTEM = "System"
+    const val MACHINE = "Machine"
+    const val USER = "User"
+    const val HOME = "Home"
+    const val SHELL = "Shell"
+    const val DISTRIBUTION = "Distribution"
+    const val KERNEL = "Kernel"
+    const val UPTIME = "Uptime"
+    const val MEMORY = "Memory"
+    const val SUPPORT = "Support"
+    const val HOME_PAGE = "Home page"
+    const val LOCALE = "Locale"
+}
+
+/** Control center page with basic information about this machine and the current user. */
 @Suppress("FunctionName")
 fun AboutPage(context: IDesktopContext) = ControlCenterPage(
     context = context,
@@ -17,9 +37,30 @@ fun AboutPage(context: IDesktopContext) = ControlCenterPage(
     name = "About",
     condition = { true },
 ) {
-    Box(
-        modifier = Modifier.fillMaxSize(),
-    )
+    SettingsPageColumn {
+        SettingsSection(title = AboutLabels.SYSTEM) {
+            // Read once per page open: these values come from files and shell commands.
+            val os = context.osManager
+            val machine = remember { context.machineName }
+            val distribution = remember { os.prettyName }
+            val kernel = remember { os.kernel }
+            val uptime = remember { os.uptime }
+            val memory = remember { MemInfo(context).total.toMemorySizeReadable() }
+            SettingsInfoRow(AboutLabels.MACHINE, machine)
+            SettingsInfoRow(AboutLabels.DISTRIBUTION, distribution)
+            SettingsInfoRow(AboutLabels.KERNEL, kernel)
+            SettingsInfoRow(AboutLabels.UPTIME, uptime)
+            SettingsInfoRow(AboutLabels.MEMORY, memory)
+            SettingsInfoRow(AboutLabels.HOME_PAGE, remember { os.homeUrl })
+            SettingsInfoRow(AboutLabels.SUPPORT, remember { os.supportUrl })
+            SettingsInfoRow(AboutLabels.LOCALE, context.currentLocale.toString())
+        }
+        SettingsSection(title = AboutLabels.USER) {
+            SettingsInfoRow(AboutLabels.USER, currentUser.userName)
+            SettingsInfoRow(AboutLabels.HOME, currentUser.home)
+            SettingsInfoRow(AboutLabels.SHELL, currentUser.shell)
+        }
+    }
 }
 
 @Preview

+ 87 - 8
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/bluetooth/BluetoothSettingsPage.kt

@@ -1,28 +1,107 @@
 package org.mjdev.desktop.components.controlcenter.pages.bluetooth
 
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
 import androidx.compose.ui.tooling.preview.Preview
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
 import org.mjdev.desktop.components.controlcenter.base.ControlCenterPage
+import org.mjdev.desktop.components.controlcenter.settings.SettingsInfoRow
+import org.mjdev.desktop.components.controlcenter.settings.SettingsPageColumn
+import org.mjdev.desktop.components.controlcenter.settings.SettingsSection
+import org.mjdev.desktop.components.controlcenter.settings.SettingsSwitchRow
 import org.mjdev.desktop.context.IDesktopContext
+import org.mjdev.desktop.data.BthDevice
 import org.mjdev.desktop.extensions.Compose.preview
 import org.mjdev.desktop.icons.network.Bluetooth
 
+/** Static labels used by [BluetoothSettingsPage]. */
+private object BluetoothLabels {
+    const val ADAPTER = "Adapter"
+    const val POWER = "Bluetooth on"
+    const val DEVICES = "Devices"
+    const val NONE = "No devices found. Pair a device with the system tools first."
+    const val PAIRED = " (paired)"
+}
+
+/** Period in milliseconds between two reads of the bluetooth state. */
+private const val REFRESH_MS = 5_000L
+
+/**
+ * Control center page for bluetooth: switch the adapter on or off and connect or disconnect the
+ * known devices. Hidden when the machine has no bluetooth adapter.
+ */
 @Suppress("FunctionName")
 fun BluetoothSettingsPage(context: IDesktopContext) = ControlCenterPage(
     context = context,
     icon = Bluetooth,
     name = "Bluetooth",
     condition = {
-        false
-//        connectionManager.isBthAdapterAvailable
+        connectionManager.isBthAdapterAvailable
     },
 ) {
-    Box(
-        modifier = Modifier.fillMaxSize(),
-    )
+    val manager = context.connectionManager
+    val scope = rememberCoroutineScope()
+    var powered by remember { mutableStateOf(false) }
+    var devices by remember { mutableStateOf<List<BthDevice>>(emptyList()) }
+    var busy by remember { mutableStateOf(false) }
+    suspend fun reload() = withContext(Dispatchers.Default) {
+        powered = manager.isBluetoothPowered
+        devices = manager.bthNetworks.values.sortedWith(compareBy({ !it.connected }, { it.name }))
+    }
+    LaunchedEffect(Unit) {
+        while (isActive) {
+            reload()
+            delay(REFRESH_MS)
+        }
+    }
+    SettingsPageColumn {
+        SettingsSection(title = BluetoothLabels.ADAPTER) {
+            SettingsSwitchRow(
+                label = BluetoothLabels.POWER,
+                checked = powered,
+                enabled = !busy,
+                onCheckedChange = { on ->
+                    scope.launch {
+                        busy = true
+                        withContext(Dispatchers.Default) { manager.setBluetoothPowered(on) }
+                        reload()
+                        busy = false
+                    }
+                },
+            )
+        }
+        SettingsSection(title = BluetoothLabels.DEVICES) {
+            if (devices.isEmpty()) {
+                SettingsInfoRow(label = BluetoothLabels.NONE, value = "")
+            }
+            devices.forEach { device ->
+                SettingsSwitchRow(
+                    label = device.name + if (device.paired) BluetoothLabels.PAIRED else "",
+                    checked = device.connected,
+                    enabled = powered && !busy,
+                    onCheckedChange = { connect ->
+                        scope.launch {
+                            busy = true
+                            withContext(Dispatchers.Default) {
+                                manager.setBluetoothDeviceConnected(device.address, connect)
+                            }
+                            reload()
+                            busy = false
+                        }
+                    },
+                )
+            }
+        }
+    }
 }
 
 @Preview

+ 19 - 8
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/devices/Devices.kt

@@ -1,28 +1,39 @@
 package org.mjdev.desktop.components.controlcenter.pages.devices
 
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
+import androidx.compose.runtime.getValue
 import androidx.compose.ui.tooling.preview.Preview
 import org.mjdev.desktop.components.controlcenter.base.ControlCenterPage
+import org.mjdev.desktop.components.controlcenter.settings.SettingsDeviceSection
+import org.mjdev.desktop.components.controlcenter.settings.SettingsPageColumn
 import org.mjdev.desktop.context.IDesktopContext
+import org.mjdev.desktop.data.NetDevice
 import org.mjdev.desktop.extensions.Compose.preview
+import org.mjdev.desktop.extensions.LaunchedEffect.flowBlock
 import org.mjdev.desktop.icons.mobile.MobileFriendly
 
+/** Title of the connected devices section. */
+private const val CONNECTED = "Connected devices"
+
+/** Refresh period of the device list in milliseconds. */
+private const val REFRESH_MS = 1000L
+
+/** Control center page listing currently connected devices; hidden when there are none. */
 @Suppress("FunctionName")
 fun DevicesPage(context: IDesktopContext) = ControlCenterPage(
     context = context,
     icon = MobileFriendly,
     name = "Connected devices",
     condition = {
-        false
-//        connectionManager.hasConnectedDevices
+        connectionManager.hasConnectedDevices
     },
 ) {
-    Box(
-        modifier = Modifier.fillMaxSize(),
-    ) {
+    val devices: Map<String, NetDevice> by flowBlock(
+        emptyMap(),
+        REFRESH_MS,
+    ) { context.connectionManager.connectedDevices.toMap() }
+    SettingsPageColumn {
+        SettingsDeviceSection(CONNECTED, devices.values)
     }
 }
 

+ 26 - 6
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/display/DisplaySettingsPage.kt

@@ -1,24 +1,44 @@
 package org.mjdev.desktop.components.controlcenter.pages.display
 
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
+import androidx.compose.runtime.getValue
 import androidx.compose.ui.tooling.preview.Preview
 import org.mjdev.desktop.components.controlcenter.base.ControlCenterPage
+import org.mjdev.desktop.components.controlcenter.settings.SettingsPageColumn
+import org.mjdev.desktop.components.controlcenter.settings.SettingsSection
 import org.mjdev.desktop.context.IDesktopContext
 import org.mjdev.desktop.extensions.Compose.preview
+import org.mjdev.desktop.components.controlcenter.settings.SettingsInfoRow
 import org.mjdev.desktop.icons.settings.SettingsMonitor
 
+/** Static labels used by [DisplaySettingsPage]. */
+private object DisplayLabels {
+    const val SCREEN = "Screen"
+    const val WIDTH = "Width"
+    const val HEIGHT = "Height"
+    const val LAYOUT = "Layout"
+    const val PANEL = "Panel location"
+    const val CONTROL_CENTER = "Control center"
+    const val UNIT = "dp"
+}
+
+/** Control center page describing the desktop surface size and where the panels are placed. */
 @Suppress("FunctionName")
 fun DisplaySettingsPage(context: IDesktopContext) = ControlCenterPage(
     context = context,
     icon = SettingsMonitor,
     name = "Display",
 ) {
-    Box(
-        modifier = Modifier.fillMaxSize(),
-    )
+    SettingsPageColumn {
+        SettingsSection(title = DisplayLabels.SCREEN) {
+            SettingsInfoRow(DisplayLabels.WIDTH, "${containerSize.width.value.toInt()} ${DisplayLabels.UNIT}")
+            SettingsInfoRow(DisplayLabels.HEIGHT, "${containerSize.height.value.toInt()} ${DisplayLabels.UNIT}")
+        }
+        SettingsSection(title = DisplayLabels.LAYOUT) {
+            SettingsInfoRow(DisplayLabels.PANEL, panelLocation.name)
+            SettingsInfoRow(DisplayLabels.CONTROL_CENTER, theme.controlCenterLocation.name)
+        }
+    }
 }
 
 @Preview

+ 26 - 8
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/ethernet/EthSettingsPage.kt

@@ -1,28 +1,46 @@
 package org.mjdev.desktop.components.controlcenter.pages.ethernet
 
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
+import androidx.compose.runtime.getValue
 import androidx.compose.ui.tooling.preview.Preview
 import org.mjdev.desktop.components.controlcenter.base.ControlCenterPage
+import org.mjdev.desktop.components.controlcenter.settings.SettingsDeviceSection
+import org.mjdev.desktop.components.controlcenter.settings.SettingsNetDeviceSection
+import org.mjdev.desktop.components.controlcenter.settings.SettingsPageColumn
 import org.mjdev.desktop.context.IDesktopContext
+import org.mjdev.desktop.data.NetDevice
 import org.mjdev.desktop.extensions.Compose.preview
+import org.mjdev.desktop.extensions.LaunchedEffect.flowBlock
 import org.mjdev.desktop.icons.settings.SettingsEthernet
 
+/** Title of the adapters section. */
+private const val ADAPTERS = "Ethernet adapters"
+
+/** Refresh period of the adapter list in milliseconds. */
+private const val REFRESH_MS = 1000L
+
+/** Control center page listing wired network adapters; hidden when none is present. */
 @Suppress("FunctionName")
 fun EthSettingsPage(context: IDesktopContext) = ControlCenterPage(
     context = context,
     icon = SettingsEthernet,
     name = "Ethernet",
     condition = {
-        false
-//        connectionManager.isEthAdapterAvailable
+        connectionManager.isEthAdapterAvailable
     },
 ) {
-    Box(
-        modifier = Modifier.fillMaxSize(),
-    )
+    val devices: Map<String, NetDevice> by flowBlock(
+        emptyMap(),
+        REFRESH_MS,
+    ) { context.connectionManager.ethDevices.toMap() }
+    SettingsPageColumn {
+        if (devices.isEmpty()) {
+            SettingsDeviceSection(ADAPTERS, devices.values)
+        }
+        devices.values.forEach { device ->
+            SettingsNetDeviceSection(device, context.connectionManager)
+        }
+    }
 }
 
 @Preview

+ 109 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/plugins/PluginRow.kt

@@ -0,0 +1,109 @@
+package org.mjdev.desktop.components.controlcenter.pages.plugins
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.Switch
+import androidx.compose.material3.SwitchDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.mjdev.desktop.components.controlcenter.settings.SettingsInfoRow
+import org.mjdev.desktop.components.image.ImageAny
+import org.mjdev.desktop.components.text.TextAny
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+import org.mjdev.desktop.extensions.Colors.alpha
+import org.mjdev.desktop.plugins.PluginInfo
+
+/**
+ * One plugin in the Plugins tab: image, label and an on/off switch. Clicking anywhere except the
+ * switch expands or collapses the details (description, version, author, id, source, last error).
+ */
+@Suppress("FunctionName")
+@Composable
+fun PluginRow(
+    info: PluginInfo,
+    onEnabledChange: (Boolean) -> Unit,
+    modifier: Modifier = Modifier,
+) = withDesktopContext {
+    var expanded by remember(info.id) { mutableStateOf(false) }
+    Column(modifier = modifier.fillMaxWidth()) {
+        Row(
+            modifier =
+                Modifier
+                    .fillMaxWidth()
+                    .clip(PluginsPageDefaults.rowShape)
+                    .clickable { expanded = !expanded }
+                    .padding(PluginsPageDefaults.rowPadding),
+            verticalAlignment = Alignment.CenterVertically,
+            horizontalArrangement = Arrangement.spacedBy(PluginsPageDefaults.rowSpacing),
+        ) {
+            ImageAny(
+                modifier = Modifier.size(PluginsPageDefaults.iconSize).clip(PluginsPageDefaults.rowShape),
+                src = info.icon ?: PluginsPageDefaults.fallbackIcon,
+                colorFilter = if (info.icon == null) ColorFilter.tint(textColor) else null,
+                contentDescription = "",
+            )
+            Column(modifier = Modifier.weight(1f)) {
+                TextAny(
+                    text = info.metadata.name.ifBlank { info.id },
+                    color = textColor,
+                    fontSize = 15.sp,
+                    fontWeight = FontWeight.Bold,
+                    singleLine = true,
+                    overflow = TextOverflow.Ellipsis,
+                )
+                if (!expanded && info.metadata.description.isNotBlank()) {
+                    TextAny(
+                        text = info.metadata.description,
+                        color = textColor.alpha(PluginsPageDefaults.SECONDARY_ALPHA),
+                        fontSize = 12.sp,
+                        singleLine = true,
+                        overflow = TextOverflow.Ellipsis,
+                    )
+                }
+            }
+            Switch(
+                checked = info.enabled,
+                onCheckedChange = onEnabledChange,
+                colors =
+                    SwitchDefaults.colors(
+                        checkedThumbColor = textColor,
+                        checkedTrackColor = textColor.alpha(PluginsPageDefaults.TRACK_ALPHA),
+                        uncheckedThumbColor = textColor.alpha(PluginsPageDefaults.SECONDARY_ALPHA),
+                        uncheckedTrackColor = backgroundColor.alpha(PluginsPageDefaults.SECONDARY_ALPHA),
+                    ),
+            )
+        }
+        AnimatedVisibility(visible = expanded) {
+            Column(modifier = Modifier.padding(horizontal = PluginsPageDefaults.rowPadding)) {
+                TextAny(
+                    modifier = Modifier.padding(bottom = PluginsPageDefaults.rowSpacing),
+                    text = info.metadata.description.ifBlank { PluginsPageDefaults.NO_DESCRIPTION },
+                    color = textColor.alpha(PluginsPageDefaults.SECONDARY_ALPHA),
+                    fontSize = 13.sp,
+                )
+                SettingsInfoRow(PluginsPageDefaults.LABEL_VERSION, info.metadata.version)
+                SettingsInfoRow(PluginsPageDefaults.LABEL_AUTHOR, info.metadata.author)
+                SettingsInfoRow(PluginsPageDefaults.LABEL_ID, info.id)
+                SettingsInfoRow(PluginsPageDefaults.LABEL_SOURCE, info.source)
+                info.error?.let { SettingsInfoRow(PluginsPageDefaults.LABEL_ERROR, it) }
+            }
+        }
+    }
+}

+ 43 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/plugins/PluginsPageDefaults.kt

@@ -0,0 +1,43 @@
+package org.mjdev.desktop.components.controlcenter.pages.plugins
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Build
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/** Constants used by [PluginsSettingsPage] and [PluginRow]. */
+object PluginsPageDefaults {
+    /** Size of the plugin image. */
+    val iconSize: Dp = 40.dp
+
+    /** Padding inside a plugin row. */
+    val rowPadding: Dp = 8.dp
+
+    /** Gap between the elements of a plugin row. */
+    val rowSpacing: Dp = 12.dp
+
+    /** Corner shape of a plugin row and its image. */
+    val rowShape = RoundedCornerShape(10.dp)
+
+    /** Image shown for plugins that ship none. */
+    val fallbackIcon: ImageVector = Icons.Filled.Build
+
+    /** Alpha of secondary text and the unchecked switch. */
+    const val SECONDARY_ALPHA: Float = 0.7f
+
+    /** Alpha of the checked switch track. */
+    const val TRACK_ALPHA: Float = 0.4f
+
+    const val SECTION_TITLE = "Desktop widgets"
+    const val FOLDER_TITLE = "Plugins folder"
+    const val EMPTY_HINT = "Drop plugin .jar files into the folder above, then reopen this tab."
+    const val NO_DESCRIPTION = "No description."
+    const val LABEL_VERSION = "Version"
+    const val LABEL_AUTHOR = "Author"
+    const val LABEL_ID = "Id"
+    const val LABEL_PATH = "Path"
+    const val LABEL_SOURCE = "Source"
+    const val LABEL_ERROR = "Error"
+}

+ 55 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/plugins/PluginsSettingsPage.kt

@@ -0,0 +1,55 @@
+package org.mjdev.desktop.components.controlcenter.pages.plugins
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.sp
+import org.mjdev.desktop.components.controlcenter.base.ControlCenterPage
+import org.mjdev.desktop.components.controlcenter.settings.SettingsInfoRow
+import org.mjdev.desktop.components.controlcenter.settings.SettingsPageColumn
+import org.mjdev.desktop.components.controlcenter.settings.SettingsSection
+import org.mjdev.desktop.components.text.TextAny
+import org.mjdev.desktop.context.IDesktopContext
+import org.mjdev.desktop.extensions.Colors.alpha
+import org.mjdev.desktop.extensions.Compose.preview
+
+/**
+ * Control center page listing every plugin found in `~/.mjdev/plugins/` (plus the built-in
+ * ones). Each row shows the plugin image and label with a switch that shows or hides its widget
+ * on the desktop; clicking the row expands its details.
+ */
+@Suppress("FunctionName")
+fun PluginsSettingsPage(context: IDesktopContext) = ControlCenterPage(
+    context = context,
+    icon = PluginsPageDefaults.fallbackIcon,
+    name = "Plugins",
+    condition = { true },
+) {
+    val manager = context.pluginManager
+    // Pick up jars that were dropped into the folder while the desktop was running.
+    LaunchedEffect(Unit) { manager.refresh() }
+    SettingsPageColumn {
+        SettingsSection(title = PluginsPageDefaults.SECTION_TITLE) {
+            manager.plugins.forEach { info ->
+                PluginRow(
+                    info = info,
+                    onEnabledChange = { enabled -> manager.setEnabled(info.id, enabled) },
+                )
+            }
+        }
+        SettingsSection(title = PluginsPageDefaults.FOLDER_TITLE) {
+            SettingsInfoRow(label = PluginsPageDefaults.LABEL_PATH, value = manager.pluginsDir)
+            TextAny(
+                text = PluginsPageDefaults.EMPTY_HINT,
+                color = textColor.alpha(PluginsPageDefaults.SECONDARY_ALPHA),
+                fontSize = 12.sp,
+            )
+        }
+    }
+}
+
+@Preview
+@Composable
+fun PluginsSettingsPagePreview() = preview {
+    PluginsSettingsPage(context).Render()
+}

+ 34 - 39
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/sound/SoundSettingsPage.kt

@@ -1,27 +1,17 @@
 package org.mjdev.desktop.components.controlcenter.pages.sound
 
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.automirrored.filled.VolumeOff
-import androidx.compose.material.icons.automirrored.filled.VolumeUp
-import androidx.compose.material3.Icon
-import androidx.compose.material3.IconButton
-import androidx.compose.material3.Slider
-import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
 import androidx.compose.ui.tooling.preview.Preview
 import org.mjdev.desktop.components.controlcenter.base.ControlCenterPage
+import org.mjdev.desktop.components.controlcenter.settings.SettingsPageColumn
+import org.mjdev.desktop.components.controlcenter.settings.SettingsSection
+import org.mjdev.desktop.components.controlcenter.settings.SettingsSelectRow
+import org.mjdev.desktop.components.controlcenter.settings.SettingsSliderRow
+import org.mjdev.desktop.components.controlcenter.settings.SettingsSwitchRow
 import org.mjdev.desktop.context.IDesktopContext
 import org.mjdev.desktop.extensions.Compose.preview
 import org.mjdev.desktop.icons.settings.SettingsSound
@@ -40,7 +30,7 @@ import org.mjdev.desktop.icons.settings.SettingsSound
 
 /**
  * Control center page allowing the user to control the system output volume
- * and toggle mute, backed by [IDesktopContext.volumeManager].
+ * and toggle mute, backed by [IDesktopContext.volumeManager]. Colored from the wallpaper palette.
  */
 @Suppress("FunctionName")
 fun SoundSettingsPage(context: IDesktopContext) = ControlCenterPage(
@@ -51,36 +41,41 @@ fun SoundSettingsPage(context: IDesktopContext) = ControlCenterPage(
 ) {
     var volume by remember { mutableStateOf(context.volumeManager.volume) }
     var muted by remember { mutableStateOf(context.volumeManager.isMuted) }
-    Column(
-        modifier =
-            Modifier
-                .fillMaxSize()
-                .padding(SoundSettingsPageDefaults.contentPadding),
-        verticalArrangement = Arrangement.spacedBy(SoundSettingsPageDefaults.itemSpacing),
-    ) {
-        Text(text = SoundSettingsPageDefaults.volumeLabel)
-        Row(
-            modifier = Modifier.fillMaxWidth(),
-            verticalAlignment = Alignment.CenterVertically,
-        ) {
-            IconButton(
-                onClick = {
-                    muted = context.volumeManager.toggleMute()
-                },
-            ) {
-                Icon(
-                    imageVector = if (muted) Icons.AutoMirrored.Filled.VolumeOff else Icons.AutoMirrored.Filled.VolumeUp,
-                    contentDescription = SoundSettingsPageDefaults.muteToggleDescription,
+    var outputs by remember { mutableStateOf(context.volumeManager.outputs) }
+    SettingsPageColumn {
+        if (outputs.isNotEmpty()) {
+            SettingsSection(title = SoundSettingsPageDefaults.deviceTitle) {
+                SettingsSelectRow(
+                    label = SoundSettingsPageDefaults.deviceLabel,
+                    selected = outputs.firstOrNull { it.isDefault }?.name.orEmpty(),
+                    options = outputs.map { it.name },
+                    onSelected = { name ->
+                        context.volumeManager.setDefaultOutput(name)
+                        // Re-read so the picker shows what the system really selected.
+                        outputs = context.volumeManager.outputs
+                        volume = context.volumeManager.volume
+                        muted = context.volumeManager.isMuted
+                    },
                 )
             }
-            Slider(
-                modifier = Modifier.fillMaxWidth(),
+        }
+        SettingsSection(title = SoundSettingsPageDefaults.outputTitle) {
+            SettingsSliderRow(
+                label = SoundSettingsPageDefaults.volumeLabel,
                 value = volume,
+                valueRange = SoundSettingsPageDefaults.volumeRange,
+                format = { value -> "${(value * SoundSettingsPageDefaults.PERCENT).toInt()}%" },
                 onValueChange = { newValue ->
                     volume = newValue
                     context.volumeManager.setVolume(newValue)
                 },
-                valueRange = SoundSettingsPageDefaults.volumeRange,
+            )
+            SettingsSwitchRow(
+                label = SoundSettingsPageDefaults.muteLabel,
+                checked = muted,
+                onCheckedChange = {
+                    muted = context.volumeManager.toggleMute()
+                },
             )
         }
     }

+ 15 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/sound/SoundSettingsPageDefaults.kt

@@ -19,6 +19,21 @@ object SoundSettingsPageDefaults {
     /** Label displayed above the volume slider. */
     const val volumeLabel: String = "Volume"
 
+    /** Title of the output settings section. */
+    const val outputTitle: String = "Output"
+
+    /** Title of the output device section. */
+    const val deviceTitle: String = "Output device"
+
+    /** Label of the output device picker. */
+    const val deviceLabel: String = "Play sound through"
+
+    /** Label of the mute switch. */
+    const val muteLabel: String = "Muted"
+
+    /** Multiplier converting the 0..1 volume into a percentage. */
+    const val PERCENT: Int = 100
+
     /** Accessibility description for the mute toggle button. */
     const val muteToggleDescription: String = "Toggle mute"
 }

+ 31 - 7
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/pages/theme/ThemeSettingsPage.kt

@@ -1,25 +1,49 @@
 package org.mjdev.desktop.components.controlcenter.pages.theme
 
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.runtime.Composable
-import androidx.compose.ui.Modifier
+import androidx.compose.runtime.getValue
 import androidx.compose.ui.tooling.preview.Preview
 import org.mjdev.desktop.components.controlcenter.base.ControlCenterPage
+import org.mjdev.desktop.components.controlcenter.settings.SettingsPageColumn
+import org.mjdev.desktop.components.controlcenter.settings.SettingsSection
 import org.mjdev.desktop.context.IDesktopContext
 import org.mjdev.desktop.extensions.Compose.preview
+import org.mjdev.desktop.components.controlcenter.settings.SettingsColorRow
 import org.mjdev.desktop.icons.custom.Mjdev
 
+/** Static labels used by [ThemeSettingsPage]. */
+private object ThemeLabels {
+    const val PALETTE = "Wallpaper palette"
+    const val BACKGROUND = "Background"
+    const val TEXT = "Text"
+    const val ICONS = "Icons"
+    const val BORDER = "Border"
+    const val SELECTED = "Selected"
+    const val SELECTED_TEXT = "Selected text"
+}
+
+/**
+ * Control center page showing the colors the whole desktop is painted with. They are not
+ * configured here - the [org.mjdev.desktop.managers.palette.IPalette] extracts them from the
+ * current wallpaper, so every control center page follows the background image.
+ */
 @Suppress("FunctionName")
 fun ThemeSettingsPage(context: IDesktopContext) = ControlCenterPage(
     context = context,
-    icon = Mjdev, // todo
+    icon = Mjdev, // todo dedicated theme icon
     name = "Theme",
     condition = { true },
 ) {
-    Box(
-        modifier = Modifier.fillMaxSize(),
-    )
+    SettingsPageColumn {
+        SettingsSection(title = ThemeLabels.PALETTE) {
+            SettingsColorRow(ThemeLabels.BACKGROUND, backgroundColor)
+            SettingsColorRow(ThemeLabels.TEXT, textColor)
+            SettingsColorRow(ThemeLabels.ICONS, iconsTintColor)
+            SettingsColorRow(ThemeLabels.BORDER, borderColor)
+            SettingsColorRow(ThemeLabels.SELECTED, selectedBgColor)
+            SettingsColorRow(ThemeLabels.SELECTED_TEXT, selectedFgColor)
+        }
+    }
 }
 
 @Preview

+ 89 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/settings/SettingsColorRow.kt

@@ -0,0 +1,89 @@
+package org.mjdev.desktop.components.controlcenter.settings
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.mjdev.desktop.components.text.TextAny
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+import org.mjdev.desktop.extensions.Colors.alpha
+import org.mjdev.desktop.extensions.Compose.preview
+
+/** A read-only row showing a palette color as a swatch next to its [label] and hex value. */
+@Suppress("FunctionName")
+@Composable
+fun SettingsColorRow(
+    label: String,
+    color: Color,
+    modifier: Modifier = Modifier,
+) = withDesktopContext {
+    val shape = RoundedCornerShape(SettingsDefaults.cornerRadius / 2)
+    Row(
+        modifier =
+            modifier
+                .fillMaxWidth()
+                .padding(vertical = 6.dp),
+        verticalAlignment = Alignment.CenterVertically,
+        horizontalArrangement = Arrangement.SpaceBetween,
+    ) {
+        TextAny(
+            text = label,
+            color = textColor,
+            fontSize = 14.sp,
+        )
+        Row(
+            verticalAlignment = Alignment.CenterVertically,
+            horizontalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            TextAny(
+                text = color.toHex(),
+                color = textColor.alpha(SettingsDefaults.SECONDARY_ALPHA),
+                fontSize = 12.sp,
+            )
+            Box(
+                modifier =
+                    Modifier
+                        .size(SettingsDefaults.swatchSize)
+                        .background(color, shape)
+                        .border(
+                            SettingsDefaults.swatchBorderWidth,
+                            textColor.alpha(SettingsDefaults.BORDER_ALPHA),
+                            shape,
+                        ),
+            )
+        }
+    }
+}
+
+/** Formats an opaque RGB hex string, e.g. `#1A2B3C`, ignoring alpha. */
+private fun Color.toHex(): String {
+    val rgb =
+        (red * COMPONENT_MAX).toInt().shl(RED_SHIFT) or
+            (green * COMPONENT_MAX).toInt().shl(GREEN_SHIFT) or
+            (blue * COMPONENT_MAX).toInt()
+    return "#" + rgb.toString(HEX_RADIX).padStart(HEX_DIGITS, '0').uppercase()
+}
+
+private const val COMPONENT_MAX = 255
+private const val RED_SHIFT = 16
+private const val GREEN_SHIFT = 8
+private const val HEX_RADIX = 16
+private const val HEX_DIGITS = 6
+
+@Preview
+@Composable
+fun PreviewSettingsColorRow() = preview {
+    SettingsColorRow(label = "Background", color = Color.DarkGray)
+}

+ 31 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/settings/SettingsDefaults.kt

@@ -0,0 +1,31 @@
+package org.mjdev.desktop.components.controlcenter.settings
+
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.dp
+
+/** Layout and alpha constants shared by all control center settings pages and rows. */
+object SettingsDefaults {
+    /** Padding around the scrollable content of a settings page. */
+    val pagePadding: Dp = 16.dp
+
+    /** Extra space kept free at the bottom of a scrollable page. */
+    val pageBottomPadding: Dp = 24.dp
+
+    /** Corner radius of cards, chips and swatches. */
+    val cornerRadius: Dp = 12.dp
+
+    /** Size of a color swatch in the theme page. */
+    val swatchSize: Dp = 32.dp
+
+    /** Border width of a color swatch. */
+    val swatchBorderWidth: Dp = 1.dp
+
+    /** Alpha applied to secondary (value) text. */
+    const val SECONDARY_ALPHA: Float = 0.7f
+
+    /** Alpha applied to the border of swatches and chips. */
+    const val BORDER_ALPHA: Float = 0.5f
+
+    /** Placeholder shown when a value is not available. */
+    const val EMPTY_VALUE: String = "-"
+}

+ 29 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/settings/SettingsDeviceSection.kt

@@ -0,0 +1,29 @@
+package org.mjdev.desktop.components.controlcenter.settings
+
+import androidx.compose.runtime.Composable
+import org.mjdev.desktop.data.NetDevice
+
+/** Shared status labels for network device sections. */
+object NetDeviceLabels {
+    const val UP = "Up"
+    const val DOWN = "Down"
+    const val NONE = "No devices found"
+}
+
+/** Lists [devices] as rows of "name : state (mac)" inside a titled section. */
+@Suppress("FunctionName")
+@Composable
+fun SettingsDeviceSection(
+    title: String,
+    devices: Collection<NetDevice>,
+) {
+    SettingsSection(title = title) {
+        if (devices.isEmpty()) {
+            SettingsInfoRow(label = NetDeviceLabels.NONE, value = "")
+        }
+        devices.forEach { device ->
+            val state = if (device.isUp) NetDeviceLabels.UP else NetDeviceLabels.DOWN
+            SettingsInfoRow(label = device.name, value = "$state  ${device.macAddress}".trim())
+        }
+    }
+}

+ 54 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/settings/SettingsInfoRow.kt

@@ -0,0 +1,54 @@
+package org.mjdev.desktop.components.controlcenter.settings
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import org.mjdev.desktop.components.text.TextAny
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+import org.mjdev.desktop.extensions.Colors.alpha
+import org.mjdev.desktop.extensions.Compose.preview
+
+/** A read-only "label : value" row colored from the wallpaper palette. */
+@Suppress("FunctionName")
+@Composable
+fun SettingsInfoRow(
+    label: String,
+    value: String,
+    modifier: Modifier = Modifier,
+) = withDesktopContext {
+    Row(
+        modifier =
+            modifier
+                .fillMaxWidth()
+                .padding(vertical = 6.dp),
+        verticalAlignment = Alignment.CenterVertically,
+        horizontalArrangement = Arrangement.SpaceBetween,
+    ) {
+        TextAny(
+            text = label,
+            color = textColor,
+            fontSize = 14.sp,
+        )
+        TextAny(
+            modifier = Modifier.padding(start = 16.dp),
+            text = value.ifBlank { SettingsDefaults.EMPTY_VALUE },
+            color = textColor.alpha(SettingsDefaults.SECONDARY_ALPHA),
+            fontSize = 14.sp,
+            textAlign = TextAlign.End,
+        )
+    }
+}
+
+@Preview
+@Composable
+fun PreviewSettingsInfoRow() = preview {
+    SettingsInfoRow(label = "Host", value = "mjdev-laptop")
+}

+ 66 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/settings/SettingsNetDeviceSection.kt

@@ -0,0 +1,66 @@
+package org.mjdev.desktop.components.controlcenter.settings
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.mjdev.desktop.data.NetDetail
+import org.mjdev.desktop.data.NetDevice
+import org.mjdev.desktop.managers.connectivity.IConnectivityManager
+
+/** Constants of [SettingsNetDeviceSection]. */
+private object NetDeviceSectionDefaults {
+    const val CONNECTED_LABEL = "Connected"
+
+    /** Period in milliseconds between two reads of the device details. */
+    const val REFRESH_MS = 5_000L
+}
+
+/**
+ * Settings for one network [device]: a switch that connects or disconnects it, followed by its
+ * current details (connection, IP address, gateway, DNS, MAC). Details refresh in the background.
+ */
+@Suppress("FunctionName")
+@Composable
+fun SettingsNetDeviceSection(
+    device: NetDevice,
+    manager: IConnectivityManager,
+) {
+    val scope = rememberCoroutineScope()
+    var details by remember(device.name) { mutableStateOf<Map<String, String>>(emptyMap()) }
+    var busy by remember(device.name) { mutableStateOf(false) }
+    LaunchedEffect(device.name) {
+        while (isActive) {
+            details = withContext(Dispatchers.Default) { manager.deviceDetails(device.name) }
+            delay(NetDeviceSectionDefaults.REFRESH_MS)
+        }
+    }
+    SettingsSection(title = device.name) {
+        SettingsSwitchRow(
+            label = NetDeviceSectionDefaults.CONNECTED_LABEL,
+            checked = NetDetail.isConnected(details),
+            enabled = !busy,
+            onCheckedChange = { connect ->
+                scope.launch {
+                    busy = true
+                    withContext(Dispatchers.Default) { manager.setDeviceConnected(device.name, connect) }
+                    details = withContext(Dispatchers.Default) { manager.deviceDetails(device.name) }
+                    busy = false
+                }
+            },
+        )
+        NetDetail.entries
+            .filter { it != NetDetail.State }
+            .forEach { detail ->
+                details[detail.key]?.let { value -> SettingsInfoRow(detail.label, value) }
+            }
+    }
+}

+ 28 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/controlcenter/settings/SettingsPageColumn.kt

@@ -0,0 +1,28 @@
+package org.mjdev.desktop.components.controlcenter.settings
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+
+/** Scrollable, padded root container used by every control center settings page. */
+@Suppress("FunctionName")
+@Composable
+fun SettingsPageColumn(
+    modifier: Modifier = Modifier,
+    content: @Composable () -> Unit,
+) {
+    Column(
+        modifier =
+            modifier
+                .fillMaxSize()
+                .verticalScroll(rememberScrollState())
+                .padding(SettingsDefaults.pagePadding)
+                .padding(bottom = SettingsDefaults.pageBottomPadding),
+    ) {
+        content()
+    }
+}

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

@@ -55,6 +55,7 @@ fun Desktop(
             switchDelay = theme.backgroundRotationDelay,
             images = backgrounds,
             onChange = { src ->
+                context.wallpaper.value = src
                 context.palette
                     .apply {
                         update(src)

+ 65 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/desktop/widgets/PluginWidget.kt

@@ -0,0 +1,65 @@
+package org.mjdev.desktop.components.desktop.widgets
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+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.Modifier
+import androidx.compose.ui.unit.dp
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.withContext
+import org.mjdev.desktop.components.draggable.DraggableView
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+import org.mjdev.desktop.log.Log
+import org.mjdev.desktop.plugins.IDesktopPlugin
+import org.mjdev.desktop.plugins.remote.RemoteAlign
+import org.mjdev.desktop.plugins.remote.RemoteDocumentPlayer
+import org.mjdev.desktop.plugins.remote.RemoteVariables
+
+/**
+ * Draws one enabled [plugin] on the desktop: its document is rendered by [RemoteDocumentPlayer]
+ * and its variables are refreshed off the UI thread every [IDesktopPlugin.refreshMs]. The widget
+ * can be dragged like the other desktop widgets.
+ */
+@Suppress("FunctionName")
+@Composable
+fun PluginWidget(
+    plugin: IDesktopPlugin,
+    modifier: Modifier = Modifier,
+) = withDesktopContext {
+    val document = remember(plugin) { plugin.document }
+    var variables by remember(plugin) { mutableStateOf(RemoteVariables()) }
+    LaunchedEffect(plugin) {
+        while (isActive) {
+            // A failing plugin keeps its last values instead of crashing the desktop.
+            variables =
+                withContext(Dispatchers.Default) {
+                    runCatching { plugin.variables(context) }
+                        .onFailure { e -> Log.e(e) }
+                        .getOrDefault(variables)
+                }
+            delay(plugin.refreshMs)
+        }
+    }
+    Box(modifier = modifier.fillMaxSize()) {
+        DraggableView(
+            modifier =
+                Modifier
+                    .size(document.width.dp, document.height.dp)
+                    .align(RemoteAlign.parse(document.anchor, RemoteAlign.BOTTOM_END).alignment),
+        ) {
+            RemoteDocumentPlayer(
+                document = document,
+                variables = variables,
+                modifier = Modifier.fillMaxSize(),
+            )
+        }
+    }
+}

+ 20 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/components/desktop/widgets/PluginWidgets.kt

@@ -0,0 +1,20 @@
+package org.mjdev.desktop.components.desktop.widgets
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.key
+import androidx.compose.runtime.remember
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+
+/** Desktop widget layer: shows every plugin the user enabled in the control center. */
+@Suppress("FunctionName")
+@Composable
+fun PluginWidgets() = withDesktopContext {
+    pluginManager.plugins
+        .filter { info -> info.enabled }
+        .forEach { info ->
+            key(info.id) {
+                val plugin = remember(info.id) { pluginManager.instance(info.id) }
+                if (plugin != null) PluginWidget(plugin)
+            }
+        }
+}

+ 10 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/context/DesktopContextScope.kt

@@ -16,6 +16,7 @@ import org.mjdev.desktop.interfaces.ITheme
 import org.mjdev.desktop.interfaces.IUser
 import org.mjdev.desktop.managers.apps.IAppsManager
 import org.mjdev.desktop.managers.palette.IPalette
+import org.mjdev.desktop.managers.plugins.IPluginManager
 import org.mjdev.desktop.managers.process.IProcessManager
 import kotlin.coroutines.CoroutineContext
 import kotlin.coroutines.EmptyCoroutineContext
@@ -106,6 +107,9 @@ open class DesktopContextScope(
 //    val desktopUtils
 //        get() = context.desktopUtils
 
+    val pluginManager: IPluginManager
+        get() = context.pluginManager
+
     val controlCenterPages
         get() = context.controlCenterPages
 
@@ -124,6 +128,12 @@ open class DesktopContextScope(
     val focusBorderColor: Color
         get() = context.palette.textColor
 
+    // Accent colors derived from the wallpaper palette, used by control center pages.
+    val selectedBgColor: Color
+        get() = context.palette.selectedBgColor
+    val selectedFgColor: Color
+        get() = context.palette.selectedFgColor
+
     val menuPadding: Dp = context.theme.appMenuOuterPadding
 
     val panelHideDelay: Long

+ 14 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/context/IDesktopContext.kt

@@ -1,5 +1,7 @@
 package org.mjdev.desktop.context
 
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.mutableStateOf
 import androidx.compose.ui.unit.DpSize
 import coil3.ImageLoader
 import coil3.PlatformContext
@@ -15,6 +17,7 @@ import org.mjdev.desktop.components.controlcenter.pages.devices.DevicesPage
 import org.mjdev.desktop.components.controlcenter.pages.display.DisplaySettingsPage
 import org.mjdev.desktop.components.controlcenter.pages.ethernet.EthSettingsPage
 import org.mjdev.desktop.components.controlcenter.pages.main.MainSettingsPage
+import org.mjdev.desktop.components.controlcenter.pages.plugins.PluginsSettingsPage
 import org.mjdev.desktop.components.controlcenter.pages.remotes.RemotesSettingsPage
 import org.mjdev.desktop.components.controlcenter.pages.sound.SoundSettingsPage
 import org.mjdev.desktop.components.controlcenter.pages.theme.ThemeSettingsPage
@@ -35,6 +38,7 @@ import org.mjdev.desktop.managers.connectivity.IConnectivityManager
 import org.mjdev.desktop.managers.keys.IKeyManager
 import org.mjdev.desktop.managers.os.IOSManager
 import org.mjdev.desktop.managers.palette.IPalette
+import org.mjdev.desktop.managers.plugins.IPluginManager
 import org.mjdev.desktop.managers.process.IProcessManager
 import org.mjdev.desktop.managers.remote.IRemoteDesktopManager
 import org.mjdev.desktop.managers.theme.IThemeManager
@@ -61,12 +65,19 @@ abstract class IDesktopContext : IDisposable {
             DevicesPage(this),
             RemotesSettingsPage(this),
             ThemeSettingsPage(this),
+            PluginsSettingsPage(this),
             AIPage(this),
             AboutPage(this),
         )
 
     val storageProvider = StorageProvider(this)
 
+    /**
+     * Source of the wallpaper the desktop currently shows (path, url, bitmap or a plain color), or
+     * null before the first one is chosen. Other shell windows read it to draw frosted glass.
+     */
+    val wallpaper: MutableState<Any?> = mutableStateOf(null)
+
     open val osManager: IOSManager by this
     open val connectionManager: IConnectivityManager by this
     open val ai: IAiManager by this
@@ -81,6 +92,9 @@ abstract class IDesktopContext : IDisposable {
     // System output volume / mute control - see IVolumeManager for platform implementations.
     open val volumeManager: IVolumeManager by this
 
+    // Discovers and toggles desktop widget plugins from ~/.mjdev/plugins - see IPluginManager.
+    open val pluginManager: IPluginManager by this
+
     abstract var isFirstStart: Boolean
     abstract var isInstalled: Boolean
 

+ 6 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/data/BthDevice.kt

@@ -10,4 +10,10 @@ package org.mjdev.desktop.data
 
 data class BthDevice(
     val name: String = "",
+    /** Bluetooth hardware address, e.g. `AA:BB:CC:DD:EE:FF`; empty when unknown. */
+    val address: String = "",
+    /** True while the device is connected. */
+    val connected: Boolean = false,
+    /** True when the device has been paired before. */
+    val paired: Boolean = false,
 )

+ 26 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/data/NetDetail.kt

@@ -0,0 +1,26 @@
+package org.mjdev.desktop.data
+
+/** Network device details the control center shows, with the system field they come from. */
+enum class NetDetail(
+    /** Field name as reported by the connectivity manager. */
+    val key: String,
+    /** Label shown to the user. */
+    val label: String,
+) {
+    State("GENERAL.STATE", "State"),
+    Connection("GENERAL.CONNECTION", "Connection"),
+    Address("IP4.ADDRESS", "IP address"),
+    Gateway("IP4.GATEWAY", "Gateway"),
+    Dns("IP4.DNS", "DNS"),
+    Mac("GENERAL.HWADDR", "MAC address"),
+    ;
+
+    companion object {
+        /** Marker in the state text of a device that has an active connection. */
+        private const val CONNECTED_MARK = "(connected)"
+
+        /** True when [details] describe a device with an active connection. */
+        fun isConnected(details: Map<String, String>): Boolean =
+            details[State.key].orEmpty().contains(CONNECTED_MARK)
+    }
+}

+ 3 - 1
shared/src/commonMain/kotlin/org/mjdev/desktop/data/ProviderConfig.kt

@@ -12,7 +12,9 @@ import org.mjdev.desktop.providers.background.ProviderSmug
  */
 @Suppress("MemberVisibilityCanBePrivate", "unused")
 data class ProviderConfig(
-    val id: String,
+    // Default value is required: it gives Gson a no-args constructor, otherwise it falls back to
+    // sun.misc.Unsafe which is unavailable on some JVMs and crashed config loading.
+    val id: String = "",
     var enabled: Boolean = false,
     var loadCount: Int = DEFAULT_LOAD_COUNT,
 ) {

+ 25 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/managers/connectivity/IConnectivityManager.kt

@@ -31,6 +31,31 @@ interface IConnectivityManager : IDelegate {
 
     val hasConnectedDevices: Boolean
 
+    /** True while the bluetooth adapter is switched on. */
+    val isBluetoothPowered: Boolean
+        get() = false
+
+    /** Connects or disconnects the network device named [device]; no-op where unsupported. */
+    fun setDeviceConnected(
+        device: String,
+        connected: Boolean,
+    ): Result<Boolean> = Result.success(false)
+
+    /**
+     * Details of the network device [device] (connection, addresses, DNS, ...) keyed by their
+     * system field name, see [org.mjdev.desktop.data.NetDetail]; empty when unsupported.
+     */
+    fun deviceDetails(device: String): Map<String, String> = emptyMap()
+
+    /** Switches the bluetooth adapter on or off; no-op where unsupported. */
+    fun setBluetoothPowered(powered: Boolean): Result<Boolean> = Result.success(false)
+
+    /** Connects or disconnects the bluetooth device with [address]; no-op where unsupported. */
+    fun setBluetoothDeviceConnected(
+        address: String,
+        connected: Boolean,
+    ): Result<Boolean> = Result.success(false)
+
     fun connectWifi(ssid: String): Result<Boolean> = Result.success(false)
 
     fun connectWifi(

+ 20 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/managers/os/IOSManager.kt

@@ -5,6 +5,26 @@ import org.mjdev.desktop.managers.base.IDelegate
 interface IOSManager : IDelegate {
     val machineName: String
 
+    /** Human readable distribution name, e.g. `Ubuntu 24.04 LTS`; empty when unknown. */
+    val prettyName: String
+        get() = ""
+
+    /** Distribution home page; empty when unknown. */
+    val homeUrl: String
+        get() = ""
+
+    /** Where to get support for the distribution; empty when unknown. */
+    val supportUrl: String
+        get() = ""
+
+    /** Kernel release, e.g. `6.8.0-45-generic`; empty when unknown. */
+    val kernel: String
+        get() = ""
+
+    /** How long the machine has been running, human readable; empty when unknown. */
+    val uptime: String
+        get() = ""
+
     companion object {
         val EMPTY =
             object : IOSManager {

+ 3 - 3
shared/src/commonMain/kotlin/org/mjdev/desktop/managers/os/base/OSManagerStub.kt

@@ -14,15 +14,15 @@ import org.mjdev.desktop.managers.os.IOSManager
 open class OSManagerStub(
     val context: IDesktopContext,
 ) : IOSManager {
-    open val prettyName: String = ""
+    override val prettyName: String = ""
     open val name: String = ""
     open val versionId: String = ""
     open val version: String = ""
     open val versionCodeName: String = ""
     open val id: String = ""
     open val idLike: String = ""
-    open val homeUrl: String = ""
-    open val supportUrl: String = ""
+    override val homeUrl: String = ""
+    override val supportUrl: String = ""
     open val bugReportUrl: String = ""
     open val privacyPolicyUrl: String = ""
     open val codename: String = ""

+ 48 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/managers/plugins/IPluginManager.kt

@@ -0,0 +1,48 @@
+package org.mjdev.desktop.managers.plugins
+
+import org.mjdev.desktop.managers.base.IDelegate
+import org.mjdev.desktop.plugins.IDesktopPlugin
+import org.mjdev.desktop.plugins.PluginInfo
+
+/**
+ * Discovers desktop widget plugins in `~/.mjdev/plugins/`, remembers which ones the user enabled
+ * and hands out live plugin instances. The [plugins] list is observable, so the control center
+ * and the desktop update as soon as it changes.
+ */
+interface IPluginManager : IDelegate {
+    /** Absolute path of the folder external plugin jars are read from. */
+    val pluginsDir: String
+
+    /** All known plugins (built-in and external) with their current enabled state. */
+    val plugins: List<PluginInfo>
+
+    /** Re-scans the plugins folder; keeps the enabled state of plugins that are still present. */
+    fun refresh()
+
+    /** Enables or disables the plugin [id] and persists the choice. */
+    fun setEnabled(
+        id: String,
+        enabled: Boolean,
+    )
+
+    /** Returns the loaded instance of an enabled plugin, loading it on first use, or null. */
+    fun instance(id: String): IDesktopPlugin?
+
+    companion object {
+        /** No-op fallback used when no platform manager is available. */
+        val EMPTY =
+            object : IPluginManager {
+                override val pluginsDir: String = ""
+                override val plugins: List<PluginInfo> = emptyList()
+
+                override fun refresh() {}
+
+                override fun setEnabled(
+                    id: String,
+                    enabled: Boolean,
+                ) {}
+
+                override fun instance(id: String): IDesktopPlugin? = null
+            }
+    }
+}

+ 9 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/managers/volume/AudioOutput.kt

@@ -0,0 +1,9 @@
+package org.mjdev.desktop.managers.volume
+
+/** One audio output device (sink) the system can play sound through. */
+data class AudioOutput(
+    /** System name of the sink, used to select it. */
+    val name: String = "",
+    /** True when this sink is the current default output. */
+    val isDefault: Boolean = false,
+)

+ 7 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/managers/volume/IVolumeManager.kt

@@ -20,6 +20,13 @@ interface IVolumeManager : IDelegate {
     /** Toggles mute state and returns the new state. */
     fun toggleMute(): Boolean
 
+    /** Audio output devices known to the system; empty when the platform cannot list them. */
+    val outputs: List<AudioOutput>
+        get() = emptyList()
+
+    /** Makes the output named [name] the default one. No-op where unsupported. */
+    fun setDefaultOutput(name: String) {}
+
     companion object {
         /** No-op fallback used when no platform manager is available. */
         val EMPTY =

+ 31 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/IDesktopPlugin.kt

@@ -0,0 +1,31 @@
+package org.mjdev.desktop.plugins
+
+import org.mjdev.desktop.context.IDesktopContext
+import org.mjdev.desktop.plugins.remote.RemoteDocument
+import org.mjdev.desktop.plugins.remote.RemoteVariables
+
+/**
+ * Contract implemented by every desktop widget plugin.
+ *
+ * A plugin describes its look as a [RemoteDocument] (data, not code) and feeds it live values
+ * through [variables]; the desktop renders both with its own player. External plugins are jars
+ * that register an implementation in `META-INF/services/org.mjdev.desktop.plugins.IDesktopPlugin`
+ * and carry a `plugin.json` ([PluginMetadata]) at the jar root. One plugin per jar.
+ */
+interface IDesktopPlugin {
+    /** The widget layout. Read once when the plugin is enabled. */
+    val document: RemoteDocument
+
+    /** Period in milliseconds between two calls of [variables]. */
+    val refreshMs: Long
+        get() = PluginDefaults.DEFAULT_REFRESH_MS
+
+    /** Produces the current values the [document] binds to. Called off the UI thread. */
+    fun variables(context: IDesktopContext): RemoteVariables
+
+    /** Called after the user enabled the plugin. */
+    fun onEnabled() {}
+
+    /** Called after the user disabled the plugin; release resources here. */
+    fun onDisabled() {}
+}

+ 128 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/MemoryWidgetPlugin.kt

@@ -0,0 +1,128 @@
+package org.mjdev.desktop.plugins
+
+import org.mjdev.desktop.context.IDesktopContext
+import org.mjdev.desktop.extensions.DoubleExt.toMemorySizeReadable
+import org.mjdev.desktop.helpers.system.meminfo.MemInfo
+import org.mjdev.desktop.plugins.remote.RemoteAlign
+import org.mjdev.desktop.plugins.remote.RemoteColorToken
+import org.mjdev.desktop.plugins.remote.RemoteDocument
+import org.mjdev.desktop.plugins.remote.RemoteOp
+import org.mjdev.desktop.plugins.remote.RemoteOpType
+import org.mjdev.desktop.plugins.remote.RemoteVariables
+
+/**
+ * Built-in memory usage widget (donut chart with used / free legend). It is a regular
+ * [IDesktopPlugin]: the look is a [RemoteDocument] and only the numbers come from code, exactly
+ * what an external plugin jar would provide.
+ */
+class MemoryWidgetPlugin : IDesktopPlugin {
+    override val document: RemoteDocument =
+        RemoteDocument(
+            ops =
+                listOf(
+                    ring(alpha = RING_TRACK_ALPHA, sweepVar = ""),
+                    ring(alpha = 1f, sweepVar = VAR_USED_RATIO),
+                    text("{$VAR_USED_PERCENT}", RING_Y, RING_H, CENTER_TEXT_SIZE),
+                    text(TITLE, TITLE_Y, LINE_H, LINE_TEXT_SIZE),
+                    text("$USED_LABEL: {$VAR_USED}", USED_Y, LINE_H, LINE_TEXT_SIZE),
+                    text("$FREE_LABEL: {$VAR_FREE}", FREE_Y, LINE_H, LINE_TEXT_SIZE),
+                ),
+        )
+
+    override val refreshMs: Long = REFRESH_MS
+
+    override fun variables(context: IDesktopContext): RemoteVariables {
+        val mem = MemInfo(context)
+        val ratio = if (mem.total > 0.0) (mem.used / mem.total).toFloat() else 0f
+        return RemoteVariables(
+            numbers = mapOf(VAR_USED_RATIO to ratio),
+            texts =
+                mapOf(
+                    VAR_USED_PERCENT to "${(ratio * PERCENT).toInt()}%",
+                    VAR_USED to mem.used.toMemorySizeReadable(),
+                    VAR_FREE to mem.free.toMemorySizeReadable(),
+                ),
+        )
+    }
+
+    /** One ring of the donut; the color token is always ICONS, only [alpha] differs. */
+    private fun ring(
+        alpha: Float,
+        sweepVar: String,
+    ) = RemoteOp(
+        type = RemoteOpType.ARC.name,
+        x = RING_X,
+        y = RING_Y,
+        w = RING_W,
+        h = RING_H,
+        color = RemoteColorToken.ICONS.name,
+        alpha = alpha,
+        stroke = RING_STROKE,
+        sweepVar = sweepVar,
+    )
+
+    private fun text(
+        value: String,
+        y: Float,
+        h: Float,
+        size: Float,
+    ) = RemoteOp(
+        type = RemoteOpType.TEXT.name,
+        y = y,
+        h = h,
+        color = RemoteColorToken.ICONS.name,
+        text = value,
+        textSize = size,
+        align = RemoteAlign.CENTER.name,
+    )
+
+    companion object {
+        /** Stable id used to persist whether the widget is enabled. */
+        const val ID = "builtin.memory"
+
+        /** Label of the plugin in the control center and the widget title. */
+        const val TITLE = "Memory"
+
+        /** Description shown when the plugin row is expanded. */
+        const val DESCRIPTION = "Shows used and free system memory as a donut chart on the desktop."
+
+        /** Author shown in the plugin details. */
+        const val AUTHOR = "mjdev"
+
+        /** Version shown in the plugin details. */
+        const val VERSION = "1.0"
+
+        private const val USED_LABEL = "Used"
+        private const val FREE_LABEL = "Free"
+        private const val VAR_USED_RATIO = "usedRatio"
+        private const val VAR_USED_PERCENT = "usedPercent"
+        private const val VAR_USED = "used"
+        private const val VAR_FREE = "free"
+        private const val PERCENT = 100
+        private const val REFRESH_MS = 10_000L
+
+        // layout, normalized to the widget size
+        private const val RING_X = 0.25f
+        private const val RING_Y = 0.03f
+        private const val RING_W = 0.5f
+        private const val RING_H = 0.6f
+        private const val RING_STROKE = 0.12f
+        private const val RING_TRACK_ALPHA = 0.25f
+        private const val TITLE_Y = 0.65f
+        private const val USED_Y = 0.76f
+        private const val FREE_Y = 0.87f
+        private const val LINE_H = 0.1f
+        private const val LINE_TEXT_SIZE = 0.07f
+        private const val CENTER_TEXT_SIZE = 0.1f
+
+        /** Metadata of the built-in plugin. */
+        val METADATA =
+            PluginMetadata(
+                id = ID,
+                name = TITLE,
+                description = DESCRIPTION,
+                version = VERSION,
+                author = AUTHOR,
+            )
+    }
+}

+ 25 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/PluginDefaults.kt

@@ -0,0 +1,25 @@
+package org.mjdev.desktop.plugins
+
+/** Constants describing where plugins live and how they are recognised on disk. */
+object PluginDefaults {
+    /** Directory name (under the `.mjdev` folder in the user's home) holding plugin jars. */
+    const val PLUGINS_DIR_NAME = "plugins"
+
+    /** File name of the persisted plugin state, stored beside the desktop config. */
+    const val STATE_FILE_NAME = "plugins.json"
+
+    /** Extension of a loadable plugin archive. */
+    const val JAR_EXTENSION = "jar"
+
+    /** Entry inside the jar that carries the [PluginMetadata] JSON. */
+    const val METADATA_ENTRY = "plugin.json"
+
+    /** Entry inside the jar used as the plugin image when metadata does not name one. */
+    const val DEFAULT_ICON_ENTRY = "icon.png"
+
+    /** Upper bound for a metadata or icon entry read from a jar, protects against huge entries. */
+    const val MAX_ENTRY_BYTES = 1_048_576
+
+    /** Default period in milliseconds between two variable refreshes of a widget. */
+    const val DEFAULT_REFRESH_MS = 5_000L
+}

+ 23 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/PluginInfo.kt

@@ -0,0 +1,23 @@
+package org.mjdev.desktop.plugins
+
+import androidx.compose.ui.graphics.ImageBitmap
+
+/** What the control center knows about one discovered plugin. */
+data class PluginInfo(
+    /** Descriptive metadata of the plugin. */
+    val metadata: PluginMetadata,
+    /** True when the user switched the plugin on, so it is shown on the desktop. */
+    val enabled: Boolean = false,
+    /** True for plugins shipped inside the desktop itself. */
+    val builtIn: Boolean = false,
+    /** Plugin image decoded from the jar, or null when it ships none. */
+    val icon: ImageBitmap? = null,
+    /** Where the plugin comes from: a jar path, or a label for built-in plugins. */
+    val source: String = "",
+    /** Last load error, or null when the plugin loaded fine (or was not loaded yet). */
+    val error: String? = null,
+) {
+    /** Shortcut for `metadata.id`. */
+    val id: String
+        get() = metadata.id
+}

+ 20 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/PluginMetadata.kt

@@ -0,0 +1,20 @@
+package org.mjdev.desktop.plugins
+
+/**
+ * Descriptive data of a plugin, read from `plugin.json` inside the jar without executing any
+ * plugin code. Every field has a default so Gson can always build it with a no-args constructor.
+ */
+data class PluginMetadata(
+    /** Stable unique id, e.g. `com.example.clock`. Used to persist the enabled state. */
+    val id: String = "",
+    /** Label shown in the control center. */
+    val name: String = "",
+    /** Longer text shown when the plugin row is expanded. */
+    val description: String = "",
+    /** Plugin version string. */
+    val version: String = "",
+    /** Author or vendor name. */
+    val author: String = "",
+    /** Jar entry holding the plugin image; falls back to [PluginDefaults.DEFAULT_ICON_ENTRY]. */
+    val icon: String = "",
+)

+ 30 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/remote/RemoteAlign.kt

@@ -0,0 +1,30 @@
+package org.mjdev.desktop.plugins.remote
+
+import androidx.compose.ui.Alignment
+
+/** Placement of a document or text inside its bounds, parsed from a plain string in the JSON. */
+enum class RemoteAlign(
+    /** Matching Compose alignment for placing a whole widget. */
+    val alignment: Alignment,
+) {
+    TOP_START(Alignment.TopStart),
+    TOP_CENTER(Alignment.TopCenter),
+    TOP_END(Alignment.TopEnd),
+    CENTER_START(Alignment.CenterStart),
+    CENTER(Alignment.Center),
+    CENTER_END(Alignment.CenterEnd),
+    BOTTOM_START(Alignment.BottomStart),
+    BOTTOM_CENTER(Alignment.BottomCenter),
+    BOTTOM_END(Alignment.BottomEnd),
+    ;
+
+    companion object {
+        /** Parses [value] ignoring case and separators, falling back to [default]. */
+        fun parse(
+            value: String,
+            default: RemoteAlign = CENTER,
+        ): RemoteAlign = entries.firstOrNull {
+            it.name.replace("_", "").equals(value.replace("_", ""), true)
+        } ?: default
+    }
+}

+ 65 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/remote/RemoteColorToken.kt

@@ -0,0 +1,65 @@
+package org.mjdev.desktop.plugins.remote
+
+import androidx.compose.ui.graphics.Color
+import org.mjdev.desktop.managers.palette.IPalette
+
+/**
+ * Named colors a document may use instead of a hardcoded value. They resolve against the live
+ * wallpaper [IPalette], so plugin widgets always follow the background image.
+ */
+enum class RemoteColorToken {
+    BACKGROUND,
+    TEXT,
+    ICONS,
+    BORDER,
+    SELECTED,
+    ;
+
+    /** Resolves this token against [palette]. */
+    fun resolve(palette: IPalette): Color = when (this) {
+        BACKGROUND -> palette.backgroundColor
+        TEXT -> palette.textColor
+        ICONS -> palette.iconsTintColor
+        BORDER -> palette.borderColor
+        SELECTED -> palette.selectedBgColor
+    }
+
+    companion object {
+        /** Length of an `#RRGGBB` color string including the hash. */
+        private const val RGB_LENGTH = 7
+
+        /** Length of an `#AARRGGBB` color string including the hash. */
+        private const val ARGB_LENGTH = 9
+
+        /** Radix of hex color digits. */
+        private const val HEX_RADIX = 16
+
+        /** Alpha channel added to opaque `#RRGGBB` colors. */
+        private const val OPAQUE = 0xFF000000L
+
+        /**
+         * Resolves a [spec]: a token name (`ICONS`), `#RRGGBB` or `#AARRGGBB`.
+         * Unknown specs resolve to the palette text color so a widget stays readable.
+         */
+        fun resolve(
+            spec: String,
+            palette: IPalette,
+        ): Color {
+            val token = entries.firstOrNull { it.name.equals(spec, true) }
+            return when {
+                token != null -> token.resolve(palette)
+                spec.startsWith("#") -> parseHex(spec) ?: palette.textColor
+                else -> palette.textColor
+            }
+        }
+
+        private fun parseHex(spec: String): Color? {
+            val value = spec.drop(1).toLongOrNull(HEX_RADIX) ?: return null
+            return when (spec.length) {
+                RGB_LENGTH -> Color((OPAQUE or value).toInt())
+                ARGB_LENGTH -> Color(value.toInt())
+                else -> null
+            }
+        }
+    }
+}

+ 41 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/remote/RemoteDocument.kt

@@ -0,0 +1,41 @@
+package org.mjdev.desktop.plugins.remote
+
+import org.mjdev.desktop.helpers.generic.JsonHelper
+import org.mjdev.desktop.helpers.generic.JsonHelper.toJson
+
+/**
+ * A serializable widget layout in the spirit of Remote Compose: the UI is plain data (a list of
+ * [RemoteOp]s) that the desktop renders with [RemoteDocumentPlayer], so it can be stored, sent
+ * over the network or shipped inside a plugin jar and drawn without running plugin UI code.
+ */
+data class RemoteDocument(
+    /** Format version, bumped on breaking changes. */
+    val version: Int = FORMAT_VERSION,
+    /** Preferred widget width in dp. */
+    val width: Float = DEFAULT_WIDTH,
+    /** Preferred widget height in dp. */
+    val height: Float = DEFAULT_HEIGHT,
+    /** Where the widget sits on the desktop, see [RemoteAlign]. */
+    val anchor: String = RemoteAlign.BOTTOM_END.name,
+    /** Drawing instructions, painted in order. */
+    val ops: List<RemoteOp> = emptyList(),
+) {
+    /** Serializes this document to JSON. */
+    fun encode(): String = toJson()
+
+    companion object {
+        /** Current document format version. */
+        const val FORMAT_VERSION = 1
+
+        /** Default widget width in dp. */
+        const val DEFAULT_WIDTH = 350f
+
+        /** Default widget height in dp. */
+        const val DEFAULT_HEIGHT = 300f
+
+        /** Parses a document from [json], or returns null when it is not valid. */
+        fun decode(json: String): RemoteDocument? = runCatching {
+            JsonHelper.fromJson<RemoteDocument>(json)
+        }.getOrNull()
+    }
+}

+ 25 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/remote/RemoteDocumentPlayer.kt

@@ -0,0 +1,25 @@
+package org.mjdev.desktop.plugins.remote
+
+import androidx.compose.foundation.Canvas
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.rememberTextMeasurer
+import org.mjdev.desktop.context.DesktopContextScope.Companion.withDesktopContext
+import org.mjdev.desktop.plugins.remote.RemoteOpPainter.paint
+
+/**
+ * Renders a [RemoteDocument] with the live [variables]. Colors resolve from the wallpaper palette
+ * on every frame, so the widget re-colors itself when the background changes.
+ */
+@Suppress("FunctionName")
+@Composable
+fun RemoteDocumentPlayer(
+    document: RemoteDocument,
+    variables: RemoteVariables,
+    modifier: Modifier = Modifier,
+) = withDesktopContext {
+    val textMeasurer = rememberTextMeasurer()
+    Canvas(modifier = modifier) {
+        paint(document, variables, palette, textMeasurer)
+    }
+}

+ 47 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/remote/RemoteOp.kt

@@ -0,0 +1,47 @@
+package org.mjdev.desktop.plugins.remote
+
+/**
+ * One drawing instruction of a [RemoteDocument]. All geometry is normalized (0..1) to the widget
+ * size so the same document renders at any size. Every field has a default for Gson.
+ */
+data class RemoteOp(
+    /** Operation name, see [RemoteOpType]. */
+    val type: String = "",
+    /** Left edge of the bounds, 0..1 of the widget width. */
+    val x: Float = 0f,
+    /** Top edge of the bounds, 0..1 of the widget height. */
+    val y: Float = 0f,
+    /** Bounds width, 0..1 of the widget width. */
+    val w: Float = 1f,
+    /** Bounds height, 0..1 of the widget height. */
+    val h: Float = 1f,
+    /** Color: a [RemoteColorToken] name, `#RRGGBB` or `#AARRGGBB`. */
+    val color: String = "",
+    /** Alpha multiplier applied to [color], 0..1. */
+    val alpha: Float = 1f,
+    /** Stroke width, 0..1 of the smaller bounds side. Zero draws a filled shape. */
+    val stroke: Float = 0f,
+    /** ARC: angle where the arc starts, degrees, -90 is the top. */
+    val startAngle: Float = DEFAULT_START_ANGLE,
+    /** ARC: static sweep in degrees, used when [sweepVar] is empty. */
+    val sweep: Float = FULL_CIRCLE,
+    /** ARC: name of a [RemoteVariables.numbers] entry (0..1) that scales the sweep. */
+    val sweepVar: String = "",
+    /** TEXT: the text, `{name}` placeholders come from [RemoteVariables.texts]. */
+    val text: String = "",
+    /** TEXT: font size, 0..1 of the widget height. */
+    val textSize: Float = DEFAULT_TEXT_SIZE,
+    /** TEXT: horizontal placement inside the bounds, see [RemoteAlign]. */
+    val align: String = RemoteAlign.CENTER.name,
+) {
+    companion object {
+        /** Start angle that points the arc to the top of the widget. */
+        const val DEFAULT_START_ANGLE = -90f
+
+        /** Degrees of a full circle. */
+        const val FULL_CIRCLE = 360f
+
+        /** Default text size relative to the widget height. */
+        const val DEFAULT_TEXT_SIZE = 0.07f
+    }
+}

+ 116 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/remote/RemoteOpPainter.kt

@@ -0,0 +1,116 @@
+package org.mjdev.desktop.plugins.remote
+
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.drawscope.DrawScope
+import androidx.compose.ui.graphics.drawscope.Fill
+import androidx.compose.ui.graphics.drawscope.Stroke
+import androidx.compose.ui.text.TextMeasurer
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.drawText
+import org.mjdev.desktop.managers.palette.IPalette
+
+/** Paints single [RemoteOp]s onto a [DrawScope]; stateless so it is cheap to reuse every frame. */
+object RemoteOpPainter {
+    /** Placeholder delimiters in text ops. */
+    private const val PLACEHOLDER_START = "{"
+    private const val PLACEHOLDER_END = "}"
+
+    /** Paints every op of [document] in order. */
+    fun DrawScope.paint(
+        document: RemoteDocument,
+        variables: RemoteVariables,
+        palette: IPalette,
+        textMeasurer: TextMeasurer,
+    ) {
+        document.ops.forEach { op ->
+            when (RemoteOpType.parse(op.type)) {
+                RemoteOpType.RECT -> paintRect(op, palette)
+                RemoteOpType.ARC -> paintArc(op, variables, palette)
+                RemoteOpType.TEXT -> paintText(op, variables, palette, textMeasurer)
+                null -> Unit
+            }
+        }
+    }
+
+    private fun DrawScope.paintRect(
+        op: RemoteOp,
+        palette: IPalette,
+    ) {
+        val color = RemoteColorToken.resolve(op.color, palette).withAlpha(op.alpha)
+        val topLeft = Offset(op.x * size.width, op.y * size.height)
+        val rectSize = Size(op.w * size.width, op.h * size.height)
+        if (op.stroke > 0f) {
+            drawRect(color, topLeft, rectSize, style = Stroke(op.stroke * minOf(rectSize.width, rectSize.height)))
+        } else {
+            drawRect(color, topLeft, rectSize)
+        }
+    }
+
+    private fun DrawScope.paintArc(
+        op: RemoteOp,
+        variables: RemoteVariables,
+        palette: IPalette,
+    ) {
+        val color = RemoteColorToken.resolve(op.color, palette).withAlpha(op.alpha)
+        val boundsW = op.w * size.width
+        val boundsH = op.h * size.height
+        val diameter = minOf(boundsW, boundsH)
+        val strokeWidth = op.stroke * diameter
+        // Keep the ring fully inside its bounds: the stroke is centered on the arc path.
+        val arcDiameter = (diameter - strokeWidth).coerceAtLeast(0f)
+        val topLeft =
+            Offset(
+                op.x * size.width + (boundsW - arcDiameter) / 2f,
+                op.y * size.height + (boundsH - arcDiameter) / 2f,
+            )
+        val ratio = variables.numbers[op.sweepVar]?.coerceIn(0f, 1f)
+        val sweep = if (op.sweepVar.isEmpty() || ratio == null) op.sweep else ratio * RemoteOp.FULL_CIRCLE
+        drawArc(
+            color = color,
+            startAngle = op.startAngle,
+            sweepAngle = sweep,
+            useCenter = op.stroke <= 0f,
+            topLeft = topLeft,
+            size = Size(arcDiameter, arcDiameter),
+            style = if (op.stroke > 0f) Stroke(strokeWidth) else Fill,
+        )
+    }
+
+    private fun DrawScope.paintText(
+        op: RemoteOp,
+        variables: RemoteVariables,
+        palette: IPalette,
+        textMeasurer: TextMeasurer,
+    ) {
+        val text = op.text.resolve(variables)
+        if (text.isEmpty()) return
+        val color = RemoteColorToken.resolve(op.color, palette).withAlpha(op.alpha)
+        val style = TextStyle(color = color, fontSize = (op.textSize * size.height).toSp())
+        val measured = textMeasurer.measure(text, style)
+        val boundsX = op.x * size.width
+        val boundsW = op.w * size.width
+        val left =
+            when (RemoteAlign.parse(op.align)) {
+                RemoteAlign.TOP_START, RemoteAlign.CENTER_START, RemoteAlign.BOTTOM_START -> boundsX
+                RemoteAlign.TOP_END, RemoteAlign.CENTER_END, RemoteAlign.BOTTOM_END ->
+                    boundsX + boundsW - measured.size.width
+                else -> boundsX + (boundsW - measured.size.width) / 2f
+            }
+        val top = op.y * size.height + (op.h * size.height - measured.size.height) / 2f
+        drawText(textMeasurer, text, Offset(left, top), style)
+    }
+
+    /** Replaces every `{name}` with its value from [variables]; unknown names stay empty. */
+    private fun String.resolve(variables: RemoteVariables): String {
+        var result = this
+        variables.texts.forEach { (name, value) ->
+            result = result.replace(PLACEHOLDER_START + name + PLACEHOLDER_END, value)
+        }
+        return result
+    }
+
+    private fun Color.withAlpha(multiplier: Float) =
+        copy(alpha = alpha * multiplier.coerceIn(0f, 1f))
+}

+ 19 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/remote/RemoteOpType.kt

@@ -0,0 +1,19 @@
+package org.mjdev.desktop.plugins.remote
+
+/** Drawing operations a [RemoteDocument] can contain. */
+enum class RemoteOpType {
+    /** Filled or stroked rectangle. */
+    RECT,
+
+    /** Arc (ring segment) centered in the op bounds, used for donut charts and gauges. */
+    ARC,
+
+    /** Text line, may contain `{name}` placeholders resolved from [RemoteVariables.texts]. */
+    TEXT,
+    ;
+
+    companion object {
+        /** Parses [value] ignoring case, or returns null for unknown operations. */
+        fun parse(value: String): RemoteOpType? = entries.firstOrNull { it.name.equals(value, true) }
+    }
+}

+ 9 - 0
shared/src/commonMain/kotlin/org/mjdev/desktop/plugins/remote/RemoteVariables.kt

@@ -0,0 +1,9 @@
+package org.mjdev.desktop.plugins.remote
+
+/** Live values a [RemoteDocument] binds to; produced by the plugin on every refresh. */
+data class RemoteVariables(
+    /** Numeric values by name, e.g. a ratio 0..1 that drives an arc sweep. */
+    val numbers: Map<String, Float> = emptyMap(),
+    /** Text values by name, substituted for `{name}` placeholders in text ops. */
+    val texts: Map<String, String> = emptyMap(),
+)

+ 1 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/appsmenu/AppsMenuWindow.kt

@@ -81,6 +81,7 @@ fun AppsMenuWindow(
     ChromeWindow(
         name = "AppsMenu",
         visible = menuState.isVisible,
+        blur = true,
         // Not alwaysOnTop: DesktopState's focusOnShow (see MainWindow's register call) already
         // raises + focuses this window on open — a normal toFront(), not a permanent OS-level
         // pin that would keep it above external apps too.

+ 1 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/controlcenter/ControlCenterWindow.kt

@@ -60,6 +60,7 @@ fun ControlCenterWindow(
     ChromeWindow(
         name = "ControlCenter",
         visible = true,
+        blur = true,
         // Not alwaysOnTop: DesktopState's focusOnShow (see MainWindow's register call) already
         // raises + focuses this window whenever the reveal hotspot is entered — a normal
         // toFront(), not a permanent OS-level pin that would keep it above external apps too.

+ 1 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/components/desktoppanel/DesktopPanelWindow.kt

@@ -95,6 +95,7 @@ fun DesktopPanelWindow(
     ChromeWindow(
         name = "DesktopPanel",
         visible = true,
+        blur = true,
         position = position,
         size = size,
         onFocusChange = onFocusChange,

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

@@ -17,7 +17,7 @@ import org.mjdev.desktop.components.appsmenu.AppsMenuState.Companion.rememberApp
 import org.mjdev.desktop.components.appsmenu.AppsMenuWindow
 import org.mjdev.desktop.components.controlcenter.ControlCenterWindow
 import org.mjdev.desktop.components.desktop.Desktop
-import org.mjdev.desktop.components.desktop.widgets.MemoryChart
+import org.mjdev.desktop.components.desktop.widgets.PluginWidgets
 import org.mjdev.desktop.components.desktoppanel.DesktopPanelWindow
 import org.mjdev.desktop.components.dockbar.DockBarWindow
 import org.mjdev.desktop.components.greeter.GreeterWindow
@@ -156,12 +156,12 @@ fun MainWindow() = withDesktopContext {
                     bottom = bottomPadding,
                 ),
             widgets = {
-                MemoryChart(
-                    modifier =
-                        Modifier
-                            .size(350.dp, 300.dp)
-                            .align(Alignment.BottomEnd),
-                )
+                // Widgets are plugins now (the memory chart is the built-in one), switched on and
+                // off in the control center Plugins tab. Previous direct usage kept for history:
+                // MemoryChart(
+                //     modifier = Modifier.size(350.dp, 300.dp).align(Alignment.BottomEnd),
+                // )
+                PluginWidgets()
 //                WebView(
 //                    modifier = Modifier
 //                        .size(800.dp, 600.dp)

+ 23 - 5
shared/src/desktopMain/kotlin/org/mjdev/desktop/context/DeskptopContext.kt

@@ -42,6 +42,8 @@ import org.mjdev.desktop.managers.os.IOSManager
 import org.mjdev.desktop.managers.os.OsManager
 import org.mjdev.desktop.managers.palette.IPalette
 import org.mjdev.desktop.managers.palette.Palette
+import org.mjdev.desktop.managers.plugins.IPluginManager
+import org.mjdev.desktop.managers.plugins.PluginManager
 import org.mjdev.desktop.managers.process.IProcessManager
 import org.mjdev.desktop.managers.processes.ProcessManager
 import org.mjdev.desktop.managers.remote.IRemoteDesktopManager
@@ -49,12 +51,16 @@ import org.mjdev.desktop.managers.remote.RemoteDesktopManager
 import org.mjdev.desktop.managers.theme.IThemeManager
 import org.mjdev.desktop.managers.theme.ThemeManager
 import org.mjdev.desktop.managers.translations.ITranslator
+import org.mjdev.desktop.managers.volume.IVolumeManager
+import org.mjdev.desktop.managers.volume.VolumeManager
 import java.awt.Desktop
 import java.awt.Toolkit
 import java.io.File
 import java.net.URI
 import kotlin.reflect.KClass
 import kotlin.reflect.full.companionObject
+import kotlin.reflect.full.companionObjectInstance
+import kotlin.reflect.jvm.isAccessible
 
 @Suppress("RedundantSuspendModifier", "unused", "MemberVisibilityCanBePrivate")
 class DesktopContext(
@@ -263,13 +269,25 @@ class DesktopContext(
         IProcessManager::class -> ProcessManager(this)
         IKeyManager::class -> KeysManager(this)
         IRemoteDesktopManager::class -> RemoteDesktopManager(this)
-        else ->
-            cls.companionObject
-                ?.members
-                ?.first { it.name == "EMPTY" }
-                ?.call() as IDelegate
+        IVolumeManager::class -> VolumeManager(this)
+        IPluginManager::class -> PluginManager(this)
+        else -> createEmptyManager(cls)
     }
 
+    /**
+     * Resolves the `EMPTY` fallback of a manager's companion. The backing field is private
+     * static, so the getter must be made accessible before it is called reflectively.
+     */
+    private fun createEmptyManager(cls: KClass<*>): IDelegate = runCatching {
+        cls.companionObject
+            ?.members
+            ?.firstOrNull { it.name == "EMPTY" }
+            ?.also { it.isAccessible = true }
+            ?.call(cls.companionObjectInstance) as? IDelegate
+    }.onFailure { e ->
+        Log.e(e)
+    }.getOrNull() ?: error("No manager available for $cls")
+
     override suspend fun restart() {
         Shell.executeAndRead("/usr/sbin/halt", "--reboot")
     }

+ 90 - 2
shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/connectivity/ConnectivityManagerLinux.kt

@@ -61,8 +61,80 @@ class ConnectivityManagerLinux(
             return getWifiNetworks().associateBy { wi -> wi.name }.toMutableMap()
         }
 
-    // todo
-    override val bthNetworks: MutableMap<String, BthDevice> = mutableMapOf()
+    override val bthNetworks: MutableMap<String, BthDevice>
+        get() {
+            val connected = bluetoothAddresses(*CMD_BTH_CONNECTED)
+            val paired = bluetoothAddresses(*CMD_BTH_PAIRED)
+            return Shell
+                .executeAndReadLines(CMD_BLUETOOTHCTL, *CMD_BTH_DEVICES)
+                .mapNotNull { line -> BTH_LINE_REGEX.matchEntire(line.trim()) }
+                .associate { match ->
+                    val address = match.groupValues[1]
+                    address to
+                        BthDevice(
+                            name = match.groupValues[2].ifBlank { address },
+                            address = address,
+                            connected = address in connected,
+                            paired = address in paired,
+                        )
+                }.toMutableMap()
+        }
+
+    override val isBluetoothPowered: Boolean
+        get() = Shell
+            .executeAndReadLines(CMD_BLUETOOTHCTL, CMD_BTH_SHOW)
+            .any { it.trim().startsWith(BTH_POWERED_PREFIX) && it.contains(BTH_YES) }
+
+    override fun setDeviceConnected(
+        device: String,
+        connected: Boolean,
+    ): Result<Boolean> = succeeded(
+        CMD_NMCLI,
+        "device",
+        if (connected) "connect" else "disconnect",
+        device,
+    )
+
+    override fun deviceDetails(device: String): Map<String, String> {
+        val result = linkedMapOf<String, String>()
+        Shell
+            .executeAndReadLines(CMD_NMCLI, "-t", "-f", NMCLI_DETAIL_FIELDS, "device", "show", device)
+            .forEach { line ->
+                // Terse nmcli output: KEY:value, a colon inside the value is escaped as \:
+                val key = line.substringBefore(':').substringBefore('[')
+                val value = line.substringAfter(':', "").replace("\\:", ":").trim()
+                if (key.isNotBlank() && value.isNotBlank() && value != NMCLI_EMPTY) {
+                    result[key] = result[key]?.let { "$it, $value" } ?: value
+                }
+            }
+        return result
+    }
+
+    override fun setBluetoothPowered(powered: Boolean): Result<Boolean> = succeeded(
+        CMD_BLUETOOTHCTL,
+        "power",
+        if (powered) BTH_ON else BTH_OFF,
+    )
+
+    override fun setBluetoothDeviceConnected(
+        address: String,
+        connected: Boolean,
+    ): Result<Boolean> = succeeded(
+        CMD_BLUETOOTHCTL,
+        if (connected) "connect" else "disconnect",
+        address,
+    )
+
+    private fun bluetoothAddresses(vararg args: String): Set<String> = Shell
+        .executeAndReadLines(CMD_BLUETOOTHCTL, *args)
+        .mapNotNull { line -> BTH_LINE_REGEX.matchEntire(line.trim())?.groupValues?.get(1) }
+        .toSet()
+
+    /** Runs a command and reports whether it exited with 0. */
+    private fun succeeded(
+        cmd: String,
+        vararg args: String,
+    ): Result<Boolean> = Shell.execute(cmd, *args).map { process -> process.exitValue() == 0 }
 
     @Suppress("unused")
     val adbHandler =
@@ -125,6 +197,22 @@ class ConnectivityManagerLinux(
         val bthDevicesDir = "/sys/class/bluetooth/".toPath(true)
 
         const val CMD_NMCLI = "nmcli"
+        const val CMD_BLUETOOTHCTL = "bluetoothctl"
+        const val CMD_BTH_SHOW = "show"
+        val CMD_BTH_DEVICES = arrayOf("devices")
+        val CMD_BTH_CONNECTED = arrayOf("devices", "Connected")
+        val CMD_BTH_PAIRED = arrayOf("devices", "Paired")
+
+        // `bluetoothctl devices` prints: Device AA:BB:CC:DD:EE:FF Some name
+        private val BTH_LINE_REGEX = Regex("^Device\\s+(\\S+)\\s*(.*)$")
+        private const val BTH_POWERED_PREFIX = "Powered:"
+        private const val BTH_YES = "yes"
+        private const val BTH_ON = "on"
+        private const val BTH_OFF = "off"
+
+        private const val NMCLI_DETAIL_FIELDS =
+            "GENERAL.STATE,GENERAL.CONNECTION,IP4.ADDRESS,IP4.GATEWAY,IP4.DNS,GENERAL.HWADDR"
+        private const val NMCLI_EMPTY = "--"
         val CMD_NMCLI_RESCAN = arrayOf("dev", "wifi", "rescan")
         val CMD_NMCLI_GET_NETWORKS = arrayOf("-t", "-f", "ALL", "dev", "wifi")
     }

+ 25 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/os/linux/OSManagerLinux.kt

@@ -11,6 +11,7 @@ package org.mjdev.desktop.managers.os.linux
 import org.mjdev.desktop.context.IDesktopContext
 import org.mjdev.desktop.helpers.system.shell.Shell
 import org.mjdev.desktop.managers.os.base.OSManagerStub
+import java.io.File
 
 class OSManagerLinux(
     context: IDesktopContext,
@@ -43,6 +44,30 @@ class OSManagerLinux(
         get() = osRelease.ubuntuCodename
     override val logo
         get() = osRelease.logo
+    override val kernel: String
+        get() = Shell.executeAndRead("uname", "-r").trim()
+    override val uptime: String
+        get() = runCatching {
+            File(UPTIME_FILE)
+                .readText()
+                .substringBefore(' ')
+                .toDouble()
+                .toLong()
+                .let { total ->
+                    val days = total / SECONDS_PER_DAY
+                    val hours = total % SECONDS_PER_DAY / SECONDS_PER_HOUR
+                    val minutes = total % SECONDS_PER_HOUR / SECONDS_PER_MINUTE
+                    "${days}d ${hours}h ${minutes}m"
+                }
+        }.getOrDefault("")
     override val machineName
         get() = Shell.executeAndRead("hostname").trim()
+
+    companion object {
+        /** Kernel file holding the uptime in seconds as its first field. */
+        private const val UPTIME_FILE = "/proc/uptime"
+        private const val SECONDS_PER_DAY = 86_400L
+        private const val SECONDS_PER_HOUR = 3_600L
+        private const val SECONDS_PER_MINUTE = 60L
+    }
 }

+ 48 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/plugins/PluginJarReader.kt

@@ -0,0 +1,48 @@
+package org.mjdev.desktop.managers.plugins
+
+import androidx.compose.ui.graphics.ImageBitmap
+import androidx.compose.ui.graphics.toComposeImageBitmap
+import org.jetbrains.skia.Image
+import org.mjdev.desktop.helpers.generic.JsonHelper
+import org.mjdev.desktop.log.Log
+import org.mjdev.desktop.plugins.PluginDefaults
+import org.mjdev.desktop.plugins.PluginMetadata
+import java.io.File
+import java.util.zip.ZipFile
+
+/**
+ * Reads plugin descriptions straight out of a jar archive. Only plain data entries are read;
+ * no class from the jar is loaded, so listing plugins never runs third-party code.
+ */
+object PluginJarReader {
+    /** Returns the metadata of [jar], or null when `plugin.json` is missing or has no id. */
+    fun readMetadata(jar: File): PluginMetadata? = runCatching {
+        readEntry(jar, PluginDefaults.METADATA_ENTRY)
+            ?.toString(Charsets.UTF_8)
+            ?.let { JsonHelper.fromJson<PluginMetadata>(it) }
+            ?.takeIf { it.id.isNotBlank() }
+    }.onFailure { e ->
+        Log.e(e)
+    }.getOrNull()
+
+    /** Returns the plugin image of [jar] named by [metadata], or null when there is none. */
+    fun readIcon(
+        jar: File,
+        metadata: PluginMetadata,
+    ): ImageBitmap? = runCatching {
+        readEntry(jar, metadata.icon.ifBlank { PluginDefaults.DEFAULT_ICON_ENTRY })
+            ?.let { Image.makeFromEncoded(it).toComposeImageBitmap() }
+    }.onFailure { e ->
+        Log.e(e)
+    }.getOrNull()
+
+    private fun readEntry(
+        jar: File,
+        name: String,
+    ): ByteArray? = ZipFile(jar).use { zip ->
+        zip
+            .getEntry(name)
+            ?.takeIf { it.size in 0..PluginDefaults.MAX_ENTRY_BYTES.toLong() }
+            ?.let { entry -> zip.getInputStream(entry).use { it.readBytes() } }
+    }
+}

+ 179 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/plugins/PluginManager.kt

@@ -0,0 +1,179 @@
+package org.mjdev.desktop.managers.plugins
+
+import androidx.compose.runtime.mutableStateListOf
+import org.mjdev.desktop.context.IDesktopContext
+import org.mjdev.desktop.data.DesktopConfigStore
+import org.mjdev.desktop.extensions.PathExt.exists
+import org.mjdev.desktop.extensions.PathExt.mkdirs
+import org.mjdev.desktop.extensions.PathExt.parentFile
+import org.mjdev.desktop.extensions.PathExt.text
+import org.mjdev.desktop.extensions.PathExt.writeText
+import org.mjdev.desktop.helpers.generic.JsonHelper
+import org.mjdev.desktop.helpers.generic.JsonHelper.toJson
+import org.mjdev.desktop.log.Log
+import org.mjdev.desktop.plugins.IDesktopPlugin
+import org.mjdev.desktop.plugins.MemoryWidgetPlugin
+import org.mjdev.desktop.plugins.PluginDefaults
+import org.mjdev.desktop.plugins.PluginInfo
+import java.io.File
+import java.net.URLClassLoader
+import java.util.ServiceLoader
+
+/**
+ * Desktop (JVM) [IPluginManager]. External plugins are jars in `~/.mjdev/plugins/`; a jar is only
+ * opened as a class loader once the user enables its plugin, and the loader is closed again on
+ * disable. Plugins run with the desktop's own permissions, so only enable jars you trust.
+ */
+class PluginManager(
+    private val context: IDesktopContext,
+) : IPluginManager {
+    private val homeDir: String
+        get() = context.currentUser.homeDir.toString()
+
+    private val stateFile =
+        context.currentUser.homeDir
+            .resolve(DesktopConfigStore.CONFIG_DIR_NAME)
+            .resolve(DesktopConfigStore.CONFIG_SUBDIR_NAME)
+            .resolve(PluginDefaults.STATE_FILE_NAME)
+
+    override val pluginsDir: String
+        get() = File(homeDir, "${DesktopConfigStore.CONFIG_DIR_NAME}/${PluginDefaults.PLUGINS_DIR_NAME}").path
+
+    private val state: PluginState = loadState()
+
+    private val builtIn = MemoryWidgetPlugin()
+
+    private val jars = mutableMapOf<String, File>()
+    private val loaders = mutableMapOf<String, URLClassLoader>()
+    private val instances = mutableMapOf<String, IDesktopPlugin>()
+
+    private val pluginList = mutableStateListOf<PluginInfo>()
+
+    override val plugins: List<PluginInfo>
+        get() = pluginList
+
+    init {
+        refresh()
+    }
+
+    override fun refresh() {
+        jars.clear()
+        val found = mutableListOf(builtInInfo())
+        scanJars().forEach { jar ->
+            val metadata = PluginJarReader.readMetadata(jar)
+            when {
+                metadata == null -> Log.d("Skipping ${jar.name}: no valid ${PluginDefaults.METADATA_ENTRY}")
+                found.any { it.id == metadata.id } -> Log.d("Skipping ${jar.name}: duplicate id ${metadata.id}")
+                else -> {
+                    jars[metadata.id] = jar
+                    found.add(
+                        PluginInfo(
+                            metadata = metadata,
+                            enabled = metadata.id in state.enabled,
+                            icon = PluginJarReader.readIcon(jar, metadata),
+                            source = jar.path,
+                            error = pluginList.firstOrNull { it.id == metadata.id }?.error,
+                        ),
+                    )
+                }
+            }
+        }
+        pluginList.clear()
+        pluginList.addAll(found)
+    }
+
+    override fun setEnabled(
+        id: String,
+        enabled: Boolean,
+    ) {
+        val index = pluginList.indexOfFirst { it.id == id }
+        if (index < 0) return
+        if (!enabled) {
+            state.enabled.remove(id)
+        } else if (id !in state.enabled) {
+            state.enabled.add(id)
+        }
+        saveState()
+        if (!enabled) unload(id)
+        pluginList[index] = pluginList[index].copy(enabled = enabled, error = null)
+        if (enabled) instance(id)
+    }
+
+    override fun instance(id: String): IDesktopPlugin? {
+        val info = pluginList.firstOrNull { it.id == id }
+        return when {
+            info == null || !info.enabled -> null
+            instances.containsKey(id) -> instances[id]
+            info.builtIn -> builtIn.also { instances[id] = it; it.onEnabled() }
+            else -> load(id)
+        }
+    }
+
+    private fun load(id: String): IDesktopPlugin? = runCatching {
+        val jar = jars[id] ?: return null
+        val loader = URLClassLoader(arrayOf(jar.toURI().toURL()), IDesktopPlugin::class.java.classLoader)
+        loaders[id] = loader
+        ServiceLoader
+            .load(IDesktopPlugin::class.java, loader)
+            .first()
+            .also { plugin ->
+                instances[id] = plugin
+                plugin.onEnabled()
+                Log.i("Plugin $id loaded from ${jar.name}")
+            }
+    }.onFailure { e ->
+        // LinkageError and friends are Errors, but a broken plugin must never take the desktop down.
+        Log.e(e)
+        unload(id)
+        markError(id, e.message ?: e.javaClass.simpleName)
+    }.getOrNull()
+
+    private fun unload(id: String) {
+        instances.remove(id)?.let { runCatching { it.onDisabled() }.onFailure { e -> Log.e(e) } }
+        loaders.remove(id)?.let { runCatching { it.close() } }
+    }
+
+    private fun markError(
+        id: String,
+        message: String,
+    ) {
+        val index = pluginList.indexOfFirst { it.id == id }
+        if (index >= 0) pluginList[index] = pluginList[index].copy(error = message)
+    }
+
+    private fun builtInInfo() = PluginInfo(
+        metadata = MemoryWidgetPlugin.METADATA,
+        enabled = MemoryWidgetPlugin.ID in state.enabled,
+        builtIn = true,
+        source = BUILT_IN_SOURCE,
+    )
+
+    private fun scanJars(): List<File> {
+        val dir = File(pluginsDir)
+        if (!dir.exists()) dir.mkdirs()
+        return dir
+            .listFiles { file -> file.isFile && file.extension.equals(PluginDefaults.JAR_EXTENSION, true) }
+            ?.sortedBy { it.name }
+            .orEmpty()
+    }
+
+    private fun loadState(): PluginState = runCatching {
+        if (stateFile.exists) JsonHelper.fromJson<PluginState>(stateFile.text) else null
+    }.onFailure { e ->
+        Log.e(e)
+    }.getOrNull() ?: PluginState()
+
+    private fun saveState() {
+        runCatching {
+            stateFile.parentFile.mkdirs()
+            stateFile.writeText(state.toJson())
+        }.onFailure { e ->
+            Log.e(e)
+        }
+    }
+
+    companion object {
+        /** Source label shown for plugins that ship inside the desktop. */
+        private const val BUILT_IN_SOURCE = "built-in"
+    }
+}

+ 12 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/plugins/PluginState.kt

@@ -0,0 +1,12 @@
+package org.mjdev.desktop.managers.plugins
+
+import org.mjdev.desktop.plugins.MemoryWidgetPlugin
+
+/**
+ * Persisted plugin choices, stored as JSON in `~/.mjdev/desktop/plugins.json`. Defaults keep the
+ * memory widget visible on a fresh install, matching the desktop before plugins existed.
+ */
+data class PluginState(
+    /** Ids of the plugins the user switched on. */
+    val enabled: MutableList<String> = mutableListOf(MemoryWidgetPlugin.ID),
+)

+ 31 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/managers/volume/VolumeManager.kt

@@ -17,6 +17,13 @@ class VolumeManager(
 
         // Regex used to parse the percentage value out of `pactl get-sink-volume` output.
         private val VOLUME_REGEX = Regex("(\\d+)%")
+
+        // pactl arguments used to discover the output devices.
+        private const val CMD_GET_DEFAULT_SINK = "get-default-sink"
+        private val CMD_LIST_SINKS = arrayOf("list", "short", "sinks")
+
+        // Column of the sink name in `pactl list short sinks` (index, name, module, ...).
+        private const val SINK_NAME_COLUMN = 1
     }
 
     override val volume: Float
@@ -42,6 +49,30 @@ class VolumeManager(
                 output.contains("yes", ignoreCase = true)
             }.getOrNull() ?: false
 
+    override val outputs: List<AudioOutput>
+        get() {
+            val default = runCatching { runPactl(CMD_GET_DEFAULT_SINK).trim() }.getOrDefault("")
+            return runCatching { runPactl(*CMD_LIST_SINKS) }
+                .getOrDefault("")
+                .lineSequence()
+                .mapNotNull { line -> line.split('\t').getOrNull(SINK_NAME_COLUMN)?.takeIf { it.isNotBlank() } }
+                .map { name -> AudioOutput(name = name, isDefault = name == default) }
+                .toList()
+        }
+
+    override fun setDefaultOutput(name: String) {
+        runCatching {
+            ProcessBuilder("pactl", "set-default-sink", name).start().waitFor()
+        }
+    }
+
+    private fun runPactl(vararg args: String): String {
+        val process = ProcessBuilder("pactl", *args).start()
+        val output = process.inputStream.bufferedReader().use(BufferedReader::readText)
+        process.waitFor()
+        return output
+    }
+
     override fun setVolume(volume: Float) {
         val clamped = volume.coerceIn(0f, 1f)
         val percent = (clamped * 100).toInt()

+ 8 - 2
shared/src/desktopMain/kotlin/org/mjdev/desktop/windows/ChromeWindow.kt

@@ -21,6 +21,9 @@ fun ChromeWindow(
     name: String? = null,
     visible: Boolean = true,
     transparent: Boolean = true,
+    // Ask the compositor to blur whatever is rendered below this window (needs a compositor
+    // built with scenefx and a window with transparent pixels, see WindowTitle).
+    blur: Boolean = false,
     resizable: Boolean = false,
     enabled: Boolean = true,
     focusable: Boolean = true,
@@ -75,7 +78,7 @@ fun ChromeWindow(
             // 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" } ?: "",
+            title = WindowTitle.encode(name, blur),
             icon = null,
             undecorated = true,
             transparent = transparent,
@@ -89,7 +92,10 @@ fun ChromeWindow(
             stateHelper = windowState.stateHelper,
             focusHelper = windowState.focusHelper,
             content = {
-                content()
+                // exposes the live screen bounds so glass backdrops can line up with the wallpaper
+                ProvideWindowBounds(window) {
+                    content()
+                }
             },
         )
         updateWindowState(visible, wnState, windowState)

+ 54 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/windows/ProvideWindowBounds.kt

@@ -0,0 +1,54 @@
+package org.mjdev.desktop.windows
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.DisposableEffect
+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.DpOffset
+import androidx.compose.ui.unit.DpSize
+import androidx.compose.ui.unit.dp
+import org.mjdev.desktop.components.blur.LocalWindowBounds
+import org.mjdev.desktop.components.blur.WindowBounds
+import java.awt.Window
+import java.awt.event.ComponentAdapter
+import java.awt.event.ComponentEvent
+
+/**
+ * Provides the live screen bounds of [window] to [content] through [LocalWindowBounds]. The bounds
+ * come straight from AWT, so they stay right when the compositor or the user moves the window.
+ */
+@Composable
+fun ProvideWindowBounds(
+    window: Window,
+    content: @Composable () -> Unit,
+) {
+    var bounds by remember(window) { mutableStateOf(window.currentBounds()) }
+    DisposableEffect(window) {
+        val listener =
+            object : ComponentAdapter() {
+                override fun componentMoved(e: ComponentEvent) {
+                    bounds = window.currentBounds()
+                }
+
+                override fun componentResized(e: ComponentEvent) {
+                    bounds = window.currentBounds()
+                }
+
+                override fun componentShown(e: ComponentEvent) {
+                    bounds = window.currentBounds()
+                }
+            }
+        window.addComponentListener(listener)
+        bounds = window.currentBounds()
+        onDispose { window.removeComponentListener(listener) }
+    }
+    CompositionLocalProvider(LocalWindowBounds provides bounds, content = content)
+}
+
+private fun Window.currentBounds() = WindowBounds(
+    position = DpOffset(x.dp, y.dp),
+    size = DpSize(width.dp, height.dp),
+)

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

@@ -112,7 +112,10 @@ fun Window(
                     WindowEventsAdapter(
                         this,
                         currentOnCloseRequest,
-                        onOpened,
+                        { opened ->
+                            WindowTransparencyDiagnostics.log(currentName, opened, currentTransparent)
+                            onOpened(opened)
+                        },
                         onClosing,
                         onClosed,
                         onActivated,

+ 22 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/windows/WindowTitle.kt

@@ -0,0 +1,22 @@
+package org.mjdev.desktop.windows
+
+/**
+ * Encodes what the shell wants the compositor to know about a window into its (never visible)
+ * title: `mjdev::<name>` for the role, optionally followed by space separated flags, e.g.
+ * `mjdev::ControlCenter blur`. The compositor parses the same format in its Policy.
+ */
+object WindowTitle {
+    /** Prefix that marks a window as part of the desktop shell and names its role. */
+    const val PREFIX = "mjdev::"
+
+    /** Flag asking the compositor to blur everything rendered below the window. */
+    const val FLAG_BLUR = "blur"
+
+    /** Builds the title for a window called [name]; null when the window has no name. */
+    fun encode(
+        name: String?,
+        blur: Boolean,
+    ): String = name
+        ?.let { role -> listOfNotNull(PREFIX + role, FLAG_BLUR.takeIf { blur }).joinToString(" ") }
+        .orEmpty()
+}

+ 44 - 0
shared/src/desktopMain/kotlin/org/mjdev/desktop/windows/WindowTransparencyDiagnostics.kt

@@ -0,0 +1,44 @@
+package org.mjdev.desktop.windows
+
+import org.mjdev.desktop.log.Log
+import java.awt.Frame
+import java.awt.GraphicsDevice
+import java.awt.Window
+
+/**
+ * Logs, once per window, every fact that decides whether a window can be see-through on this
+ * machine: what the app asked for, what AWT really applied, and what the screen and session
+ * support. Read the log lines prefixed with [TAG] to find which link of the chain is missing.
+ */
+object WindowTransparencyDiagnostics {
+    /** Prefix of every diagnostic line, easy to grep in the session log. */
+    private const val TAG = "WindowTransparency"
+
+    /** Environment variables that influence rendering and the display session type. */
+    private val ENV_KEYS = listOf("SKIKO_RENDER_API", "XDG_SESSION_TYPE", "WAYLAND_DISPLAY", "DISPLAY")
+
+    /** Logs the transparency state of [window] named [name]; [requested] is what the app asked for. */
+    fun log(
+        name: String,
+        window: Window,
+        requested: Boolean,
+    ) {
+        if (!requested) return
+        runCatching {
+            val config = window.graphicsConfiguration
+            val perPixel =
+                config?.device?.isWindowTranslucencySupported(
+                    GraphicsDevice.WindowTranslucency.PERPIXEL_TRANSLUCENT,
+                )
+            Log.i(
+                "$TAG [$name] requested=$requested undecorated=${(window as? Frame)?.isUndecorated} " +
+                    "backgroundAlpha=${window.background?.alpha} opaque=${window.isOpaque} " +
+                    "gcTranslucencyCapable=${config?.isTranslucencyCapable} " +
+                    "devicePerPixelTranslucency=$perPixel",
+            )
+            Log.i("$TAG [$name] env " + ENV_KEYS.joinToString(" ") { "$it=${System.getenv(it)}" })
+        }.onFailure { e ->
+            Log.e(e)
+        }
+    }
+}