Prechádzať zdrojové kódy

Merge adding-linux-wallpaperengine into master

Resolves all add/add conflicts by taking the linux-wallpaperengine branch
as the authoritative version, which adds LWE install/launch support,
desktop icons overlay, koffi FFI voting, folder cleanup, and various UI
improvements over the initial master commit.
UwU 4 mesiacov pred
rodič
commit
968647749f

Rozdielové dáta súboru neboli zobrazené, pretože súbor je príliš veľký
+ 1167 - 260
package-lock.json


+ 38 - 2
package.json

@@ -3,22 +3,29 @@
   "version": "0.1.0",
   "type": "module",
   "description": "Wallpaper Engine workshop manager",
+  "author": {
+    "name": "WE Manager",
+    "email": "developer@example.com"
+  },
   "main": "out/main/index.js",
   "scripts": {
     "dev": "electron-vite dev",
     "build": "electron-vite build",
     "preview": "electron-vite preview",
+    "dist": "npm run build && electron-builder",
+    "dist:linux": "npm run build && electron-builder --linux",
     "postinstall": "echo 'Dependencies installed'"
   },
   "dependencies": {
+    "@tanstack/react-query": "^5.59.0",
     "clsx": "^2.1.1",
     "electron-store": "^10.0.0",
+    "koffi": "^3.0.2",
     "lucide-react": "^0.460.0",
     "react": "^18.3.1",
     "react-dom": "^18.3.1",
     "steamworks.js": "^0.4.0",
-    "zustand": "^4.5.2",
-    "@tanstack/react-query": "^5.59.0"
+    "zustand": "^4.5.2"
   },
   "devDependencies": {
     "@electron-toolkit/preload": "^3.0.0",
@@ -29,10 +36,39 @@
     "@vitejs/plugin-react": "^4.3.1",
     "autoprefixer": "^10.4.20",
     "electron": "^33.0.0",
+    "electron-builder": "^26.7.0",
     "electron-vite": "^2.3.0",
     "postcss": "^8.4.47",
     "tailwindcss": "^3.4.14",
     "typescript": "^5.6.3",
     "vite": "^5.4.10"
+  },
+  "build": {
+    "appId": "com.we-manager.app",
+    "productName": "WE Manager",
+    "directories": {
+      "output": "dist"
+    },
+    "linux": {
+      "target": [
+        {
+          "target": "AppImage",
+          "arch": [
+            "x64"
+          ]
+        },
+        {
+          "target": "deb",
+          "arch": [
+            "x64"
+          ]
+        }
+      ],
+      "category": "Utility"
+    },
+    "files": [
+      "out/**/*",
+      "node_modules/**/*"
+    ]
   }
 }

+ 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()
+  })
+}

+ 5 - 0
src/main/ipc/folders.handlers.ts

@@ -31,4 +31,9 @@ export function registerFolderHandlers(): void {
   ipcMain.handle(IpcChannels.FOLDERS_IMPORT_WE_CONFIG, (_e, configPath: string) => {
     return lib.importWEConfig(configPath)
   })
+
+  ipcMain.handle(IpcChannels.FOLDERS_CLEANUP, () => {
+    const removed = lib.cleanupFolders()
+    return { removed }
+  })
 }

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

@@ -6,6 +6,8 @@ import { registerWallpaperHandlers } from './wallpaper.handlers'
 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)
@@ -15,4 +17,6 @@ export function registerAllHandlers(win: BrowserWindow): void {
   registerConfigHandlers()
   registerFolderHandlers()
   registerShellHandlers()
+  registerLweHandlers(win)
+  registerDesktopIconsHandlers()
 }

+ 41 - 0
src/main/ipc/lwe.handlers.ts

@@ -0,0 +1,41 @@
+import { ipcMain, type BrowserWindow } from 'electron'
+import { IpcChannels } from '@shared/ipc-channels'
+import { getLweStatus, detectDistro, installLweDeps, installLwe, uninstallLwe, launchLwe, stopLwe, isLweRunning } from '../services/lwe.service'
+import { invalidateEnvCache } from '../services/wallpaper.service'
+
+export function registerLweHandlers(win: BrowserWindow): void {
+  ipcMain.handle(IpcChannels.LWE_STATUS, () => {
+    return getLweStatus()
+  })
+
+  ipcMain.handle(IpcChannels.LWE_DETECT_DISTRO, () => {
+    return detectDistro()
+  })
+
+  ipcMain.handle(IpcChannels.LWE_INSTALL_DEPS, async () => {
+    await installLweDeps(win)
+    return { ok: true }
+  })
+
+  ipcMain.handle(IpcChannels.LWE_INSTALL, async () => {
+    await installLwe(win)
+    invalidateEnvCache()
+    return getLweStatus()
+  })
+
+  ipcMain.handle(IpcChannels.LWE_UNINSTALL, async () => {
+    const result = await uninstallLwe()
+    invalidateEnvCache()
+    return result
+  })
+
+  ipcMain.handle(IpcChannels.LWE_LAUNCH, (_e, wallpaperPath: string, options?: { screenRoot?: string; fps?: number }) => {
+    launchLwe(wallpaperPath, options)
+    return { ok: true, running: isLweRunning() }
+  })
+
+  ipcMain.handle(IpcChannels.LWE_STOP, () => {
+    stopLwe()
+    return { ok: true }
+  })
+}

+ 10 - 0
src/main/ipc/shell.handlers.ts

@@ -26,4 +26,14 @@ export function registerShellHandlers(): void {
     const result = await shell.openPath(resolved)
     return { ok: !result, error: result || undefined }
   })
+
+  // Open a URL or protocol link (e.g. steam://, https://) with the OS handler
+  ipcMain.handle(IpcChannels.SHELL_OPEN_EXTERNAL, async (_e, url: string) => {
+    try {
+      await shell.openExternal(url)
+      return { ok: true }
+    } catch (err) {
+      return { ok: false, error: (err as Error).message }
+    }
+  })
 }

+ 18 - 2
src/main/ipc/steam.handlers.ts

@@ -1,6 +1,8 @@
 import { ipcMain, BrowserWindow } from 'electron'
+import * as fs from 'fs'
 import { IpcChannels } from '@shared/ipc-channels'
 import * as steam from '../services/steam.service'
+import * as library from '../services/library.service'
 
 export function registerSteamHandlers(win: BrowserWindow): void {
   ipcMain.handle(IpcChannels.STEAM_IS_RUNNING, () => {
@@ -31,15 +33,29 @@ export function registerSteamHandlers(win: BrowserWindow): void {
   })
 
   ipcMain.handle(IpcChannels.STEAM_UNSUBSCRIBE, async (_e, itemId: string) => {
+    const wallpaper = library.getWallpaper(itemId)
     await steam.unsubscribeFromItem(BigInt(itemId))
+    if (wallpaper?.localPath) {
+      try { fs.rmSync(wallpaper.localPath, { recursive: true, force: true }) } catch { /* ignore */ }
+    }
+    library.deleteWallpaper(itemId)
     return { ok: true }
   })
 
-  ipcMain.handle(IpcChannels.STEAM_VOTE, async (_e, itemId: string, voteUp: boolean) => {
-    await steam.voteItem(BigInt(itemId), voteUp)
+  ipcMain.handle(IpcChannels.STEAM_VOTE, (_e, itemId: string, voteUp: boolean) => {
+    steam.voteOnItem(BigInt(itemId), voteUp)
     return { ok: true }
   })
 
+  ipcMain.handle(IpcChannels.STEAM_OPEN_WORKSHOP, (_e, itemId: string) => {
+    steam.openWorkshopItemOverlay(BigInt(itemId))
+    return { ok: true }
+  })
+
+  ipcMain.handle(IpcChannels.STEAM_GET_VOTED_IDS, async () => {
+    return steam.getVotedUpItemIds()
+  })
+
   ipcMain.handle(IpcChannels.STEAM_GET_SUBSCRIBED, () => {
     return steam.getSubscribedItems()
   })

+ 19 - 2
src/main/ipc/wallpaper.handlers.ts

@@ -2,8 +2,10 @@ import { ipcMain } from 'electron'
 import { IpcChannels } from '@shared/ipc-channels'
 import { applyWallpaper, detectEnvironment } from '../services/wallpaper.service'
 import { getWallpaper, updateWallpaper } from '../services/library.service'
+import { getLweStatus, launchLweAsync } from '../services/lwe.service'
 import type { ApplyWallpaperOptions } from '@shared/types'
 import Store from 'electron-store'
+import * as os from 'os'
 
 interface StoreSchema {
   activeWallpaperId?: string
@@ -27,11 +29,26 @@ export function registerWallpaperHandlers(): void {
     if (!wallpaper) throw new Error(`Wallpaper ${options.wallpaperId} not found in library`)
     if (!wallpaper.localPath) throw new Error(`Wallpaper ${options.wallpaperId} has no local path`)
 
-    // Find an image file in the wallpaper directory
+    const isAnimated = wallpaper.type === 'scene' || wallpaper.type === 'web' || wallpaper.type === 'video'
+    const lwe = getLweStatus()
+
+    // Use linux-wallpaperengine for animated wallpapers on Linux if available
+    if (isAnimated && os.platform() === 'linux' && lwe.installed) {
+      await launchLweAsync(wallpaper.localPath)
+
+      store.set('activeWallpaperId', options.wallpaperId)
+      updateWallpaper(options.wallpaperId, {
+        appliedCount: (wallpaper.appliedCount ?? 0) + 1,
+        lastAppliedAt: Date.now()
+      })
+
+      return { ok: true, appliedPath: wallpaper.localPath }
+    }
+
+    // Static wallpaper fallback — find an image file
     const imagePath = await findWallpaperImage(wallpaper.localPath)
     await applyWallpaper(imagePath, options.backend)
 
-    // Update metadata
     store.set('activeWallpaperId', options.wallpaperId)
     updateWallpaper(options.wallpaperId, {
       appliedCount: (wallpaper.appliedCount ?? 0) + 1,

+ 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()
+}

+ 92 - 12
src/main/services/library.service.ts

@@ -168,6 +168,21 @@ function normalizeRating(raw?: string): ContentRating {
   }
 }
 
+function getDirSize(dirPath: string): number {
+  let total = 0
+  try {
+    for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
+      const full = path.join(dirPath, entry.name)
+      if (entry.isDirectory()) {
+        total += getDirSize(full)
+      } else if (entry.isFile()) {
+        try { total += fs.statSync(full).size } catch { /* skip */ }
+      }
+    }
+  } catch { /* skip unreadable dirs */ }
+  return total
+}
+
 function buildMeta(
   workshopId: string,
   localPath: string,
@@ -175,8 +190,9 @@ function buildMeta(
   existing?: WallpaperMeta
 ): WallpaperMeta {
   const now = Date.now()
+  const { downloading: _d, ...existingRest } = existing ?? { appliedCount: 0, categories: [] }
   return {
-    ...(existing ?? { appliedCount: 0, categories: [] }),
+    ...existingRest,
     id: workshopId,
     title: pj?.title ?? `Wallpaper ${workshopId}`,
     type: normalizeType(pj?.type),
@@ -186,6 +202,7 @@ function buildMeta(
     previewUrl: undefined,
     localPath,
     file: pj?.file,
+    fileSize: getDirSize(localPath),
     createdAt: existing?.createdAt ?? now,
     updatedAt: now,
     subscribed: true,
@@ -203,41 +220,80 @@ export function importWallpaperById(workshopId: string): WallpaperMeta | null {
   return meta
 }
 
-export function scanLibrary(): { imported: number; skipped: number } {
+export function scanLibrary(): { imported: number; skipped: number; removed: number } {
   const workshopPath = getWorkshopPath()
-  if (!fs.existsSync(workshopPath)) return { imported: 0, skipped: 0 }
+  if (!fs.existsSync(workshopPath)) return { imported: 0, skipped: 0, removed: 0 }
 
   const entries = fs.readdirSync(workshopPath, { withFileTypes: true })
+  const onDisk = new Set(entries.filter(e => e.isDirectory()).map(e => e.name))
 
   // Load entire store once, mutate in memory, write once at the end
   const wallpapers = store.get('wallpapers')
   let imported = 0
   let skipped = 0
+  let removed = 0
+
+  // Remove wallpapers whose directories no longer exist on disk
+  for (const id of Object.keys(wallpapers)) {
+    if (!onDisk.has(id)) {
+      delete wallpapers[id]
+      removed++
+    }
+  }
 
   for (const entry of entries) {
     if (!entry.isDirectory()) continue
 
+    const localPath = path.join(workshopPath, entry.name)
+    const pj = readProjectJson(localPath)
+
+    if (!pj) {
+      // No project.json — directory exists but download is incomplete
+      if (!wallpapers[entry.name]) {
+        const now = Date.now()
+        wallpapers[entry.name] = {
+          id: entry.name,
+          title: `Wallpaper ${entry.name}`,
+          type: 'scene',
+          contentRating: 'everyone',
+          localPath,
+          createdAt: now,
+          updatedAt: now,
+          subscribed: true,
+          appliedCount: 0,
+          source: 'workshop',
+          tags: [],
+          categories: [],
+          downloading: true
+        }
+        imported++
+      } else {
+        skipped++
+      }
+      continue
+    }
+
     // Skip already-imported entries that have all required fields (cache hit)
     const existing = wallpapers[entry.name]
-    if (existing?.previewLocal !== undefined && existing?.contentRating !== undefined) {
+    if (!existing?.downloading && existing?.previewLocal !== undefined && existing?.contentRating !== undefined && existing?.fileSize !== undefined) {
       skipped++
       continue
     }
 
-    const localPath = path.join(workshopPath, entry.name)
-    const pj = readProjectJson(localPath)
-    if (!pj) { skipped++; continue }
-
-    wallpapers[entry.name] = buildMeta(entry.name, localPath, pj)
+    wallpapers[entry.name] = buildMeta(entry.name, localPath, pj, existing ?? undefined)
     imported++
   }
 
-  if (imported > 0) {
+  if (imported > 0 || removed > 0) {
     store.set('wallpapers', wallpapers) // single write
   }
 
-  console.log(`[Library] Scan complete: ${imported} imported, ${skipped} skipped`)
-  return { imported, skipped }
+  console.log(`[Library] Scan complete: ${imported} imported, ${skipped} skipped, ${removed} removed`)
+
+  // Auto-cleanup folder items that reference non-existent wallpapers
+  cleanupFolders()
+
+  return { imported, skipped, removed }
 }
 
 // ── Folder CRUD ──
@@ -289,6 +345,30 @@ export function removeItemsFromFolder(folderId: string, itemIds: string[]): Wall
   return folder
 }
 
+/**
+ * Remove folder item IDs that don't exist in the wallpapers store.
+ * Returns the number of stale IDs removed.
+ */
+export function cleanupFolders(): number {
+  const wallpapers = store.get('wallpapers')
+  const validIds = new Set(Object.keys(wallpapers))
+  const folders = store.get('folders')
+  let removed = 0
+
+  for (const folder of folders) {
+    const before = folder.items.length
+    folder.items = folder.items.filter((id) => validIds.has(id))
+    removed += before - folder.items.length
+  }
+
+  if (removed > 0) {
+    store.set('folders', folders)
+    console.log(`[Library] Folder cleanup: removed ${removed} stale item(s)`)
+  }
+
+  return removed
+}
+
 export function importWEConfig(configPath: string): { folders: number; playlists: number } {
   const raw = fs.readFileSync(configPath, 'utf8')
   const data = JSON.parse(raw)

+ 638 - 0
src/main/services/lwe.service.ts

@@ -0,0 +1,638 @@
+import { execFile, execFileSync, spawn, type ChildProcess } from 'child_process'
+import { promisify } from 'util'
+import * as fs from 'fs'
+import * as path from 'path'
+import * as os from 'os'
+import type { BrowserWindow } from 'electron'
+import { IpcChannels } from '@shared/ipc-channels'
+import type { LweStatus, LweInstallProgress, LinuxDistro } from '@shared/types'
+import { isCommandAvailable, invalidateCommandCache, whichCommand, getConnectedScreens } from '../utils/platform'
+import { getWorkshopPath } from '../utils/paths'
+
+const execFileAsync = promisify(execFile)
+
+const LWE_BINARY = 'linux-wallpaperengine'
+const LWE_REPO = 'https://github.com/Almamu/linux-wallpaperengine.git'
+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',
+  path.join(os.homedir(), '.local', 'bin', 'linux-wallpaperengine'),
+  path.join(os.homedir(), 'bin', 'linux-wallpaperengine'),
+  '/opt/linux-wallpaperengine/linux-wallpaperengine'
+]
+
+let activeProcess: ChildProcess | null = null
+
+/** Find the LWE binary — preferred paths first, then $PATH as fallback. */
+function findLweBinary(): string | undefined {
+  // 1. Scan preferred locations first (fork builds, custom installs)
+  for (const p of LWE_SEARCH_PATHS) {
+    try {
+      fs.accessSync(p, fs.constants.X_OK)
+      return p
+    } catch { /* not here */ }
+  }
+
+  // 2. Fall back to $PATH
+  const inPath = whichCommand(LWE_BINARY)
+  if (inPath) return inPath
+
+  return undefined
+}
+
+export function getLweStatus(): LweStatus {
+  // Scan preferred paths first, then $PATH
+  const found = findLweBinary()
+  if (found) {
+    return { installed: true, path: found }
+  }
+
+  return { installed: false }
+}
+
+export function detectDistro(): LinuxDistro {
+  try {
+    const release = fs.readFileSync('/etc/os-release', 'utf8').toLowerCase()
+    if (release.includes('id=fedora') || release.includes('id=nobara') || release.includes('id=rhel') || release.includes('id=centos')) return 'fedora'
+    if (release.includes('id=arch') || release.includes('id=manjaro') || release.includes('id=endeavouros')) return 'arch'
+    if (release.includes('id=debian') || release.includes('id=ubuntu') || release.includes('id=linuxmint') || release.includes('id=pop')) return 'debian'
+  } catch { /* no os-release */ }
+  return 'unknown'
+}
+
+// Aligned with https://github.com/Almamu/linux-wallpaperengine (README + Wayland + FBOProvider gmpxx)
+const DEPS_DEBIAN = [
+  'build-essential', 'cmake', 'pkg-config',
+  'libxrandr-dev', 'libxinerama-dev', 'libxcursor-dev', 'libxi-dev',
+  'libgl-dev', 'libglew-dev', 'freeglut3-dev', 'libsdl2-dev',
+  'liblz4-dev', 'libavcodec-dev', 'libavformat-dev', 'libavutil-dev', 'libswscale-dev',
+  'libxxf86vm-dev', 'libglm-dev', 'libglfw3-dev',
+  'libmpv-dev', 'mpv', 'libpulse-dev', 'libpulse0', 'libfftw3-dev',
+  'libwayland-dev', 'wayland-protocols', 'libegl1-mesa-dev',
+  'libgmp-dev', 'patchelf'
+]
+
+// Fedora/Nobara: do not add 'ffmpeg' — conflicts with ffmpeg-free; use ffmpeg-free-devel only
+const DEPS_FEDORA = [
+  'gcc', 'g++', 'cmake', 'pkg-config',
+  'libXrandr-devel', 'libXinerama-devel', 'libXcursor-devel', 'libXi-devel',
+  'mesa-libGL-devel', 'glew-devel', 'freeglut-devel', 'SDL2-devel',
+  'lz4-devel', 'ffmpeg-free-devel',
+  'libXxf86vm-devel', 'glm-devel', 'glfw-devel',
+  'mpv-devel', 'pulseaudio-libs-devel', 'fftw-devel',
+  'wayland-devel', 'wayland-protocols-devel', 'mesa-libEGL-devel',
+  'gmp-devel', 'patchelf'
+]
+
+const DEPS_ARCH = [
+  'base-devel', 'cmake', 'pkg-config',
+  'glew', 'freeglut', 'sdl2', 'lz4', 'ffmpeg',
+  'glm', 'glfw', 'mpv', 'libpulse', 'fftw',
+  'libxrandr', 'libxinerama', 'libxcursor', 'libxi', 'libxxf86vm',
+  'wayland', 'wayland-protocols', 'mesa',
+  'gmp', 'patchelf'
+]
+
+export async function installLweDeps(win: BrowserWindow): Promise<void> {
+  const send = (progress: LweInstallProgress) => {
+    win.webContents.send(IpcChannels.EVENT_LWE_INSTALL_PROGRESS, progress)
+  }
+
+  const distro = detectDistro()
+  if (distro === 'unknown') {
+    send({
+      stage: 'error',
+      message: 'Could not detect your Linux distribution. Please install build dependencies manually.',
+      percentage: 0
+    })
+    return
+  }
+
+  let pkgManager: string
+  let installArgs: string[]
+  let packages: string[]
+
+  switch (distro) {
+    case 'debian':
+      pkgManager = 'apt-get'
+      installArgs = ['install', '-y']
+      packages = DEPS_DEBIAN
+      break
+    case 'fedora':
+      pkgManager = 'dnf'
+      // --skip-unavailable: avoid failing on missing/broken repos (e.g. Cursor 403) or package renames
+      installArgs = ['install', '-y', '--skip-unavailable']
+      packages = DEPS_FEDORA
+      break
+    case 'arch':
+      pkgManager = 'pacman'
+      installArgs = ['-S', '--noconfirm', '--needed']
+      packages = DEPS_ARCH
+      break
+  }
+
+  const elevate = isCommandAvailable('pkexec') ? 'pkexec' : 'sudo'
+
+  send({
+    stage: 'installing-deps',
+    message: `Installing ${packages.length} packages via ${pkgManager} (sudo required)...`,
+    percentage: 10
+  })
+
+  try {
+    await execFileAsync(elevate, [pkgManager, ...installArgs, ...packages], {
+      timeout: 600_000
+    })
+    send({
+      stage: 'done',
+      message: 'Build dependencies installed successfully!',
+      percentage: 100
+    })
+  } catch (err) {
+    send({
+      stage: 'error',
+      message: `Failed to install dependencies: ${(err as Error).message}`,
+      percentage: 0
+    })
+  }
+}
+
+export async function installLwe(win: BrowserWindow): Promise<void> {
+  const send = (progress: LweInstallProgress) => {
+    win.webContents.send(IpcChannels.EVENT_LWE_INSTALL_PROGRESS, progress)
+  }
+
+  try {
+    send({ stage: 'cloning', message: 'Checking build dependencies...', percentage: 0 })
+
+    const missingDeps: string[] = []
+    for (const dep of ['git', 'cmake', 'make', 'pkg-config']) {
+      if (!isCommandAvailable(dep)) missingDeps.push(dep)
+    }
+
+    if (missingDeps.length > 0) {
+      send({
+        stage: 'error',
+        message: `Missing build dependencies: ${missingDeps.join(', ')}. Please install them first.`,
+        percentage: 0
+      })
+      return
+    }
+
+    // Clean previous build
+    if (fs.existsSync(BUILD_DIR)) {
+      fs.rmSync(BUILD_DIR, { recursive: true, force: true })
+    }
+
+    // Clone
+    send({ stage: 'cloning', message: 'Cloning linux-wallpaperengine...', percentage: 5 })
+    await execFileAsync('git', ['clone', '--depth', '1', '--recursive', LWE_REPO, BUILD_DIR], {
+      timeout: 120_000,
+      maxBuffer: 10 * 1024 * 1024
+    })
+
+    // Create build dir
+    const cmakeBuild = path.join(BUILD_DIR, 'build')
+    fs.mkdirSync(cmakeBuild, { recursive: true })
+
+    // CMake configure — CEF download can take several minutes; install to /usr/local and symlink into PATH
+    send({ stage: 'building', message: 'Running cmake (downloading CEF if needed, may take a few minutes)...', percentage: 25 })
+    await execFileAsync('cmake', [
+      '..',
+      '-DCMAKE_BUILD_TYPE=Release',
+      '-DCMAKE_INSTALL_PREFIX=/usr/local',
+      '-DCMAKE_C_FLAGS=-Wno-error',
+      '-DCMAKE_CXX_FLAGS=-Wno-error'
+    ], {
+      cwd: cmakeBuild,
+      timeout: 600_000,
+      maxBuffer: 10 * 1024 * 1024,
+      encoding: 'utf8'
+    })
+
+    // Build
+    send({ stage: 'building', message: 'Compiling (this may take a few minutes)...', percentage: 40 })
+    const cores = Math.max(1, os.cpus().length - 1)
+    await execFileAsync('make', ['-j', String(cores)], {
+      cwd: cmakeBuild,
+      timeout: 600_000,
+      maxBuffer: 10 * 1024 * 1024,
+      encoding: 'utf8'
+    })
+
+    send({ stage: 'building', message: 'Build complete.', percentage: 80 })
+
+    // Install — needs sudo via graphical prompt (binary ends up at /usr/local/linux-wallpaperengine)
+    send({ stage: 'installing', message: 'Installing (sudo required)...', percentage: 85 })
+
+    const installTool = isCommandAvailable('pkexec') ? 'pkexec' : 'sudo'
+    // pkexec often runs the command in a different cwd (e.g. / or root's home), so run install
+    // inside a shell that cd's to the build dir first; PATH preserved for make
+    const pathEnv = process.env.PATH ?? '/usr/local/bin:/usr/bin:/bin'
+    // After install, fix RUNPATH to avoid CEF's libEGL.so shadowing the system Mesa EGL,
+    // and run ldconfig so the linker cache picks up the new shared libs.
+    const installScript = `cd ${JSON.stringify(cmakeBuild)} && export PATH=${JSON.stringify(pathEnv)} && make install && patchelf --set-rpath /usr/local/lib64:/usr/local/lib /usr/local/linux-wallpaperengine 2>/dev/null; ldconfig`
+    await execFileAsync(installTool, ['bash', '-c', installScript], {
+      timeout: 120_000,
+      maxBuffer: 2 * 1024 * 1024,
+      encoding: 'utf8'
+    })
+
+    // Symlink binary into PATH so `linux-wallpaperengine` is found
+    const binaryPath = '/usr/local/linux-wallpaperengine'
+    const linkPath = '/usr/local/bin/linux-wallpaperengine'
+    try {
+      await execFileAsync(installTool, ['ln', '-sf', binaryPath, linkPath], {
+        timeout: 10_000,
+        encoding: 'utf8',
+        maxBuffer: 65536
+      })
+    } catch {
+      // Non-fatal: user may already have it in PATH or can add /usr/local to PATH
+    }
+
+    // Cleanup build dir
+    fs.rmSync(BUILD_DIR, { recursive: true, force: true })
+
+    // Invalidate cache so next status check re-detects
+    invalidateCommandCache(LWE_BINARY)
+
+    const status = getLweStatus()
+    if (status.installed) {
+      send({ stage: 'done', message: 'linux-wallpaperengine installed successfully!', percentage: 100 })
+    } else {
+      send({
+        stage: 'error',
+        message: 'Build completed but binary not found in PATH. You may need to add /usr/local/bin to your PATH.',
+        percentage: 100
+      })
+    }
+  } catch (err) {
+    try {
+      if (fs.existsSync(BUILD_DIR)) {
+        fs.rmSync(BUILD_DIR, { recursive: true, force: true })
+      }
+    } catch { /* ignore cleanup errors */ }
+
+    // Capture both stdout and stderr (Node provides these on execFile error when encoding/maxBuffer set)
+    const e = err as Error & { stdout?: string; stderr?: string }
+    const fullMsg = e.message ?? String(err)
+    const combined = [e.stdout, e.stderr].filter(Boolean).join('\n')
+    const lines = (combined || fullMsg).split('\n')
+
+    // 1. Real compiler/linker errors (highest priority)
+    const compilerErrors = lines.filter(
+      (l: string) => /:\d+:\d+: (?:fatal )?error:/i.test(l) || /^\/.*error:/i.test(l) ||
+        /undefined reference/i.test(l) || /ld returned/i.test(l) ||
+        /collect2: error/i.test(l)
+    )
+
+    // 2. CMake / install / permission errors
+    const otherErrors = lines.filter(
+      (l: string) => /CMake Error/i.test(l) || /No such file or directory/i.test(l) ||
+        /Permission denied/i.test(l) || /cannot create/i.test(l) ||
+        /fatal:/i.test(l) || /failed to/i.test(l)
+    )
+
+    let excerpt: string
+    if (compilerErrors.length > 0) {
+      // Show the first few real compiler errors (most useful)
+      excerpt = compilerErrors.slice(0, 10).join('\n')
+    } else if (otherErrors.length > 0) {
+      excerpt = otherErrors.slice(0, 8).join('\n')
+    } else {
+      // Fallback: show last 30 lines (skip empty/make-summary-only lines)
+      excerpt = lines.filter((l: string) => l.trim() && !/^make\[\d+\]: (Entering|Leaving)/i.test(l))
+        .slice(-30).join('\n')
+    }
+
+    send({
+      stage: 'error',
+      message: `Build failed:\n${excerpt.trim() || fullMsg}`,
+      percentage: 0
+    })
+  }
+}
+
+export async function uninstallLwe(): Promise<{ ok: boolean; message: string }> {
+  const status = getLweStatus()
+  if (!status.installed || !status.path) {
+    return { ok: false, message: 'linux-wallpaperengine is not installed.' }
+  }
+
+  // Stop any running instance first
+  stopLwe()
+
+  const elevate = isCommandAvailable('pkexec') ? 'pkexec' : 'sudo'
+  const binaryPath = status.path
+
+  // Resolve symlink to find the real binary location
+  let realPath: string
+  try {
+    realPath = fs.realpathSync(binaryPath)
+  } catch {
+    realPath = binaryPath
+  }
+
+  // Collect all files to remove (binary, symlink if different, known lib dir)
+  const filesToRemove: string[] = [realPath]
+  if (realPath !== binaryPath) filesToRemove.push(binaryPath)
+
+  // Check for LWE lib directory next to the real binary (cmake install puts libs there)
+  const binDir = path.dirname(realPath)
+  const libDir = path.join(binDir, 'lib')
+  if (fs.existsSync(libDir)) {
+    // Only remove if it looks like it belongs to LWE (contains libcef or similar)
+    try {
+      const entries = fs.readdirSync(libDir)
+      if (entries.some(e => e.includes('cef') || e.includes('wallpaper'))) {
+        filesToRemove.push(libDir)
+      }
+    } catch { /* ignore */ }
+  }
+
+  try {
+    await execFileAsync(elevate, ['rm', '-rf', ...filesToRemove], {
+      timeout: 30_000,
+      encoding: 'utf8'
+    })
+
+    // Invalidate caches
+    invalidateCommandCache(LWE_BINARY)
+
+    return { ok: true, message: 'linux-wallpaperengine has been uninstalled.' }
+  } catch (err) {
+    return { ok: false, message: `Uninstall failed: ${(err as Error).message}` }
+  }
+}
+
+/** Find the Wallpaper Engine assets directory from the Steam install. */
+function findWeAssetsDir(): string | undefined {
+  const workshopPath = getWorkshopPath()
+  // Workshop path looks like: .../steamapps/workshop/content/431960
+  // Assets are at:            .../steamapps/common/wallpaper_engine/assets
+  const steamappsIdx = workshopPath.lastIndexOf(path.join('steamapps', 'workshop'))
+  if (steamappsIdx !== -1) {
+    const steamappsRoot = workshopPath.substring(0, steamappsIdx + 'steamapps'.length)
+    const assetsDir = path.join(steamappsRoot, 'common', 'wallpaper_engine', 'assets')
+    if (fs.existsSync(assetsDir)) return assetsDir
+  }
+
+  // Fallback: scan common Steam locations
+  const home = os.homedir()
+  const candidates = [
+    path.join(home, '.steam', 'steam', 'steamapps', 'common', 'wallpaper_engine', 'assets'),
+    path.join(home, '.local', 'share', 'Steam', 'steamapps', 'common', 'wallpaper_engine', 'assets')
+  ]
+  return candidates.find(p => fs.existsSync(p))
+}
+
+/** Directory containing the LWE binary; used for LD_LIBRARY_PATH. */
+function getLweLibDir(): string {
+  const p = findLweBinary()
+  if (!p) return '/usr/local'
+  try {
+    const resolved = fs.realpathSync(p)
+    return path.dirname(resolved)
+  } catch {
+    return path.dirname(p)
+  }
+}
+
+/** LD_LIBRARY_PATH value so the loader finds LWE's .so files. */
+function getLweLdLibraryPath(): string {
+  const binDir = getLweLibDir()
+  const candidates = [
+    // For local/fork builds, the .so files live next to the binary
+    binDir,
+    path.join(binDir, 'lib64'),
+    path.join(binDir, 'lib'),
+    '/usr/local/lib64',
+    '/usr/local/lib'
+  ]
+  // Only include paths that actually exist
+  const paths = candidates.filter(p => {
+    try { return fs.statSync(p).isDirectory() } catch { return false }
+  })
+  const existing = process.env.LD_LIBRARY_PATH
+  if (existing) paths.push(existing)
+  return paths.join(':')
+}
+
+/** Full path to LWE binary (resolved symlink). */
+function getLweBinaryPath(): string {
+  const p = findLweBinary()
+  if (!p) return LWE_BINARY
+  try {
+    return fs.realpathSync(p)
+  } catch {
+    return p
+  }
+}
+
+/** Detect Wayland display from the session if Electron doesn't have it (Electron often runs under XWayland). */
+function getWaylandDisplay(): string | undefined {
+  if (process.env.WAYLAND_DISPLAY) return process.env.WAYLAND_DISPLAY
+
+  // Check XDG_SESSION_TYPE to see if we're in a Wayland session
+  if (process.env.XDG_SESSION_TYPE === 'wayland') return 'wayland-0'
+
+  // Try to detect from loginctl
+  try {
+    const sessionType = execFileSync('bash', ['-c',
+      'loginctl show-session $(loginctl 2>/dev/null | grep $USER | head -1 | awk \'{print $1}\') -p Type --value 2>/dev/null'
+    ], { encoding: 'utf8', timeout: 3000 }).trim()
+    if (sessionType === 'wayland') return 'wayland-0'
+  } catch { /* ignore */ }
+
+  return undefined
+}
+
+/** Build env vars that must be set for LWE, as KEY=VALUE strings for use with `env` command. */
+function buildLweEnvVars(): string[] {
+  const vars: string[] = []
+
+  vars.push(`LD_LIBRARY_PATH=${getLweLdLibraryPath()}`)
+
+  // 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}`)
+
+  // 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/XWayland)
+  if (process.env.DISPLAY) vars.push(`DISPLAY=${process.env.DISPLAY}`)
+
+  // Ensure XDG_RUNTIME_DIR is set (needed for Wayland socket)
+  const xdgRuntime = process.env.XDG_RUNTIME_DIR || `/run/user/${process.getuid?.() ?? 1000}`
+  vars.push(`XDG_RUNTIME_DIR=${xdgRuntime}`)
+
+  return vars
+}
+
+/** Build the common LWE args (assets dir, screen roots, fps). */
+function buildLweArgs(
+  wallpaperPath: string,
+  options: { screenRoot?: string; fps?: number }
+): string[] {
+  const args: string[] = []
+  const assetsDir = findWeAssetsDir()
+  if (assetsDir) args.push('--assets-dir', assetsDir)
+
+  // --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()
+  const lweArgs = buildLweArgs(wallpaperPath, options)
+  const envVars = buildLweEnvVars()
+
+  // 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', '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)
+        const msg = chunk.toString('utf8').trim()
+        if (msg) console.log('[LWE stderr]', msg)
+      })
+    }
+
+    let settled = false
+    const cleanup = () => {
+      activeProcess = null
+    }
+
+    child.on('exit', (code) => {
+      cleanup()
+      if (settled) return
+      settled = true
+      if (code !== 0 && code !== null) {
+        const stderr = Buffer.concat(stderrChunks).toString('utf8').trim()
+        const msg = stderr ? stderr.split('\n').slice(0, 3).join('\n') : `Exit code ${code}`
+        reject(new Error(`linux-wallpaperengine failed: ${msg}`))
+      } else {
+        resolve()
+      }
+    })
+
+    child.on('error', (err) => {
+      cleanup()
+      if (!settled) {
+        settled = true
+        reject(err)
+      }
+    })
+
+    child.unref()
+
+    // If still running after 2s, consider it started successfully
+    setTimeout(() => {
+      if (!settled && activeProcess === child && child.exitCode === null) {
+        settled = true
+        resolve()
+      }
+    }, 2000)
+  })
+}
+
+/** Sync launch (no wait); use launchLweAsync in handlers so startup failures are visible. */
+export function launchLwe(
+  wallpaperPath: string,
+  options: { screenRoot?: string; fps?: number } = {}
+): ChildProcess {
+  stopLwe()
+  const binaryPath = getLweBinaryPath()
+  const lweArgs = buildLweArgs(wallpaperPath, options)
+  const envVars = buildLweEnvVars()
+  activeProcess = spawn('env', [...envVars, binaryPath, ...lweArgs], { stdio: 'ignore', detached: true })
+  activeProcess.on('exit', () => { activeProcess = null })
+  activeProcess.unref()
+  return activeProcess
+}
+
+export function stopLwe(): void {
+  if (activeProcess) {
+    try {
+      activeProcess.kill('SIGTERM')
+    } catch { /* already dead */ }
+    activeProcess = null
+  }
+}
+
+export function isLweRunning(): boolean {
+  return activeProcess !== null && activeProcess.exitCode === null
+}

+ 66 - 2
src/main/services/steam.service.ts

@@ -1,3 +1,4 @@
+import * as path from 'path'
 import type { DownloadProgressEvent } from '@shared/types'
 
 // steamworks.js must be required at runtime (native module)
@@ -35,8 +36,71 @@ export async function unsubscribeFromItem(itemId: bigint): Promise<void> {
   await getClient().workshop.unsubscribe(itemId)
 }
 
-export async function voteItem(itemId: bigint, voteUp: boolean): Promise<void> {
-  await getClient().workshop.setUserItemVote(itemId, voteUp)
+// ISteamUGC::SetUserItemVote is not bound in steamworks.js, so we call the
+// flat C API directly via koffi FFI. libsteam_api.so is already loaded by
+// steamworks.js, so dlopen just returns the existing handle.
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const koffi = require('koffi')
+const _libPath = path.join(
+  path.dirname(require.resolve('steamworks.js')),
+  'dist', 'linux64', 'libsteam_api.so'
+)
+const _steamLib = koffi.load(_libPath)
+const _UGCType = koffi.opaque('ISteamUGC')
+const _UGCPtr = koffi.pointer(_UGCType)
+const _getSteamUGC = _steamLib.func('SteamAPI_SteamUGC_v020', _UGCPtr, [])
+const _setUserItemVote = _steamLib.func(
+  'SteamAPI_ISteamUGC_SetUserItemVote', 'uint64', [_UGCPtr, 'uint64', 'bool']
+)
+// Resolved lazily after SteamAPI_Init (called inside steamworks.init)
+let _ugcPtr: unknown = null
+function getUGCPtr(): unknown {
+  if (!_ugcPtr) _ugcPtr = _getSteamUGC()
+  return _ugcPtr
+}
+
+export function voteOnItem(itemId: bigint, voteUp: boolean): void {
+  const handle = _setUserItemVote(getUGCPtr(), itemId, voteUp)
+  // k_uAPICallInvalid = 0 means the call failed immediately
+  if (handle === BigInt(0)) throw new Error('SetUserItemVote failed')
+  invalidateVoteCache()
+}
+
+export function openWorkshopItemOverlay(itemId: bigint): void {
+  getClient().overlay.activateToWebPage(
+    `https://steamcommunity.com/sharedfiles/filedetails/?id=${itemId}`
+  )
+}
+
+// steamworks.js does not expose SetUserItemVote, so we fetch via getUserItems(VotedUp).
+// Results are cached for the process lifetime; call invalidateVoteCache() after a vote.
+let _votedUpCache: Set<string> | null = null
+
+export async function getVotedUpItemIds(): Promise<string[]> {
+  if (_votedUpCache) return [..._votedUpCache]
+  const c = getClient()
+  const accountId = c.localplayer.getSteamId().accountId
+  const ids = new Set<string>()
+  let page = 1
+  while (true) {
+    const r = await c.workshop.getUserItems(
+      page, accountId,
+      2 /* VotedUp */, 0 /* Items */,
+      1 /* CreationOrderDesc */,
+      { consumer: 431960 }
+    )
+    for (const item of r.items) {
+      if (item) ids.add(item.publishedFileId.toString())
+    }
+    if (r.returnedResults === 0 || ids.size >= r.totalResults) break
+    page++
+  }
+  _votedUpCache = ids
+  return [...ids]
+}
+
+export function invalidateVoteCache(): void {
+  _votedUpCache = null
 }
 
 export function getSubscribedItems(): string[] {

+ 13 - 2
src/main/services/wallpaper.service.ts

@@ -3,12 +3,17 @@ import { promisify } from 'util'
 import * as os from 'os'
 import { detectDisplayServer, detectDesktopEnv, isCommandAvailable } from '../utils/platform'
 import type { WallpaperBackend, WallpaperEnvironment } from '@shared/types'
+import { getLweStatus } from './lwe.service'
 
 const execFileAsync = promisify(execFile)
 
-// Cache environment detection — it never changes during a session
+// Cache environment detection
 let envCache: WallpaperEnvironment | null = null
 
+export function invalidateEnvCache(): void {
+  envCache = null
+}
+
 export function detectEnvironment(): WallpaperEnvironment {
   if (envCache) return envCache
 
@@ -38,7 +43,13 @@ export function detectEnvironment(): WallpaperEnvironment {
   else if (displayServer === 'wayland' && available.includes('swww')) recommended = 'swww'
   else if (available.includes('feh')) recommended = 'feh'
 
-  envCache = { displayServer, desktopEnv, availableBackends: available, recommendedBackend: recommended }
+  envCache = {
+    displayServer,
+    desktopEnv,
+    availableBackends: available,
+    recommendedBackend: recommended,
+    linuxWallpaperEngine: getLweStatus()
+  }
   return envCache
 }
 

+ 74 - 0
src/main/utils/platform.ts

@@ -20,6 +20,67 @@ export function detectDesktopEnv(): DesktopEnvironment {
   return 'other'
 }
 
+/** Detect connected screen/output names for use with linux-wallpaperengine --screen-root. */
+export function getConnectedScreens(): string[] {
+  if (process.platform !== 'linux') return []
+
+  // Electron often runs under XWayland, so WAYLAND_DISPLAY may be unset even on Wayland sessions.
+  // Check XDG_SESSION_TYPE as well to detect the real session type.
+  const isWayland = !!process.env.WAYLAND_DISPLAY || process.env.XDG_SESSION_TYPE === 'wayland'
+
+  // On Wayland, we MUST use native Wayland output names (not xrandr/XWayland names)
+  if (isWayland) {
+    // wlr-randr (wlroots compositors: hyprland, sway, etc.)
+    try {
+      const output = execSync('wlr-randr 2>/dev/null', { encoding: 'utf8', timeout: 5000 })
+      const screens = output
+        .split('\n')
+        .filter(line => /^[A-Za-z]/.test(line) && !line.startsWith(' '))
+        .map(line => line.split(/\s+/)[0])
+        .filter(Boolean)
+      if (screens.length > 0) return screens
+    } catch { /* not available */ }
+
+    // KDE Plasma Wayland: kscreen-doctor
+    try {
+      const output = execSync('kscreen-doctor --outputs 2>/dev/null', { encoding: 'utf8', timeout: 5000 })
+      // Strip ANSI color/escape codes before parsing
+      const clean = output.replace(/\x1b\[[0-9;]*m/g, '')
+      const screens = clean
+        .split('\n')
+        .map(line => {
+          const m = line.match(/Output:\s+\d+\s+(\S+)/)
+          return m ? m[1] : null
+        })
+        .filter((s): s is string => s !== null)
+      if (screens.length > 0) return screens
+    } catch { /* not available */ }
+
+    // GNOME Wayland: gnome-randr or mutter output names via gdbus
+    try {
+      const output = execSync(
+        "gdbus call --session --dest org.gnome.Mutter.DisplayConfig --object-path /org/gnome/Mutter/DisplayConfig --method org.gnome.Mutter.DisplayConfig.GetCurrentState 2>/dev/null",
+        { encoding: 'utf8', timeout: 5000 }
+      )
+      const screens = [...output.matchAll(/connector:\s*'([^']+)'/gi)].map(m => m[1])
+      if (screens.length > 0) return screens
+    } catch { /* not available */ }
+  }
+
+  // X11: use xrandr
+  try {
+    const output = execSync('xrandr --query 2>/dev/null', { encoding: 'utf8', timeout: 5000 })
+    const screens = output
+      .split('\n')
+      .filter(line => / connected/i.test(line))
+      .map(line => line.split(/\s+/)[0])
+      .filter(Boolean)
+    if (screens.length > 0) return screens
+  } catch { /* xrandr not available */ }
+
+  return []
+}
+
 // Cache results so we only call which/where once per session
 const commandCache = new Map<string, boolean>()
 
@@ -35,3 +96,16 @@ export function isCommandAvailable(cmd: string): boolean {
     return false
   }
 }
+
+export function invalidateCommandCache(cmd: string): void {
+  commandCache.delete(cmd)
+}
+
+export function whichCommand(cmd: string): string | undefined {
+  try {
+    const checker = process.platform === 'win32' ? 'where' : 'which'
+    return execSync(`${checker} ${cmd}`, { encoding: 'utf8' }).trim()
+  } catch {
+    return undefined
+  }
+}

+ 45 - 5
src/preload/index.ts

@@ -9,7 +9,10 @@ import type {
   LibraryFilters,
   ApplyWallpaperOptions,
   DownloadProgressEvent,
-  WallpaperEnvironment
+  WallpaperEnvironment,
+  LweStatus,
+  LweInstallProgress,
+  LinuxDistro
 } from '../shared/types'
 
 const api = {
@@ -33,7 +36,11 @@ const api = {
     itemState: (itemId: string): Promise<number> =>
       ipcRenderer.invoke(IpcChannels.STEAM_ITEM_STATE, itemId),
     vote: (itemId: string, voteUp: boolean): Promise<{ ok: boolean }> =>
-      ipcRenderer.invoke(IpcChannels.STEAM_VOTE, itemId, voteUp)
+      ipcRenderer.invoke(IpcChannels.STEAM_VOTE, itemId, voteUp),
+    openWorkshopItem: (itemId: string): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.STEAM_OPEN_WORKSHOP, itemId),
+    getVotedIds: (): Promise<string[]> =>
+      ipcRenderer.invoke(IpcChannels.STEAM_GET_VOTED_IDS)
   },
 
   library: {
@@ -52,7 +59,7 @@ const api = {
     getTags: (): Promise<string[]> => ipcRenderer.invoke(IpcChannels.LIBRARY_GET_TAGS),
     search: (query: string): Promise<WallpaperMeta[]> =>
       ipcRenderer.invoke(IpcChannels.LIBRARY_SEARCH, query),
-    scan: (): Promise<{ imported: number; skipped: number }> =>
+    scan: (): Promise<{ imported: number; skipped: number; removed: number }> =>
       ipcRenderer.invoke(IpcChannels.LIBRARY_SCAN),
     distinctTags: (): Promise<string[]> =>
       ipcRenderer.invoke(IpcChannels.LIBRARY_DISTINCT_TAGS)
@@ -87,7 +94,9 @@ const api = {
     removeItems: (folderId: string, itemIds: string[]): Promise<WallpaperFolder | null> =>
       ipcRenderer.invoke(IpcChannels.FOLDERS_REMOVE_ITEMS, folderId, itemIds),
     importWEConfig: (configPath: string): Promise<{ folders: number; playlists: number }> =>
-      ipcRenderer.invoke(IpcChannels.FOLDERS_IMPORT_WE_CONFIG, configPath)
+      ipcRenderer.invoke(IpcChannels.FOLDERS_IMPORT_WE_CONFIG, configPath),
+    cleanup: (): Promise<{ removed: number }> =>
+      ipcRenderer.invoke(IpcChannels.FOLDERS_CLEANUP)
   },
 
   wallpaper: {
@@ -99,13 +108,39 @@ const api = {
       ipcRenderer.invoke(IpcChannels.WALLPAPER_DETECT_ENV)
   },
 
+  lwe: {
+    status: (): Promise<LweStatus> =>
+      ipcRenderer.invoke(IpcChannels.LWE_STATUS),
+    detectDistro: (): Promise<LinuxDistro> =>
+      ipcRenderer.invoke(IpcChannels.LWE_DETECT_DISTRO),
+    installDeps: (): Promise<{ ok: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.LWE_INSTALL_DEPS),
+    install: (): Promise<LweStatus> =>
+      ipcRenderer.invoke(IpcChannels.LWE_INSTALL),
+    uninstall: (): Promise<{ ok: boolean; message: string }> =>
+      ipcRenderer.invoke(IpcChannels.LWE_UNINSTALL),
+    launch: (wallpaperPath: string, options?: { screenRoot?: string; fps?: number }): Promise<{ ok: boolean; running: boolean }> =>
+      ipcRenderer.invoke(IpcChannels.LWE_LAUNCH, wallpaperPath, options),
+    stop: (): Promise<{ ok: boolean }> =>
+      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),
     openWithDefault: (filePath: string): Promise<{ ok: boolean; error?: string }> =>
       ipcRenderer.invoke(IpcChannels.SHELL_OPEN_WITH_DEFAULT, filePath),
     openPath: (targetPath: string): Promise<{ ok: boolean; error?: string }> =>
-      ipcRenderer.invoke(IpcChannels.SHELL_OPEN_PATH, targetPath)
+      ipcRenderer.invoke(IpcChannels.SHELL_OPEN_PATH, targetPath),
+    openExternal: (url: string): Promise<{ ok: boolean; error?: string }> =>
+      ipcRenderer.invoke(IpcChannels.SHELL_OPEN_EXTERNAL, url)
   },
 
   on: {
@@ -123,6 +158,11 @@ const api = {
       const listener = (_: Electron.IpcRendererEvent, running: boolean) => cb(running)
       ipcRenderer.on(IpcChannels.EVENT_STEAM_STATUS, listener)
       return () => ipcRenderer.off(IpcChannels.EVENT_STEAM_STATUS, listener)
+    },
+    lweInstallProgress: (cb: (progress: LweInstallProgress) => void): (() => void) => {
+      const listener = (_: Electron.IpcRendererEvent, progress: LweInstallProgress) => cb(progress)
+      ipcRenderer.on(IpcChannels.EVENT_LWE_INSTALL_PROGRESS, listener)
+      return () => ipcRenderer.off(IpcChannels.EVENT_LWE_INSTALL_PROGRESS, listener)
     }
   }
 }

+ 162 - 76
src/renderer/src/components/library/LibraryView.tsx

@@ -18,10 +18,11 @@ import {
   FolderInput,
   Eye,
   ThumbsUp,
-  ThumbsDown
+  ThumbsDown,
+  Eraser
 } from 'lucide-react'
 import WallpaperCard from './WallpaperCard'
-import type { LibraryFilters, WallpaperFolder } from '@shared/types'
+import type { LibraryFilters, WallpaperFolder, LweStatus } from '@shared/types'
 import clsx from 'clsx'
 import { WE_TYPES, WE_AGE_RATINGS } from '../../constants/weFilters'
 
@@ -183,27 +184,37 @@ function FolderMenu({
   onRename: () => void
   onDelete: () => void
 }) {
+  const ref = useRef<HTMLDivElement>(null)
+
+  useEffect(() => {
+    function onMouseDown(e: MouseEvent) {
+      if (ref.current && !ref.current.contains(e.target as Node)) {
+        onClose()
+      }
+    }
+    document.addEventListener('mousedown', onMouseDown)
+    return () => document.removeEventListener('mousedown', onMouseDown)
+  }, [onClose])
+
   return (
-    <>
-      <div className="fixed inset-0 z-40" onClick={onClose} />
-      <div
-        className="fixed z-50 min-w-[140px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl text-sm"
-        style={{ left: x, top: y }}
+    <div
+      ref={ref}
+      className="fixed z-50 min-w-[140px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl text-sm"
+      style={{ left: x, top: y }}
+    >
+      <button
+        onClick={onRename}
+        className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
       >
-        <button
-          onClick={onRename}
-          className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-        >
-          <Pencil size={12} /> Rename
-        </button>
-        <button
-          onClick={onDelete}
-          className="flex w-full items-center gap-2 px-3 py-1.5 text-red-400 hover:bg-white/5"
-        >
-          <Trash2 size={12} /> Delete
-        </button>
-      </div>
-    </>
+        <Pencil size={12} /> Rename
+      </button>
+      <button
+        onClick={onDelete}
+        className="flex w-full items-center gap-2 px-3 py-1.5 text-red-400 hover:bg-white/5"
+      >
+        <Trash2 size={12} /> Delete
+      </button>
+    </div>
   )
 }
 
@@ -224,6 +235,7 @@ export default function LibraryView() {
   const [scanResult, setScanResult] = useState<{
     imported: number
     skipped: number
+    removed: number
   } | null>(null)
 
   // Pagination
@@ -254,6 +266,19 @@ export default function LibraryView() {
     ids: string[]
     showFolderSub: boolean
   } | null>(null)
+  const ctxMenuRef = useRef<HTMLDivElement>(null)
+
+  // Close wallpaper context menu on outside click (mousedown so right-click on another card works)
+  useEffect(() => {
+    if (!ctxMenu) return
+    function onMouseDown(e: MouseEvent) {
+      if (ctxMenuRef.current && !ctxMenuRef.current.contains(e.target as Node)) {
+        setCtxMenu(null)
+      }
+    }
+    document.addEventListener('mousedown', onMouseDown)
+    return () => document.removeEventListener('mousedown', onMouseDown)
+  }, [ctxMenu])
 
   useEffect(() => {
     localStorage.setItem(STORAGE_KEY, JSON.stringify(filterState))
@@ -310,18 +335,35 @@ export default function LibraryView() {
     })
   }, [allWallpapers, filterState])
 
-  // Set of all IDs that belong to at least one folder
+  // Set of all existing library wallpaper IDs (for cross-referencing)
+  const existingIds = useMemo(
+    () => new Set(allWallpapers.map((w) => w.id)),
+    [allWallpapers]
+  )
+
+  // Set of all IDs that belong to at least one folder AND exist in the library
   const allFolderItemIds = useMemo(() => {
     const set = new Set<string>()
     for (const f of folders) {
-      for (const id of f.items) set.add(id)
+      for (const id of f.items) {
+        if (existingIds.has(id)) set.add(id)
+      }
     }
     return set
-  }, [folders])
+  }, [folders, existingIds])
+
+  // Real item count per folder (only items that exist in the library)
+  const folderCounts = useMemo(() => {
+    const map = new Map<string, number>()
+    for (const f of folders) {
+      map.set(f.id, f.items.filter((id) => existingIds.has(id)).length)
+    }
+    return map
+  }, [folders, existingIds])
 
   // Folder filtering:
-  // "All" = everything NOT inside any folder (unsorted)
-  // Specific folder = only that folder's items
+  // Default (null) = everything NOT inside any folder (unsorted)
+  // Specific folder = only that folder's items (wallpapers can be in multiple folders)
   const allWallpapersForView = useMemo(() => {
     if (!activeFolder) {
       return filtered.filter((w) => !allFolderItemIds.has(w.id))
@@ -355,6 +397,18 @@ export default function LibraryView() {
     queryFn: () => window.electronAPI.library.distinctTags()
   })
 
+  const { data: lweStatus } = useQuery({
+    queryKey: ['lwe-status'],
+    queryFn: () => window.electronAPI.lwe.status()
+  })
+
+  const { data: votedIds } = useQuery({
+    queryKey: ['steam-voted-ids'],
+    queryFn: () => window.electronAPI.steam.getVotedIds(),
+    staleTime: Infinity
+  })
+  const votedSet = useMemo(() => new Set(votedIds ?? []), [votedIds])
+
   async function handleScan() {
     setScanning(true)
     setScanResult(null)
@@ -397,6 +451,16 @@ export default function LibraryView() {
 
   const [importStatus, setImportStatus] = useState<string | null>(null)
 
+  async function handleCleanupFolders() {
+    const { removed } = await window.electronAPI.folders.cleanup()
+    refetchFolders()
+    setImportStatus(
+      removed > 0
+        ? `Cleaned up ${removed} non-existent wallpaper(s) from folders.`
+        : 'All folder items are valid — nothing to clean up.'
+    )
+  }
+
   async function handleRename(id: string) {
     if (!renameValue.trim()) return
     await window.electronAPI.folders.rename(id, renameValue.trim())
@@ -608,20 +672,14 @@ export default function LibraryView() {
     return wallpapers.filter((w) => idSet.has(w.id))
   }, [ctxMenu, wallpapers])
 
-  async function ctxSubscribe() {
-    if (!ctxMenu) return
-    for (const id of ctxMenu.ids) {
-      try { await window.electronAPI.steam.subscribe(id) } catch {}
-    }
-    closeCtxMenu()
-  }
-
   async function ctxUnsubscribe() {
     if (!ctxMenu) return
     for (const id of ctxMenu.ids) {
       try { await window.electronAPI.steam.unsubscribe(id) } catch {}
     }
     closeCtxMenu()
+    queryClient.invalidateQueries({ queryKey: ['library'] })
+    refetchFolders()
   }
 
   async function ctxVote(up: boolean) {
@@ -629,23 +687,24 @@ export default function LibraryView() {
     for (const id of ctxMenu.ids) {
       try { await window.electronAPI.steam.vote(id, up) } catch {}
     }
+    queryClient.invalidateQueries({ queryKey: ['steam-voted-ids'] })
     closeCtxMenu()
   }
 
   function ctxOpenInSteam() {
     if (!ctxMenu) return
     for (const id of ctxMenu.ids) {
-      window.open(`https://steamcommunity.com/sharedfiles/filedetails/?id=${id}`)
+      window.electronAPI.shell.openExternal(`steam://url/CommunityFilePage/${id}`)
     }
     closeCtxMenu()
   }
 
   async function ctxOpenLocally() {
     if (!ctxMenu) return
-    // Open the first item's local folder in the file manager
-    const w = ctxWallpapers[0]
-    if (w?.localPath) {
-      await window.electronAPI.shell.openInFileManager(w.localPath)
+    for (const w of ctxWallpapers) {
+      if (w.localPath) {
+        await window.electronAPI.shell.openPath(w.localPath)
+      }
     }
     closeCtxMenu()
   }
@@ -667,6 +726,19 @@ export default function LibraryView() {
     closeCtxMenu()
   }
 
+  async function ctxMoveToDefault() {
+    if (!ctxMenu) return
+    // Remove from every folder that contains these items
+    for (const folder of folders) {
+      const overlap = ctxMenu.ids.filter((id) => folder.items.includes(id))
+      if (overlap.length > 0) {
+        await window.electronAPI.folders.removeItems(folder.id, overlap)
+      }
+    }
+    refetchFolders()
+    closeCtxMenu()
+  }
+
   async function ctxRemoveFromFolder() {
     if (!ctxMenu || !activeFolder) return
     await window.electronAPI.folders.removeItems(activeFolder, ctxMenu.ids)
@@ -697,7 +769,7 @@ export default function LibraryView() {
         <select
           value={sortBy ?? 'updatedAt'}
           onChange={(e) => setSortBy(e.target.value as LibraryFilters['sortBy'])}
-          className="rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500"
+          className="rounded-lg bg-[#1a1a1a] px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500 [&>option]:bg-[#1a1a1a] [&>option]:text-gray-200"
         >
           <option value="title">Name</option>
           <option value="updatedAt">Date Updated</option>
@@ -716,7 +788,7 @@ export default function LibraryView() {
         <select
           value={pageSize}
           onChange={(e) => setPageSize(Number(e.target.value))}
-          className="rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500"
+          className="rounded-lg bg-[#1a1a1a] px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500 [&>option]:bg-[#1a1a1a] [&>option]:text-gray-200"
         >
           <option value={50}>50 / page</option>
           <option value={100}>100 / page</option>
@@ -793,7 +865,7 @@ export default function LibraryView() {
 
       {scanResult && (
         <div className="border-b border-white/5 px-4 py-2 text-xs text-gray-400">
-          Scan complete — {scanResult.imported} wallpapers imported
+          Scan complete — {scanResult.imported} imported{scanResult.removed > 0 ? `, ${scanResult.removed} removed` : ''}
         </div>
       )}
 
@@ -813,6 +885,13 @@ export default function LibraryView() {
               >
                 <Upload size={12} />
               </button>
+              <button
+                onClick={handleCleanupFolders}
+                title="Clean up folders (remove non-existent wallpapers)"
+                className="rounded p-0.5 text-gray-600 hover:text-gray-300"
+              >
+                <Eraser size={12} />
+              </button>
               <button
                 onClick={() => { setCreatingFolder(true); setNewFolderName('') }}
                 title="Create folder"
@@ -829,7 +908,7 @@ export default function LibraryView() {
             </div>
           )}
 
-          {/* All */}
+          {/* Default */}
           <button
             onClick={() => setActiveFolder(null)}
             className={clsx(
@@ -840,7 +919,7 @@ export default function LibraryView() {
             )}
           >
             <Folder size={14} />
-            <span className="flex-1 text-left truncate">All</span>
+            <span className="flex-1 text-left truncate">Default</span>
             <span className="text-gray-600">{unsortedCount}</span>
           </button>
 
@@ -905,7 +984,7 @@ export default function LibraryView() {
               ) : (
                 <span className="flex-1 truncate text-left">{folder.title}</span>
               )}
-              <span className="text-gray-600">{folder.items.length}</span>
+              <span className="text-gray-600">{folderCounts.get(folder.id) ?? 0}</span>
             </button>
           ))}
         </div>
@@ -971,7 +1050,7 @@ export default function LibraryView() {
                       {folder.title}
                     </p>
                     <p className="text-xs text-gray-500">
-                      {folder.items.length} items
+                      {folderCounts.get(folder.id) ?? 0} items
                     </p>
                   </div>
                 </div>
@@ -985,9 +1064,21 @@ export default function LibraryView() {
                 key={wallpaper.id}
                 wallpaper={wallpaper}
                 selected={selectedIds.has(wallpaper.id)}
+                lweInstalled={lweStatus?.installed ?? false}
+                isLiked={votedSet.has(wallpaper.id)}
                 onApplied={() =>
                   queryClient.invalidateQueries({ queryKey: ['library'] })
                 }
+                onLiked={() => {
+                  queryClient.setQueryData<string[]>(['steam-voted-ids'], (old) =>
+                    old ? [...old, wallpaper.id] : [wallpaper.id]
+                  )
+                  queryClient.invalidateQueries({ queryKey: ['steam-voted-ids'] })
+                }}
+                onUnsubscribed={() => {
+                  queryClient.invalidateQueries({ queryKey: ['library'] })
+                  refetchFolders()
+                }}
                 onSelect={(e) => handleCardSelect(wallpaper.id, e)}
                 onContextMenu={(e) => openWallpaperCtxMenu(e, wallpaper.id)}
               />
@@ -1050,33 +1141,25 @@ export default function LibraryView() {
 
       {/* Wallpaper context menu */}
       {ctxMenu && (
-        <>
-          <div className="fixed inset-0 z-40" onClick={closeCtxMenu} />
-          <div
-            className="fixed z-50 min-w-[200px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl text-sm"
-            style={{ left: ctxMenu.x, top: ctxMenu.y }}
-          >
-            {ctxMenu.ids.length > 1 && (
-              <div className="px-3 py-1 text-xs text-gray-600 border-b border-white/5 mb-1">
-                {ctxMenu.ids.length} items selected
-              </div>
-            )}
+        <div
+          ref={ctxMenuRef}
+          className="fixed z-50 min-w-[200px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl text-sm"
+          style={{ left: ctxMenu.x, top: ctxMenu.y }}
+        >
+          {ctxMenu.ids.length > 1 && (
+            <div className="px-3 py-1 text-xs text-gray-600 border-b border-white/5 mb-1">
+              {ctxMenu.ids.length} items selected
+            </div>
+          )}
 
-            {/* Steam actions */}
-            <button
-              onClick={ctxSubscribe}
-              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-            >
-              Subscribe
-            </button>
-            <button
-              onClick={ctxUnsubscribe}
-              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-            >
-              Unsubscribe
-            </button>
+          <button
+            onClick={ctxUnsubscribe}
+            className="flex w-full items-center gap-2 px-3 py-1.5 text-red-400 hover:bg-white/5"
+          >
+            Unsubscribe
+          </button>
 
-            <div className="my-1 border-t border-white/5" />
+          <div className="my-1 border-t border-white/5" />
 
             <button
               onClick={() => ctxVote(true)}
@@ -1130,9 +1213,13 @@ export default function LibraryView() {
               </button>
               {ctxMenu.showFolderSub && (
                 <div className="absolute left-full top-0 ml-1 min-w-[160px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl">
-                  {folders.length === 0 && (
-                    <p className="px-3 py-1.5 text-xs text-gray-500">No folders yet</p>
-                  )}
+                  <button
+                    onClick={ctxMoveToDefault}
+                    className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+                  >
+                    <Folder size={12} /> Default
+                  </button>
+                  {folders.length > 0 && <div className="my-1 border-t border-white/5" />}
                   {folders.map((f) => (
                     <button
                       key={f.id}
@@ -1155,8 +1242,7 @@ export default function LibraryView() {
                 <Trash2 size={12} /> Remove from folder
               </button>
             )}
-          </div>
-        </>
+        </div>
       )}
     </div>
   )

+ 108 - 5
src/renderer/src/components/library/WallpaperCard.tsx

@@ -1,12 +1,16 @@
 import { useState } from 'react'
-import { Play, Loader2, Check } from 'lucide-react'
+import { Play, Loader2, Check, ThumbsUp, Trash2, ExternalLink, Download } from 'lucide-react'
 import type { WallpaperMeta } from '@shared/types'
 import clsx from 'clsx'
 
 interface WallpaperCardProps {
   wallpaper: WallpaperMeta
   selected?: boolean
+  lweInstalled?: boolean
+  isLiked?: boolean
   onApplied?: () => void
+  onLiked?: () => void
+  onUnsubscribed?: () => void
   onSelect?: (e: React.MouseEvent) => void
   onContextMenu?: (e: React.MouseEvent) => void
 }
@@ -14,11 +18,17 @@ interface WallpaperCardProps {
 export default function WallpaperCard({
   wallpaper,
   selected,
+  lweInstalled,
+  isLiked = false,
   onApplied,
+  onLiked,
+  onUnsubscribed,
   onSelect,
   onContextMenu
 }: WallpaperCardProps) {
   const [isApplying, setIsApplying] = useState(false)
+  const [isLiking, setIsLiking] = useState(false)
+  const [unsubState, setUnsubState] = useState<'idle' | 'confirm' | 'pending'>('idle')
   const [error, setError] = useState<string | null>(null)
 
   async function handleApply(e: React.MouseEvent) {
@@ -35,6 +45,34 @@ export default function WallpaperCard({
     }
   }
 
+  async function handleLike(e: React.MouseEvent) {
+    e.stopPropagation()
+    if (isLiked || isLiking) return
+    setIsLiking(true)
+    try {
+      await window.electronAPI.steam.vote(wallpaper.id, true)
+      onLiked?.()
+    } catch (err) {
+      setError((err as Error).message)
+    } finally {
+      setIsLiking(false)
+    }
+  }
+
+  async function handleUnsubscribeClick(e: React.MouseEvent) {
+    e.stopPropagation()
+    if (unsubState === 'idle') { setUnsubState('confirm'); return }
+    if (unsubState !== 'confirm') return
+    setUnsubState('pending')
+    try {
+      await window.electronAPI.steam.unsubscribe(wallpaper.id)
+      onUnsubscribed?.()
+    } catch (err) {
+      setError((err as Error).message)
+      setUnsubState('idle')
+    }
+  }
+
   const previewSrc = wallpaper.previewLocal
     ? `wallpaper://${wallpaper.previewLocal}`
     : wallpaper.previewUrl
@@ -69,7 +107,7 @@ export default function WallpaperCard({
         <Check size={12} />
       </div>
 
-      <div className="aspect-video overflow-hidden bg-[#111]">
+      <div className="aspect-video overflow-hidden bg-[#111] relative">
         {previewSrc ? (
           <img
             src={previewSrc}
@@ -81,6 +119,12 @@ export default function WallpaperCard({
         ) : (
           <div className="flex h-full items-center justify-center text-gray-600">No preview</div>
         )}
+        {wallpaper.downloading && (
+          <div className="absolute inset-0 flex flex-col items-center justify-center gap-1.5 bg-black/70">
+            <Download size={20} className="text-indigo-400 animate-bounce" />
+            <span className="text-xs text-gray-300">Downloading…</span>
+          </div>
+        )}
       </div>
 
       <div className="p-3">
@@ -97,15 +141,74 @@ export default function WallpaperCard({
           </div>
         )}
         {error && <p className="mt-1 text-xs text-red-400">{error}</p>}
+        <div className="mt-2 flex items-center gap-2">
+          <button
+            onClick={handleLike}
+            disabled={isLiked || isLiking}
+            title={isLiked ? 'Already liked on Steam' : 'Like on Steam'}
+            className={clsx(
+              'flex items-center gap-1 rounded px-2 py-1 text-xs transition-colors disabled:cursor-default',
+              isLiked
+                ? 'bg-green-600/30 text-green-300'
+                : 'bg-white/5 text-gray-400 hover:bg-white/10 hover:text-gray-200'
+            )}
+          >
+            {isLiking ? <Loader2 size={11} className="animate-spin" /> : <ThumbsUp size={11} />}
+            {isLiked ? 'Liked' : 'Like'}
+          </button>
+          {unsubState === 'confirm' ? (
+            <div className="flex items-center gap-1">
+              <span className="text-xs text-gray-400">Sure?</span>
+              <button
+                onClick={handleUnsubscribeClick}
+                className="rounded bg-red-600/80 px-2 py-1 text-xs text-white hover:bg-red-600"
+              >
+                Yes
+              </button>
+              <button
+                onClick={(e) => { e.stopPropagation(); setUnsubState('idle') }}
+                className="rounded bg-white/5 px-2 py-1 text-xs text-gray-400 hover:bg-white/10"
+              >
+                No
+              </button>
+            </div>
+          ) : (
+            <button
+              onClick={handleUnsubscribeClick}
+              disabled={unsubState === 'pending'}
+              title="Unsubscribe and delete files"
+              className="flex items-center gap-1 rounded bg-white/5 px-2 py-1 text-xs text-gray-400 transition-colors hover:bg-red-500/20 hover:text-red-400 disabled:cursor-not-allowed disabled:opacity-50"
+            >
+              {unsubState === 'pending' ? <Loader2 size={11} className="animate-spin" /> : <Trash2 size={11} />}
+              Unsub
+            </button>
+          )}
+          <button
+            onClick={(e) => {
+              e.stopPropagation()
+              window.electronAPI.shell.openExternal(`steam://url/CommunityFilePage/${wallpaper.id}`)
+            }}
+            title="Open in Steam Workshop"
+            className="flex items-center gap-1 rounded bg-white/5 px-2 py-1 text-xs text-gray-400 transition-colors hover:bg-white/10 hover:text-gray-200"
+          >
+            <ExternalLink size={11} />
+            Steam
+          </button>
+        </div>
       </div>
 
       {/* Apply button */}
       <div className="absolute right-2 top-2">
         <button
           onClick={handleApply}
-          disabled={isApplying}
-          title="Apply as wallpaper"
-          className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition-opacity hover:bg-indigo-600 group-hover:opacity-100 disabled:cursor-not-allowed"
+          disabled={isApplying || !lweInstalled || wallpaper.downloading}
+          title={wallpaper.downloading ? 'Wallpaper is still downloading' : !lweInstalled ? 'Install linux-wallpaperengine in Settings first' : 'Play wallpaper'}
+          className={clsx(
+            'flex h-7 w-7 items-center justify-center rounded-full transition-opacity disabled:cursor-not-allowed',
+            !lweInstalled || wallpaper.downloading
+              ? 'bg-black/60 text-gray-500 opacity-0 group-hover:opacity-100'
+              : 'bg-black/60 text-white opacity-0 hover:bg-indigo-600 group-hover:opacity-100'
+          )}
         >
           {isApplying ? <Loader2 size={14} className="animate-spin" /> : <Play size={14} />}
         </button>

+ 244 - 2
src/renderer/src/components/settings/SettingsView.tsx

@@ -1,15 +1,51 @@
 import { useState, useEffect } from 'react'
-import { FolderOpen, Save, Upload } 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> = {
+  fedora: 'Fedora / Nobara / RHEL',
+  arch: 'Arch / Manjaro / EndeavourOS',
+  debian: 'Debian / Ubuntu / Mint / Pop!_OS',
+  unknown: 'Unknown'
+}
 
 export default function SettingsView() {
   const [workshopPath, setWorkshopPath] = useState('')
   const [saved, setSaved] = useState(false)
   const [importStatus, setImportStatus] = useState<string | null>(null)
 
+  // linux-wallpaperengine state
+  const [lweStatus, setLweStatus] = useState<LweStatus | null>(null)
+  const [lweInstalling, setLweInstalling] = useState(false)
+  const [lweProgress, setLweProgress] = useState<LweInstallProgress | null>(null)
+  const [distro, setDistro] = useState<LinuxDistro | null>(null)
+  const [depsInstalling, setDepsInstalling] = useState(false)
+  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
+  useEffect(() => {
+    const unsub = window.electronAPI.on.lweInstallProgress((progress) => {
+      setLweProgress(progress)
+      if (progress.stage === 'done' || progress.stage === 'error') {
+        setLweInstalling(false)
+        setDepsInstalling(false)
+        window.electronAPI.lwe.status().then(setLweStatus)
+      }
+    })
+    return unsub
   }, [])
 
   async function handleBrowse() {
@@ -40,8 +76,61 @@ export default function SettingsView() {
     }
   }
 
+  async function handleInstallDeps() {
+    setDepsInstalling(true)
+    setLweProgress({ stage: 'installing-deps', message: 'Starting dependency installation...', percentage: 0 })
+    try {
+      await window.electronAPI.lwe.installDeps()
+    } catch (err) {
+      setLweProgress({
+        stage: 'error',
+        message: `Dependency installation failed: ${(err as Error).message}`,
+        percentage: 0
+      })
+      setDepsInstalling(false)
+    }
+  }
+
+  async function handleInstallLwe() {
+    setLweInstalling(true)
+    setLweProgress({ stage: 'cloning', message: 'Starting installation...', percentage: 0 })
+    try {
+      await window.electronAPI.lwe.install()
+    } catch (err) {
+      setLweProgress({
+        stage: 'error',
+        message: `Installation failed: ${(err as Error).message}`,
+        percentage: 0
+      })
+      setLweInstalling(false)
+    }
+  }
+
+  async function handleUninstallLwe() {
+    if (!confirm('Are you sure you want to uninstall linux-wallpaperengine?')) return
+    setUninstalling(true)
+    setUninstallMsg(null)
+    try {
+      const result = await window.electronAPI.lwe.uninstall()
+      setUninstallMsg(result.message)
+      window.electronAPI.lwe.status().then(setLweStatus)
+    } catch (err) {
+      setUninstallMsg(`Uninstall failed: ${(err as Error).message}`)
+    } finally {
+      setUninstalling(false)
+    }
+  }
+
+  async function handleDesktopIconsToggle() {
+    const newVal = !desktopIconsEnabled
+    setDesktopIconsEnabled(newVal)
+    await window.electronAPI.desktopIcons.setEnabled(newVal)
+  }
+
+  const isBusy = lweInstalling || depsInstalling || uninstalling
+
   return (
-    <div className="p-6">
+    <div className="p-6 overflow-y-auto h-full">
       <h2 className="text-lg font-semibold text-gray-100">Settings</h2>
 
       <div className="mt-6 max-w-lg space-y-8">
@@ -102,6 +191,159 @@ export default function SettingsView() {
             <p className="mt-2 text-xs text-gray-400">{importStatus}</p>
           )}
         </div>
+
+        {/* linux-wallpaperengine */}
+        <div>
+          <label className="block text-xs font-medium uppercase tracking-wide text-gray-500">
+            linux-wallpaperengine
+          </label>
+          <p className="mt-1 text-xs text-gray-600">
+            Required for animated/scene wallpapers on Linux. Renders Wallpaper Engine
+            scenes directly on your desktop.
+          </p>
+
+          {lweStatus === null ? (
+            <p className="mt-3 text-xs text-gray-500">Checking...</p>
+          ) : lweStatus.installed ? (
+            <div className="mt-3 space-y-3">
+              <div className="flex items-center gap-2 text-sm text-green-400">
+                <CheckCircle size={16} />
+                Installed
+              </div>
+              {lweStatus.path && (
+                <p className="text-xs text-gray-500">Path: {lweStatus.path}</p>
+              )}
+              <button
+                onClick={handleUninstallLwe}
+                disabled={isBusy}
+                className="flex items-center gap-2 rounded-lg bg-red-600/20 px-4 py-2 text-sm text-red-400 hover:bg-red-600/30 disabled:opacity-50"
+              >
+                {uninstalling ? (
+                  <Loader2 size={14} className="animate-spin" />
+                ) : (
+                  <Trash2 size={14} />
+                )}
+                {uninstalling ? 'Uninstalling...' : 'Uninstall'}
+              </button>
+              {uninstallMsg && (
+                <p className="text-xs text-gray-400">{uninstallMsg}</p>
+              )}
+            </div>
+          ) : (
+            <div className="mt-3 space-y-3">
+              <div className="flex items-center gap-2 text-sm text-yellow-400">
+                <XCircle size={16} />
+                Not installed
+              </div>
+
+              {distro && distro !== 'unknown' && (
+                <p className="text-xs text-gray-500">
+                  Detected: {DISTRO_LABELS[distro]}
+                </p>
+              )}
+
+              <div className="flex flex-wrap gap-2">
+                {/* Install build deps */}
+                <button
+                  onClick={handleInstallDeps}
+                  disabled={isBusy || !distro || distro === 'unknown'}
+                  title={
+                    !distro || distro === 'unknown'
+                      ? 'Could not detect your distro. Install dependencies manually.'
+                      : undefined
+                  }
+                  className="flex items-center gap-2 rounded-lg bg-white/5 px-4 py-2 text-sm text-gray-300 hover:bg-white/10 disabled:opacity-50"
+                >
+                  {depsInstalling ? (
+                    <Loader2 size={14} className="animate-spin" />
+                  ) : (
+                    <Package size={14} />
+                  )}
+                  {depsInstalling ? 'Installing deps...' : 'Install build dependencies'}
+                </button>
+
+                {/* Build & install lwe */}
+                <button
+                  onClick={handleInstallLwe}
+                  disabled={isBusy}
+                  className="flex items-center gap-2 rounded-lg bg-indigo-600 px-4 py-2 text-sm font-medium text-white hover:bg-indigo-500 disabled:opacity-50"
+                >
+                  {lweInstalling ? (
+                    <Loader2 size={14} className="animate-spin" />
+                  ) : (
+                    <Download size={14} />
+                  )}
+                  {lweInstalling ? 'Building...' : 'Build & install'}
+                </button>
+              </div>
+
+              <p className="text-xs text-gray-600">
+                First install build dependencies for your distro, then build & install
+                linux-wallpaperengine from GitHub. Both steps require sudo.
+              </p>
+
+              {distro === 'unknown' && (
+                <p className="text-xs text-yellow-500/80">
+                  Could not detect your distro. Please install build dependencies
+                  manually (cmake, OpenGL, SDL2, FFmpeg, MPV, GLFW, GLEW, FFTW, PulseAudio dev packages).
+                </p>
+              )}
+            </div>
+          )}
+
+          {/* Install progress */}
+          {lweProgress && (
+            <div className="mt-3 space-y-2">
+              <div className="flex items-center gap-2">
+                {lweProgress.stage === 'error' ? (
+                  <XCircle size={14} className="text-red-400 shrink-0" />
+                ) : lweProgress.stage === 'done' ? (
+                  <CheckCircle size={14} className="text-green-400 shrink-0" />
+                ) : (
+                  <Loader2 size={14} className="animate-spin text-indigo-400 shrink-0" />
+                )}
+                <p className="text-xs text-gray-300 whitespace-pre-wrap break-all">{lweProgress.message}</p>
+              </div>
+              {lweProgress.stage !== 'error' && lweProgress.stage !== 'done' && (
+                <div className="h-1.5 w-full rounded-full bg-white/5">
+                  <div
+                    className="h-full rounded-full bg-indigo-500 transition-all duration-300"
+                    style={{ width: `${lweProgress.percentage}%` }}
+                  />
+                </div>
+              )}
+            </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>
   )

+ 57 - 48
src/renderer/src/components/workshop/WorkshopBrowser.tsx

@@ -143,6 +143,18 @@ export default function WorkshopBrowser() {
   const [page, setPage] = useState(1)
   const [pageSize, setPageSize] = useState(50)
 
+  // Close context menu on outside click (mousedown so right-click on another card works)
+  useEffect(() => {
+    if (!ctxMenu) return
+    function onMouseDown(e: MouseEvent) {
+      if (ctxRef.current && !ctxRef.current.contains(e.target as Node)) {
+        setCtxMenu(null)
+      }
+    }
+    document.addEventListener('mousedown', onMouseDown)
+    return () => document.removeEventListener('mousedown', onMouseDown)
+  }, [ctxMenu])
+
   useEffect(() => {
     saveFilters(filters)
   }, [filters])
@@ -248,7 +260,7 @@ export default function WorkshopBrowser() {
   function ctxOpenInSteam() {
     if (!ctxMenu) return
     for (const id of ctxMenu.ids) {
-      window.open(`https://steamcommunity.com/sharedfiles/filedetails/?id=${id}`)
+      window.electronAPI.shell.openExternal(`steam://url/CommunityFilePage/${id}`)
     }
     closeCtxMenu()
   }
@@ -342,7 +354,7 @@ export default function WorkshopBrowser() {
         <select
           value={queryType}
           onChange={(e) => setQueryType(e.target.value as WorkshopQueryType)}
-          className="rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500"
+          className="rounded-lg bg-[#1a1a1a] px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500 [&>option]:bg-[#1a1a1a] [&>option]:text-gray-200"
         >
           <option value="RankedByVote">Top Rated</option>
           <option value="RankedByPublicationDate">Newest</option>
@@ -572,7 +584,7 @@ export default function WorkshopBrowser() {
                 <select
                   value={pageSize}
                   onChange={(e) => setPageSize(Number(e.target.value))}
-                  className="rounded bg-white/5 px-2 py-1 text-gray-300 outline-none"
+                  className="rounded bg-[#1a1a1a] px-2 py-1 text-gray-300 outline-none [&>option]:bg-[#1a1a1a] [&>option]:text-gray-300"
                 >
                   <option value={50}>50</option>
                   <option value={100}>100</option>
@@ -605,52 +617,49 @@ export default function WorkshopBrowser() {
 
       {/* Context menu */}
       {ctxMenu && (
-        <>
-          <div className="fixed inset-0 z-40" onClick={closeCtxMenu} />
-          <div
-            ref={ctxRef}
-            className="fixed z-50 min-w-[180px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl text-sm"
-            style={{ left: ctxMenu.x, top: ctxMenu.y }}
+        <div
+          ref={ctxRef}
+          className="fixed z-50 min-w-[180px] rounded-lg border border-white/10 bg-[#1a1a1a] py-1 shadow-xl text-sm"
+          style={{ left: ctxMenu.x, top: ctxMenu.y }}
+        >
+          {ctxMenu.ids.length > 1 && (
+            <div className="px-3 py-1 text-xs text-gray-600 border-b border-white/5 mb-1">
+              {ctxMenu.ids.length} items selected
+            </div>
+          )}
+          <button
+            onClick={ctxSubscribe}
+            className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
           >
-            {ctxMenu.ids.length > 1 && (
-              <div className="px-3 py-1 text-xs text-gray-600 border-b border-white/5 mb-1">
-                {ctxMenu.ids.length} items selected
-              </div>
-            )}
-            <button
-              onClick={ctxSubscribe}
-              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-            >
-              Subscribe
-            </button>
-            <button
-              onClick={ctxUnsubscribe}
-              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-            >
-              Unsubscribe
-            </button>
-            <div className="my-1 border-t border-white/5" />
-            <button
-              onClick={() => ctxVote(true)}
-              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-            >
-              👍 Like
-            </button>
-            <button
-              onClick={() => ctxVote(false)}
-              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-            >
-              👎 Dislike
-            </button>
-            <div className="my-1 border-t border-white/5" />
-            <button
-              onClick={ctxOpenInSteam}
-              className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
-            >
-              Open in Steam Workshop
-            </button>
-          </div>
-        </>
+            Subscribe
+          </button>
+          <button
+            onClick={ctxUnsubscribe}
+            className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+          >
+            Unsubscribe
+          </button>
+          <div className="my-1 border-t border-white/5" />
+          <button
+            onClick={() => ctxVote(true)}
+            className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+          >
+            Like
+          </button>
+          <button
+            onClick={() => ctxVote(false)}
+            className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+          >
+            Dislike
+          </button>
+          <div className="my-1 border-t border-white/5" />
+          <button
+            onClick={ctxOpenInSteam}
+            className="flex w-full items-center gap-2 px-3 py-1.5 text-gray-300 hover:bg-white/5"
+          >
+            Open in Steam Workshop
+          </button>
+        </div>
       )}
     </div>
   )

+ 20 - 2
src/shared/ipc-channels.ts

@@ -1,5 +1,5 @@
 export const IpcChannels = {
-  // Workshop browsing (main proxies Steam Web API)
+  // Workshop browsing (steamworks.js)
   WORKSHOP_QUERY: 'workshop:query',
   WORKSHOP_GET_ITEM: 'workshop:get-item',
 
@@ -12,6 +12,8 @@ export const IpcChannels = {
   STEAM_INSTALL_INFO: 'steam:install-info',
   STEAM_IS_RUNNING: 'steam:is-running',
   STEAM_VOTE: 'steam:vote',
+  STEAM_OPEN_WORKSHOP: 'steam:open-workshop',
+  STEAM_GET_VOTED_IDS: 'steam:get-voted-ids',
 
   // Library management
   LIBRARY_GET_ALL: 'library:get-all',
@@ -33,6 +35,7 @@ export const IpcChannels = {
   FOLDERS_ADD_ITEMS: 'folders:add-items',
   FOLDERS_REMOVE_ITEMS: 'folders:remove-items',
   FOLDERS_IMPORT_WE_CONFIG: 'folders:import-we-config',
+  FOLDERS_CLEANUP: 'folders:cleanup',
 
   // Wallpaper application
   WALLPAPER_APPLY: 'wallpaper:apply',
@@ -51,11 +54,26 @@ export const IpcChannels = {
   SHELL_OPEN_PATH: 'shell:open-path',
   SHELL_OPEN_IN_FILE_MANAGER: 'shell:open-in-file-manager',
   SHELL_OPEN_WITH_DEFAULT: 'shell:open-with-default',
+  SHELL_OPEN_EXTERNAL: 'shell:open-external',
+
+  // linux-wallpaperengine
+  LWE_STATUS: 'lwe:status',
+  LWE_DETECT_DISTRO: 'lwe:detect-distro',
+  LWE_INSTALL_DEPS: 'lwe:install-deps',
+  LWE_INSTALL: 'lwe:install',
+  LWE_UNINSTALL: 'lwe:uninstall',
+  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',
-  EVENT_STEAM_STATUS: 'event:steam-status'
+  EVENT_STEAM_STATUS: 'event:steam-status',
+  EVENT_LWE_INSTALL_PROGRESS: 'event:lwe-install-progress'
 } as const
 
 export type IpcChannel = (typeof IpcChannels)[keyof typeof IpcChannels]

+ 16 - 0
src/shared/types.ts

@@ -42,6 +42,7 @@ export interface WallpaperMeta {
   source: 'workshop' | 'local'
   tags: string[]
   categories: string[]
+  downloading?: boolean
 }
 
 export interface WorkshopItem {
@@ -109,4 +110,19 @@ export interface WallpaperEnvironment {
   desktopEnv: 'gnome' | 'kde' | 'hyprland' | 'sway' | 'other'
   availableBackends: WallpaperBackend[]
   recommendedBackend: WallpaperBackend
+  linuxWallpaperEngine: LweStatus
+}
+
+export interface LweStatus {
+  installed: boolean
+  path?: string
+  version?: string
+}
+
+export type LinuxDistro = 'fedora' | 'arch' | 'debian' | 'unknown'
+
+export interface LweInstallProgress {
+  stage: 'installing-deps' | 'cloning' | 'building' | 'installing' | 'done' | 'error'
+  message: string
+  percentage: number
 }

Niektoré súbory nie sú zobrazené, pretože je v týchto rozdielových dátach zmenené mnoho súborov