瀏覽代碼

added desktop icons

UwU 7 月之前
父節點
當前提交
00a08c1f85

+ 3 - 0
src/main/index.ts

@@ -4,6 +4,7 @@ import { initSteam } from './services/steam.service'
 import { initDatabase } from './services/library.service'
 import { startWatcher } from './services/watcher.service'
 import { registerAllHandlers } from './ipc'
+import { initDesktopIcons, cleanupDesktopIcons } from './services/desktop-icons.service'
 
 function createWindow(): BrowserWindow {
   const win = new BrowserWindow({
@@ -53,9 +54,11 @@ app.whenReady().then(() => {
   const win = createWindow()
   registerAllHandlers(win)
   startWatcher(win)
+  initDesktopIcons()
 })
 
 app.on('window-all-closed', () => {
+  cleanupDesktopIcons()
   if (process.platform !== 'darwin') app.quit()
 })
 

+ 14 - 0
src/main/ipc/desktop-icons.handlers.ts

@@ -0,0 +1,14 @@
+import { ipcMain } from 'electron'
+import { IpcChannels } from '@shared/ipc-channels'
+import { setDesktopIconsEnabled, getDesktopIconsEnabled } from '../services/desktop-icons.service'
+
+export function registerDesktopIconsHandlers(): void {
+  ipcMain.handle(IpcChannels.DESKTOP_ICONS_SET_ENABLED, (_e, enabled: boolean) => {
+    setDesktopIconsEnabled(enabled)
+    return { ok: true, enabled }
+  })
+
+  ipcMain.handle(IpcChannels.DESKTOP_ICONS_GET_ENABLED, () => {
+    return getDesktopIconsEnabled()
+  })
+}

+ 2 - 0
src/main/ipc/index.ts

@@ -7,6 +7,7 @@ import { registerConfigHandlers } from './config.handlers'
 import { registerFolderHandlers } from './folders.handlers'
 import { registerShellHandlers } from './shell.handlers'
 import { registerLweHandlers } from './lwe.handlers'
+import { registerDesktopIconsHandlers } from './desktop-icons.handlers'
 
 export function registerAllHandlers(win: BrowserWindow): void {
   registerSteamHandlers(win)
@@ -17,4 +18,5 @@ export function registerAllHandlers(win: BrowserWindow): void {
   registerFolderHandlers()
   registerShellHandlers()
   registerLweHandlers(win)
+  registerDesktopIconsHandlers()
 }

+ 1 - 7
src/main/ipc/wallpaper.handlers.ts

@@ -32,14 +32,8 @@ export function registerWallpaperHandlers(): void {
     const isAnimated = wallpaper.type === 'scene' || wallpaper.type === 'web' || wallpaper.type === 'video'
     const lwe = getLweStatus()
 
-    // Check if we're on KDE Wayland - LWE has GLEW 2.3.0+ compatibility issues
-    const isWayland = !!(process.env.WAYLAND_DISPLAY || 
-      (process.env.XDG_SESSION_TYPE === 'wayland') ||
-      (process.env.XDG_CURRENT_DESKTOP?.toLowerCase().includes('kde')))
-    
     // Use linux-wallpaperengine for animated wallpapers on Linux if available
-    // Skip on KDE Wayland due to GLEW 2.3.0+ compatibility issues
-    if (isAnimated && os.platform() === 'linux' && lwe.installed && !isWayland) {
+    if (isAnimated && os.platform() === 'linux' && lwe.installed) {
       await launchLweAsync(wallpaper.localPath)
 
       store.set('activeWallpaperId', options.wallpaperId)

+ 480 - 0
src/main/services/desktop-icons-overlay.py

@@ -0,0 +1,480 @@
+#!/usr/bin/env python3
+"""
+Desktop icons overlay for we_manager.
+
+Renders desktop icons on a transparent layer-shell surface (LAYER_BOTTOM)
+so they appear on top of the linux-wallpaperengine wallpaper.
+
+The overlay is purely visual — input passes through via empty wl_region
+to plasmashell's Folder View underneath, which handles clicks normally.
+
+Launch with: LD_PRELOAD=/usr/lib64/libgtk4-layer-shell.so python3 this.py [MONITOR]
+"""
+
+import sys
+import os
+import json
+import subprocess
+import configparser
+
+import cairo as _cairo  # pycairo — needed for empty input region
+
+import gi
+gi.require_version('Gtk4LayerShell', '1.0')
+gi.require_version('Gtk', '4.0')
+from gi.repository import Gtk4LayerShell, Gtk, Gdk, Gio, GLib
+
+
+# --- KDE desktop config parsing ---
+
+def get_desktop_dir():
+    try:
+        result = subprocess.run(
+            ['xdg-user-dir', 'DESKTOP'], capture_output=True, text=True, timeout=3
+        )
+        if result.returncode == 0 and result.stdout.strip():
+            return result.stdout.strip()
+    except Exception:
+        pass
+    return os.path.expanduser('~/Desktop')
+
+
+def get_panel_offset():
+    """Detect KDE panel position and size to offset the icon grid."""
+    try:
+        output = subprocess.run(
+            ['qdbus', 'org.kde.plasmashell', '/PlasmaShell',
+             'org.kde.PlasmaShell.evaluateScript',
+             '''var p = panels();
+             for (var i = 0; i < p.length; i++)
+                 print(p[i].location + "," + p[i].height);'''],
+            capture_output=True, text=True, timeout=3
+        )
+        if output.returncode == 0:
+            for line in output.stdout.strip().split('\n'):
+                parts = line.strip().split(',')
+                if len(parts) == 2:
+                    loc, size = parts[0], int(parts[1])
+                    # location: 3=left, 4=right, 5=top, 6=bottom
+                    if loc == 'left':
+                        return size, 0, 0, 0  # left, right, top, bottom
+                    elif loc == 'right':
+                        return 0, size, 0, 0
+                    elif loc == 'top':
+                        return 0, 0, size, 0
+                    elif loc == 'bottom':
+                        return 0, 0, 0, size
+    except Exception:
+        pass
+    return 0, 0, 0, 0
+
+
+def parse_kde_positions(screen_key='2560x1440'):
+    """Parse icon positions from KDE's plasma desktop config."""
+    config_path = os.path.expanduser(
+        '~/.config/plasma-org.kde.plasma.desktop-appletsrc'
+    )
+    if not os.path.exists(config_path):
+        return {}, 1, 1
+
+    try:
+        cp = configparser.ConfigParser(strict=False)
+        cp.read(config_path)
+
+        for section in cp.sections():
+            if not section.endswith('][General]'):
+                continue
+            if not cp.has_option(section, 'positions'):
+                continue
+
+            positions_raw = cp.get(section, 'positions')
+            try:
+                positions_data = json.loads(positions_raw)
+            except json.JSONDecodeError:
+                continue
+
+            if screen_key not in positions_data:
+                for key in positions_data:
+                    screen_key = key
+                    break
+                else:
+                    continue
+
+            arr = positions_data[screen_key]
+            if len(arr) < 2:
+                continue
+
+            cols = int(arr[0])
+            rows = int(arr[1])
+            icons = {}
+
+            i = 2
+            while i + 2 < len(arr):
+                name = arr[i]
+                col = int(arr[i + 1])
+                row = int(arr[i + 2])
+                if name.startswith('desktop:/'):
+                    name = name[len('desktop:/'):]
+                icons[name] = (col, row)
+                i += 3
+
+            return icons, cols, rows
+
+    except Exception as e:
+        print(f'Error parsing KDE positions: {e}', file=sys.stderr)
+
+    return {}, 1, 1
+
+
+def get_desktop_entries(desktop_dir):
+    entries = []
+    if not os.path.isdir(desktop_dir):
+        return entries
+
+    for name in sorted(os.listdir(desktop_dir)):
+        if name.startswith('.'):
+            continue
+        full_path = os.path.join(desktop_dir, name)
+
+        if name.endswith('.desktop'):
+            entry = parse_desktop_file(full_path)
+            if entry:
+                entry['filename'] = name
+                entries.append(entry)
+        else:
+            entries.append({
+                'filename': name,
+                'name': name,
+                'icon_name': get_mime_icon(full_path),
+                'path': full_path,
+            })
+
+    return entries
+
+
+def parse_desktop_file(path):
+    try:
+        real_path = os.path.realpath(path)
+        kf = GLib.KeyFile()
+        kf.load_from_file(real_path, GLib.KeyFileFlags.NONE)
+
+        name = kf.get_locale_string('Desktop Entry', 'Name', None)
+        icon = kf.get_string('Desktop Entry', 'Icon')
+
+        return {
+            'name': name or os.path.basename(path),
+            'icon_name': icon or 'application-x-executable',
+            'path': path,
+        }
+    except Exception:
+        return None
+
+
+def get_mime_icon(path):
+    try:
+        f = Gio.File.new_for_path(path)
+        info = f.query_info('standard::icon', Gio.FileQueryInfoFlags.NONE, None)
+        icon = info.get_icon()
+        if isinstance(icon, Gio.ThemedIcon):
+            names = icon.get_names()
+            if names:
+                return names[0]
+    except Exception:
+        pass
+    return 'folder' if os.path.isdir(path) else 'text-x-generic'
+
+
+# --- KDE settings auto-detection ---
+
+# Kirigami icon size table (index → pixels), matching FolderTools.js
+_ICON_SIZE_TABLE = [22, 32, 48, 64, 96, 128, 256]
+
+# CSS font-weight mapping from KDE weight values (Qt::Weight)
+_KDE_WEIGHT_MAP = {
+    25: 200,   # Light
+    50: 400,   # Normal
+    57: 500,   # Medium
+    63: 600,   # DemiBold
+    75: 700,   # Bold
+    81: 800,   # ExtraBold
+    87: 900,   # Black
+}
+
+
+def detect_kde_settings():
+    """Auto-detect icon size, font, and label styling from KDE config.
+
+    Returns a dict with keys:
+        icon_size (int), font_family (str), font_size_pt (int),
+        font_weight (int CSS weight), font_italic (bool)
+    """
+    settings = {
+        'icon_size': 64,
+        'font_family': 'Noto Sans',
+        'font_size_pt': 11,
+        'font_weight': 400,
+        'font_italic': False,
+    }
+
+    # --- Icon size from Folder View config ---
+    try:
+        config_path = os.path.expanduser(
+            '~/.config/plasma-org.kde.plasma.desktop-appletsrc'
+        )
+        if os.path.exists(config_path):
+            cp = configparser.ConfigParser(strict=False)
+            cp.read(config_path)
+            # Find the desktop containment's General section
+            for section in cp.sections():
+                if 'General' not in section:
+                    continue
+                if cp.has_option(section, 'iconSize'):
+                    idx = int(cp.get(section, 'iconSize'))
+                    if 0 <= idx < len(_ICON_SIZE_TABLE):
+                        settings['icon_size'] = _ICON_SIZE_TABLE[idx]
+                    break
+    except Exception:
+        pass
+
+    # --- System font from kreadconfig6 ---
+    try:
+        result = subprocess.run(
+            ['kreadconfig6', '--file', 'kdeglobals',
+             '--group', 'General', '--key', 'font'],
+            capture_output=True, text=True, timeout=3
+        )
+        if result.returncode == 0 and result.stdout.strip():
+            # Format: family,pointSize,pixelSize,styleHint,weight,italic,...
+            parts = result.stdout.strip().split(',')
+            if len(parts) >= 6:
+                settings['font_family'] = parts[0]
+                try:
+                    settings['font_size_pt'] = int(parts[1])
+                except ValueError:
+                    pass
+                try:
+                    kde_weight = int(parts[4])
+                    # Find closest CSS weight
+                    closest = min(_KDE_WEIGHT_MAP.keys(),
+                                  key=lambda k: abs(k - kde_weight))
+                    settings['font_weight'] = _KDE_WEIGHT_MAP[closest]
+                except (ValueError, IndexError):
+                    pass
+                try:
+                    settings['font_italic'] = parts[5] == '1'
+                except IndexError:
+                    pass
+    except FileNotFoundError:
+        pass
+    except Exception:
+        pass
+
+    # KDE Folder View always renders desktop icon labels in italic
+    settings['font_italic'] = True
+
+    return settings
+
+
+# --- Layout ---
+
+_kde = detect_kde_settings()
+ICON_SIZE = _kde['icon_size']
+CELL_WIDTH = 120
+CELL_HEIGHT = 96
+# KDE Folder View internal padding (approx)
+GRID_MARGIN = 8
+
+
+def build_icon_widget(entry, icon_theme):
+    box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2)
+    box.set_halign(Gtk.Align.CENTER)
+    box.set_size_request(CELL_WIDTH, CELL_HEIGHT)
+
+    icon_name = entry.get('icon_name', 'application-x-executable')
+    image = Gtk.Image()
+    image.set_pixel_size(ICON_SIZE)
+
+    paintable = icon_theme.lookup_icon(
+        icon_name, None, ICON_SIZE, 1,
+        Gtk.TextDirection.NONE, Gtk.IconLookupFlags(0)
+    )
+    if paintable:
+        image.set_from_paintable(paintable)
+    else:
+        fallback = icon_theme.lookup_icon(
+            'application-x-executable', None, ICON_SIZE, 1,
+            Gtk.TextDirection.NONE, Gtk.IconLookupFlags(0)
+        )
+        if fallback:
+            image.set_from_paintable(fallback)
+
+    image.set_halign(Gtk.Align.CENTER)
+    box.append(image)
+
+    label = Gtk.Label(label=entry.get('name', ''))
+    label.set_max_width_chars(12)
+    label.set_ellipsize(3)  # PANGO_ELLIPSIZE_END
+    label.set_halign(Gtk.Align.CENTER)
+    label.set_justify(Gtk.Justification.CENTER)
+    label.set_wrap(True)
+    label.set_wrap_mode(2)  # PANGO_WRAP_WORD_CHAR
+    label.set_lines(2)
+    label.add_css_class('icon-label')
+    box.append(label)
+
+    return box
+
+
+def main():
+    monitor_name = sys.argv[1] if len(sys.argv) > 1 else None
+    desktop_dir = get_desktop_dir()
+    window = None
+    file_monitor = None
+    empty_region = _cairo.Region()
+
+    def apply_empty_input_region(widget, frame_clock):
+        """Tick callback: reapply empty input region every frame."""
+        surface = window.get_surface()
+        if surface:
+            surface.set_input_region(empty_region)
+        return True  # Keep the callback active
+
+    def on_activate(app):
+        nonlocal window, file_monitor
+
+        if window:
+            return
+
+        window = Gtk.Window()
+        window.set_application(app)
+
+        # Layer shell init BEFORE present
+        Gtk4LayerShell.init_for_window(window)
+        Gtk4LayerShell.set_layer(window, Gtk4LayerShell.Layer.BOTTOM)
+        Gtk4LayerShell.set_namespace(window, 'desktop')
+
+        # Target monitor
+        if monitor_name:
+            display = Gdk.Display.get_default()
+            monitors = display.get_monitors()
+            for i in range(monitors.get_n_items()):
+                mon = monitors.get_item(i)
+                if mon.get_connector() == monitor_name:
+                    Gtk4LayerShell.set_monitor(window, mon)
+                    break
+
+        # Fullscreen
+        for edge in (Gtk4LayerShell.Edge.TOP, Gtk4LayerShell.Edge.BOTTOM,
+                     Gtk4LayerShell.Edge.LEFT, Gtk4LayerShell.Edge.RIGHT):
+            Gtk4LayerShell.set_anchor(window, edge, True)
+
+        Gtk4LayerShell.set_exclusive_zone(window, -1)
+        Gtk4LayerShell.set_keyboard_mode(window, Gtk4LayerShell.KeyboardMode.NONE)
+
+        # CSS — use auto-detected font settings
+        font_style = 'italic' if _kde['font_italic'] else 'normal'
+        font_weight = _kde['font_weight']
+        css = Gtk.CssProvider()
+        css.load_from_string(f"""
+            window {{ background: transparent; }}
+            .icon-label {{
+                color: white;
+                font-family: "{_kde['font_family']}";
+                font-size: {_kde['font_size_pt']}pt;
+                font-weight: {font_weight};
+                font-style: {font_style};
+            }}
+        """)
+        Gtk.StyleContext.add_provider_for_display(
+            Gdk.Display.get_default(), css,
+            Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
+        )
+
+        build_icons()
+
+        # Watch ~/Desktop for changes
+        desktop_file = Gio.File.new_for_path(desktop_dir)
+        file_monitor = desktop_file.monitor_directory(
+            Gio.FileMonitorFlags.NONE, None
+        )
+        file_monitor.connect('changed', on_desktop_changed)
+
+        window.present()
+
+        # Reapply empty input region every frame so GTK4 can't override it
+        window.add_tick_callback(apply_empty_input_region)
+
+    def build_icons():
+        if not window:
+            return
+
+        # Get monitor resolution
+        display = Gdk.Display.get_default()
+        monitors = display.get_monitors()
+        screen_w, screen_h = 2560, 1440
+        for i in range(monitors.get_n_items()):
+            mon = monitors.get_item(i)
+            if monitor_name and mon.get_connector() != monitor_name:
+                continue
+            geo = mon.get_geometry()
+            screen_w = geo.width
+            screen_h = geo.height
+            break
+
+        # Get panel offset
+        panel_left, panel_right, panel_top, panel_bottom = get_panel_offset()
+
+        # Parse KDE positions
+        screen_key = f'{screen_w}x{screen_h}'
+        kde_positions, grid_cols, grid_rows = parse_kde_positions(screen_key)
+
+        # KDE Folder View uses a fixed cell size based on icon size (not screen/grid ratio).
+        # The grid_rows value tells us how many cells fit vertically.
+        # cell_h = available_height / grid_rows (typically ~62px for 48px icons)
+        avail_h = screen_h - panel_top - panel_bottom
+        cell_h = avail_h / max(grid_rows, 1)
+        # Cell width is the same fixed size regardless of column count
+        cell_w = CELL_WIDTH
+
+        entries = get_desktop_entries(desktop_dir)
+        if not entries:
+            window.set_child(Gtk.Box())
+            return
+
+        fixed = Gtk.Fixed()
+        icon_theme = Gtk.IconTheme.get_for_display(Gdk.Display.get_default())
+
+        for entry in entries:
+            filename = entry['filename']
+
+            if filename in kde_positions:
+                col, row = kde_positions[filename]
+            else:
+                col, row = 0, len(kde_positions)
+                kde_positions[filename] = (col, row)
+
+            # Top-left alignment within grid cell (matches KDE Folder View)
+            x = panel_left + GRID_MARGIN + col * cell_w
+            y = panel_top + GRID_MARGIN + row * cell_h
+
+            widget = build_icon_widget(entry, icon_theme)
+            fixed.put(widget, max(0, x), max(0, y))
+
+        window.set_child(fixed)
+
+    def on_desktop_changed(monitor, file, other_file, event_type):
+        if event_type in (
+            Gio.FileMonitorEvent.CREATED,
+            Gio.FileMonitorEvent.DELETED,
+            Gio.FileMonitorEvent.MOVED_IN,
+            Gio.FileMonitorEvent.MOVED_OUT,
+        ):
+            GLib.timeout_add(500, build_icons)
+
+    app = Gtk.Application(application_id='com.wemanager.desktop-icons-overlay')
+    app.connect('activate', on_activate)
+    app.run(None)
+
+
+if __name__ == '__main__':
+    main()

+ 133 - 0
src/main/services/desktop-icons.service.ts

@@ -0,0 +1,133 @@
+import { spawn, type ChildProcess } from 'child_process'
+import * as path from 'path'
+import * as fs from 'fs'
+import Store from 'electron-store'
+import { getConnectedScreens } from '../utils/platform'
+
+interface DesktopIconsStoreSchema {
+  desktopIconsEnabled: boolean
+}
+
+const store = new Store<DesktopIconsStoreSchema>({
+  defaults: { desktopIconsEnabled: false }
+})
+
+let overlayProcesses: ChildProcess[] = []
+
+function getOverlayScriptPath(): string {
+  // In dev: __dirname is out/main, project root is two levels up
+  // In prod: script is copied to resources/
+  const candidates = [
+    // Dev: source tree
+    path.join(__dirname, '..', '..', 'src', 'main', 'services', 'desktop-icons-overlay.py'),
+    // Prod: bundled in resources
+    path.join(process.resourcesPath ?? '', 'desktop-icons-overlay.py'),
+    // Fallback: next to compiled output
+    path.join(__dirname, 'desktop-icons-overlay.py')
+  ]
+  for (const c of candidates) {
+    if (fs.existsSync(c)) return c
+  }
+  return candidates[0]
+}
+
+function findLayerShellLib(): string {
+  const candidates = [
+    '/usr/lib64/libgtk4-layer-shell.so',
+    '/usr/lib/libgtk4-layer-shell.so',
+    '/usr/lib/x86_64-linux-gnu/libgtk4-layer-shell.so'
+  ]
+  return candidates.find((p) => fs.existsSync(p)) ?? candidates[0]
+}
+
+/** Build env vars for the overlay — Electron runs under XWayland so we must pass Wayland env explicitly. */
+function buildOverlayEnvVars(): string[] {
+  const vars: string[] = []
+
+  vars.push(`LD_PRELOAD=${findLayerShellLib()}`)
+
+  // Force GTK4 to use Wayland backend (otherwise it falls back to X11 where layer-shell doesn't work)
+  vars.push('GDK_BACKEND=wayland')
+
+  // Wayland display — Electron often lacks this since it runs under XWayland
+  const waylandDisplay = process.env.WAYLAND_DISPLAY
+    || (process.env.XDG_SESSION_TYPE === 'wayland' ? 'wayland-0' : undefined)
+  if (waylandDisplay) vars.push(`WAYLAND_DISPLAY=${waylandDisplay}`)
+
+  // XDG_RUNTIME_DIR is needed for the Wayland socket
+  const xdgRuntime = process.env.XDG_RUNTIME_DIR || `/run/user/${process.getuid?.() ?? 1000}`
+  vars.push(`XDG_RUNTIME_DIR=${xdgRuntime}`)
+
+  // Pass DISPLAY through for XWayland fallback
+  if (process.env.DISPLAY) vars.push(`DISPLAY=${process.env.DISPLAY}`)
+
+  return vars
+}
+
+export function startDesktopIconsOverlay(): void {
+  stopDesktopIconsOverlay()
+
+  const scriptPath = getOverlayScriptPath()
+  if (!fs.existsSync(scriptPath)) {
+    console.error('[DesktopIcons] Overlay script not found:', scriptPath)
+    return
+  }
+
+  const envVars = buildOverlayEnvVars()
+  const screens = getConnectedScreens()
+
+  // Spawn one overlay per screen
+  const targets = screens.length > 0 ? screens : [undefined]
+  for (const screen of targets) {
+    const args = ['python3', scriptPath]
+    if (screen) args.push(screen)
+
+    const child = spawn('env', [...envVars, ...args], {
+      stdio: 'ignore',
+      detached: true
+    })
+    child.on('exit', () => {
+      overlayProcesses = overlayProcesses.filter((p) => p !== child)
+    })
+    child.unref()
+    overlayProcesses.push(child)
+  }
+
+  console.log(`[DesktopIcons] Started ${targets.length} overlay(s)`)
+}
+
+export function stopDesktopIconsOverlay(): void {
+  for (const child of overlayProcesses) {
+    try {
+      child.kill('SIGTERM')
+    } catch {
+      /* already dead */
+    }
+  }
+  overlayProcesses = []
+}
+
+export function setDesktopIconsEnabled(enabled: boolean): void {
+  store.set('desktopIconsEnabled', enabled)
+  if (enabled) {
+    startDesktopIconsOverlay()
+  } else {
+    stopDesktopIconsOverlay()
+  }
+}
+
+export function getDesktopIconsEnabled(): boolean {
+  return store.get('desktopIconsEnabled', false)
+}
+
+/** Call on app startup to restore overlay if it was enabled. */
+export function initDesktopIcons(): void {
+  if (getDesktopIconsEnabled()) {
+    startDesktopIconsOverlay()
+  }
+}
+
+/** Call on app quit to clean up overlay processes. */
+export function cleanupDesktopIcons(): void {
+  stopDesktopIconsOverlay()
+}

+ 65 - 45
src/main/services/lwe.service.ts

@@ -17,6 +17,7 @@ const BUILD_DIR = path.join(os.tmpdir(), 'lwe-build')
 
 /** Common paths where linux-wallpaperengine may be installed outside $PATH. */
 const LWE_SEARCH_PATHS = [
+  '/home/uwu/projects/linux-wallpaperengine-fork/build/output/linux-wallpaperengine',
   '/usr/local/bin/linux-wallpaperengine',
   '/usr/local/linux-wallpaperengine',
   '/usr/bin/linux-wallpaperengine',
@@ -27,13 +28,9 @@ const LWE_SEARCH_PATHS = [
 
 let activeProcess: ChildProcess | null = null
 
-/** Find the LWE binary — first via $PATH, then by scanning common install locations. */
+/** Find the LWE binary — preferred paths first, then $PATH as fallback. */
 function findLweBinary(): string | undefined {
-  // 1. Check $PATH
-  const inPath = whichCommand(LWE_BINARY)
-  if (inPath) return inPath
-
-  // 2. Scan common locations
+  // 1. Scan preferred locations first (fork builds, custom installs)
   for (const p of LWE_SEARCH_PATHS) {
     try {
       fs.accessSync(p, fs.constants.X_OK)
@@ -41,16 +38,15 @@ function findLweBinary(): string | undefined {
     } catch { /* not here */ }
   }
 
+  // 2. Fall back to $PATH
+  const inPath = whichCommand(LWE_BINARY)
+  if (inPath) return inPath
+
   return undefined
 }
 
 export function getLweStatus(): LweStatus {
-  // Try $PATH first (fast, cached)
-  if (isCommandAvailable(LWE_BINARY)) {
-    return { installed: true, path: whichCommand(LWE_BINARY) }
-  }
-
-  // Scan common paths for existing installs not on $PATH
+  // Scan preferred paths first, then $PATH
   const found = findLweBinary()
   if (found) {
     return { installed: true, path: found }
@@ -411,15 +407,13 @@ function getLweLibDir(): string {
 /** LD_LIBRARY_PATH value so the loader finds LWE's .so files. */
 function getLweLdLibraryPath(): string {
   const binDir = getLweLibDir()
-  // IMPORTANT: Do NOT include binDir itself (e.g. /usr/local) because CEF installs its own
-  // libEGL.so / libGLESv2.so there which shadow the system Mesa EGL and break Wayland rendering.
-  // Only include proper lib subdirectories where LWE's own libs (libkissfft, etc.) live.
-  // Ensure lib64 comes first as it contains critical libraries like libkissfft
   const candidates = [
-    '/usr/local/lib64',  // Prioritize lib64 for critical LWE dependencies
-    '/usr/local/lib',
+    // For local/fork builds, the .so files live next to the binary
+    binDir,
     path.join(binDir, 'lib64'),
-    path.join(binDir, 'lib')
+    path.join(binDir, 'lib'),
+    '/usr/local/lib64',
+    '/usr/local/lib'
   ]
   // Only include paths that actually exist
   const paths = candidates.filter(p => {
@@ -465,33 +459,22 @@ function buildLweEnvVars(): string[] {
 
   vars.push(`LD_LIBRARY_PATH=${getLweLdLibraryPath()}`)
 
-  // CEF bundles its own libEGL.so alongside the LWE binary and the RUNPATH baked into the
-  // binary causes the dynamic linker to pick it up instead of Mesa's system EGL. CEF's EGL
-  // doesn't support eglGetPlatformDisplayEXT for Wayland, breaking --screen-root mode.
-  // Force the system EGL via LD_PRELOAD so Mesa's implementation is used.
-  const systemEglPaths = [
-    '/usr/lib64/libEGL.so.1',
-    '/usr/lib/libEGL.so.1',
-    '/lib64/libEGL.so.1',
-    '/usr/local/lib64/libEGL.so.1',
-    '/usr/local/lib/libEGL.so.1'
-  ]
-  // Detect Wayland display once and reuse
+  // CEF bundles its own libEGL.so alongside the LWE binary. LD_LIBRARY_PATH includes the
+  // binary's directory so CEF's EGL shadows Mesa's system EGL, breaking Wayland rendering.
+  // Force the system EGL via LD_PRELOAD so Mesa's implementation is always used.
+  const systemEgl = ['/lib64/libEGL.so.1', '/usr/lib64/libEGL.so.1', '/usr/lib/libEGL.so.1']
+    .find(p => fs.existsSync(p))
+  if (systemEgl) vars.push(`LD_PRELOAD=${systemEgl}`)
+
+  // Wayland display — Electron often runs under XWayland and lacks WAYLAND_DISPLAY.
   const waylandDisplay = process.env.WAYLAND_DISPLAY || getWaylandDisplay()
+  if (waylandDisplay) vars.push(`WAYLAND_DISPLAY=${waylandDisplay}`)
 
-  if (waylandDisplay) {
-    // For Wayland, use minimal environment to avoid EGL conflicts
-    vars.push(`WAYLAND_DISPLAY=${waylandDisplay}`)
-    vars.push('EGL_PLATFORM=wayland')
-  } else {
-    // X11 fallback - use LD_PRELOAD for EGL
-    const systemEgl = systemEglPaths.find(p => fs.existsSync(p))
-    if (systemEgl) {
-      vars.push(`LD_PRELOAD=${systemEgl}`)
-    }
-  }
+  // XDG_SESSION_TYPE is how LWE decides between X11 and Wayland drivers
+  const sessionType = process.env.XDG_SESSION_TYPE
+  if (sessionType) vars.push(`XDG_SESSION_TYPE=${sessionType}`)
 
-  // Ensure DISPLAY is passed (for X11 fallback)
+  // Ensure DISPLAY is passed (for X11/XWayland)
   if (process.env.DISPLAY) vars.push(`DISPLAY=${process.env.DISPLAY}`)
 
   // Ensure XDG_RUNTIME_DIR is set (needed for Wayland socket)
@@ -509,23 +492,51 @@ function buildLweArgs(
   const args: string[] = []
   const assetsDir = findWeAssetsDir()
   if (assetsDir) args.push('--assets-dir', assetsDir)
-  // --screen-root is required to render as desktop wallpaper (otherwise opens in a window)
+
+  // --screen-root uses layer-shell (Wayland) or root-window overlay (X11)
   if (options.screenRoot) {
     args.push('--screen-root', options.screenRoot)
   } else {
     const screens = getConnectedScreens()
     for (const s of screens) args.push('--screen-root', s)
   }
+
   if (options.fps) args.push('--fps', String(options.fps))
   args.push(wallpaperPath)
   return args
 }
 
+/** Get the control file path used for hot-reload signalling. */
+function getControlFilePath(): string {
+  const xdgRuntime = process.env.XDG_RUNTIME_DIR || `/run/user/${process.getuid?.() ?? 1000}`
+  return path.join(xdgRuntime, 'lwe-control')
+}
+
+/** Hot-reload: write new wallpaper path to control file and send SIGUSR1. */
+function hotReloadLwe(wallpaperPath: string): boolean {
+  if (!activeProcess || activeProcess.exitCode !== null) return false
+
+  try {
+    fs.writeFileSync(getControlFilePath(), wallpaperPath + '\n')
+    activeProcess.kill('SIGUSR1')
+    console.log('[LWE] Hot-reload signal sent for:', wallpaperPath)
+    return true
+  } catch (err) {
+    console.error('[LWE] Hot-reload failed:', err)
+    return false
+  }
+}
+
 /** Launch LWE and resolve after 2s if still running; reject if it exits with error (so UI can show message). */
 export function launchLweAsync(
   wallpaperPath: string,
   options: { screenRoot?: string; fps?: number } = {}
 ): Promise<void> {
+  // Hot-reload if already running
+  if (isLweRunning() && hotReloadLwe(wallpaperPath)) {
+    return Promise.resolve()
+  }
+
   stopLwe()
 
   const binaryPath = getLweBinaryPath()
@@ -535,14 +546,23 @@ export function launchLweAsync(
   // Spawn via `env` to set env vars explicitly — matches how LWE works from the terminal.
   // Using the env option on spawn can lose vars when combined with detached/setsid.
   const spawnArgs = [...envVars, binaryPath, ...lweArgs]
+  console.log('[LWE] Binary:', binaryPath)
+  console.log('[LWE] Args:', lweArgs.join(' '))
+  console.log('[LWE] Env:', envVars.join(' '))
   return new Promise((resolve, reject) => {
     const child = spawn('env', spawnArgs, {
-      stdio: ['ignore', 'ignore', 'pipe'],
+      stdio: ['ignore', 'pipe', 'pipe'],
       detached: true
     })
     activeProcess = child
 
     const stderrChunks: Buffer[] = []
+    if (child.stdout) {
+      child.stdout.on('data', (chunk: Buffer) => {
+        const msg = chunk.toString('utf8').trim()
+        if (msg) console.log('[LWE stdout]', msg)
+      })
+    }
     if (child.stderr) {
       child.stderr.on('data', (chunk: Buffer) => stderrChunks.push(chunk))
     }

+ 7 - 0
src/preload/index.ts

@@ -121,6 +121,13 @@ const api = {
       ipcRenderer.invoke(IpcChannels.LWE_STOP)
   },
 
+  desktopIcons: {
+    setEnabled: (enabled: boolean): Promise<{ ok: boolean; enabled: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.DESKTOP_ICONS_SET_ENABLED, enabled),
+    getEnabled: (): Promise<boolean> =>
+      ipcRenderer.invoke(IpcChannels.DESKTOP_ICONS_GET_ENABLED)
+  },
+
   shell: {
     openInFileManager: (folderPath: string): Promise<{ ok: boolean; error?: string }> =>
       ipcRenderer.invoke(IpcChannels.SHELL_OPEN_IN_FILE_MANAGER, folderPath),

+ 40 - 1
src/renderer/src/components/settings/SettingsView.tsx

@@ -1,5 +1,5 @@
 import { useState, useEffect } from 'react'
-import { FolderOpen, Save, Upload, Download, CheckCircle, XCircle, Loader2, Package, Trash2 } from 'lucide-react'
+import { FolderOpen, Save, Upload, Download, CheckCircle, XCircle, Loader2, Package, Trash2, Monitor } from 'lucide-react'
 import type { LweStatus, LweInstallProgress, LinuxDistro } from '../../../../shared/types'
 
 const DISTRO_LABELS: Record<LinuxDistro, string> = {
@@ -23,12 +23,16 @@ export default function SettingsView() {
   const [uninstalling, setUninstalling] = useState(false)
   const [uninstallMsg, setUninstallMsg] = useState<string | null>(null)
 
+  // Desktop icons overlay
+  const [desktopIconsEnabled, setDesktopIconsEnabled] = useState(false)
+
   useEffect(() => {
     window.electronAPI.config.get().then((cfg) => {
       setWorkshopPath(cfg.workshopPath ?? cfg.defaultWorkshopPath)
     })
     window.electronAPI.lwe.status().then(setLweStatus)
     window.electronAPI.lwe.detectDistro().then(setDistro)
+    window.electronAPI.desktopIcons.getEnabled().then(setDesktopIconsEnabled)
   }, [])
 
   // Listen for install progress events
@@ -117,6 +121,12 @@ export default function SettingsView() {
     }
   }
 
+  async function handleDesktopIconsToggle() {
+    const newVal = !desktopIconsEnabled
+    setDesktopIconsEnabled(newVal)
+    await window.electronAPI.desktopIcons.setEnabled(newVal)
+  }
+
   const isBusy = lweInstalling || depsInstalling || uninstalling
 
   return (
@@ -305,6 +315,35 @@ export default function SettingsView() {
             </div>
           )}
         </div>
+
+        {/* Desktop icons overlay */}
+        <div>
+          <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">
+            Desktop Icons
+          </label>
+          <p className="mt-1 text-xs text-gray-600">
+            Show desktop icons on top of the animated wallpaper. Uses a transparent overlay
+            that reads your KDE Folder View layout. Clicks pass through to the desktop underneath.
+          </p>
+          <label className="mt-3 flex items-center gap-3 cursor-pointer">
+            <button
+              onClick={handleDesktopIconsToggle}
+              className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
+                desktopIconsEnabled ? 'bg-indigo-600' : 'bg-white/10'
+              }`}
+            >
+              <span
+                className={`inline-block h-3.5 w-3.5 rounded-full bg-white transition-transform ${
+                  desktopIconsEnabled ? 'translate-x-4' : 'translate-x-0.5'
+                }`}
+              />
+            </button>
+            <span className="flex items-center gap-2 text-sm text-gray-300">
+              <Monitor size={16} />
+              {desktopIconsEnabled ? 'Desktop icons visible' : 'Desktop icons hidden'}
+            </span>
+          </label>
+        </div>
       </div>
     </div>
   )

+ 4 - 0
src/shared/ipc-channels.ts

@@ -63,6 +63,10 @@ export const IpcChannels = {
   LWE_LAUNCH: 'lwe:launch',
   LWE_STOP: 'lwe:stop',
 
+  // Desktop icons overlay
+  DESKTOP_ICONS_SET_ENABLED: 'desktop-icons:set-enabled',
+  DESKTOP_ICONS_GET_ENABLED: 'desktop-icons:get-enabled',
+
   // Events pushed from main to renderer
   EVENT_DOWNLOAD_PROGRESS: 'event:download-progress',
   EVENT_WALLPAPER_IMPORTED: 'event:wallpaper-imported',