UwU hai 7 meses
pai
achega
492719e9a2

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

@@ -6,6 +6,7 @@ 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'
 
 export function registerAllHandlers(win: BrowserWindow): void {
   registerSteamHandlers(win)
@@ -15,4 +16,5 @@ export function registerAllHandlers(win: BrowserWindow): void {
   registerConfigHandlers()
   registerFolderHandlers()
   registerShellHandlers()
+  registerLweHandlers(win)
 }

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

@@ -0,0 +1,35 @@
+import { ipcMain, type BrowserWindow } from 'electron'
+import { IpcChannels } from '@shared/ipc-channels'
+import { getLweStatus, detectDistro, installLweDeps, installLwe, 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_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 }
+  })
+}

+ 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, launchLwe } 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'
+    const lwe = getLweStatus()
+
+    // Use linux-wallpaperengine for animated wallpapers on Linux if available
+    if (isAnimated && os.platform() === 'linux' && lwe.installed) {
+      launchLwe(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,

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

@@ -0,0 +1,284 @@
+import { execFile, 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 } from '../utils/platform'
+
+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')
+
+let activeProcess: ChildProcess | null = null
+
+export function getLweStatus(): LweStatus {
+  const installed = isCommandAvailable(LWE_BINARY)
+  if (!installed) return { installed: false }
+
+  return {
+    installed: true,
+    path: whichCommand(LWE_BINARY)
+  }
+}
+
+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'
+}
+
+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', 'libpulse-dev', 'libfftw3-dev'
+]
+
+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'
+]
+
+const DEPS_ARCH = [
+  'base-devel', 'cmake', 'pkg-config',
+  'glew', 'freeglut', 'sdl2', 'lz4', 'ffmpeg',
+  'glm', 'glfw', 'mpv', 'libpulse', 'fftw',
+  'libxrandr', 'libxinerama', 'libxcursor', 'libxi', 'libxxf86vm'
+]
+
+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'
+      installArgs = ['install', '-y']
+      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 — disable -Werror so upstream warnings don't break the build on newer GCC
+    send({ stage: 'building', message: 'Running cmake configure...', percentage: 25 })
+    await execFileAsync('cmake', [
+      '..',
+      '-DCMAKE_BUILD_TYPE=Release',
+      '-DCMAKE_C_FLAGS=-Wno-error',
+      '-DCMAKE_CXX_FLAGS=-Wno-error'
+    ], {
+      cwd: cmakeBuild,
+      timeout: 120_000,
+      maxBuffer: 10 * 1024 * 1024
+    })
+
+    // 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
+    })
+
+    send({ stage: 'building', message: 'Build complete.', percentage: 80 })
+
+    // Install — needs sudo via graphical prompt
+    send({ stage: 'installing', message: 'Installing (sudo required)...', percentage: 85 })
+
+    const installTool = isCommandAvailable('pkexec') ? 'pkexec' : 'sudo'
+    await execFileAsync(installTool, ['make', 'install'], {
+      cwd: cmakeBuild,
+      timeout: 120_000
+    })
+
+    // 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 */ }
+
+    // Extract a short, useful error from potentially huge compiler output
+    const fullMsg = (err as Error).message ?? String(err)
+    const stderr = (err as { stderr?: string }).stderr
+    const errorSource = stderr || fullMsg
+
+    // Find actual error lines (not warnings)
+    const errorLines = errorSource.split('\n').filter(
+      (l: string) => /error:/i.test(l) || /fatal/i.test(l) || /make.*\*\*\*/i.test(l)
+    )
+    const shortMsg = errorLines.length > 0
+      ? errorLines.slice(-3).join('\n')
+      : errorSource.split('\n').slice(-5).join('\n')
+
+    send({
+      stage: 'error',
+      message: `Build failed:\n${shortMsg}`,
+      percentage: 0
+    })
+  }
+}
+
+export function launchLwe(
+  wallpaperPath: string,
+  options: { screenRoot?: string; fps?: number } = {}
+): ChildProcess {
+  stopLwe()
+
+  const args: string[] = []
+
+  if (options.screenRoot) {
+    args.push('--screen-root', options.screenRoot)
+  }
+  if (options.fps) {
+    args.push('--fps', String(options.fps))
+  }
+
+  args.push(wallpaperPath)
+
+  activeProcess = spawn(LWE_BINARY, args, {
+    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
+}

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

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

@@ -35,3 +35,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
+  }
+}

+ 24 - 1
src/preload/index.ts

@@ -9,7 +9,10 @@ import type {
   LibraryFilters,
   ApplyWallpaperOptions,
   DownloadProgressEvent,
-  WallpaperEnvironment
+  WallpaperEnvironment,
+  LweStatus,
+  LweInstallProgress,
+  LinuxDistro
 } from '../shared/types'
 
 const api = {
@@ -101,6 +104,21 @@ 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),
+    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)
+  },
+
   shell: {
     openInFileManager: (folderPath: string): Promise<{ ok: boolean; error?: string }> =>
       ipcRenderer.invoke(IpcChannels.SHELL_OPEN_IN_FILE_MANAGER, folderPath),
@@ -127,6 +145,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)
     }
   }
 }

+ 7 - 1
src/renderer/src/components/library/LibraryView.tsx

@@ -22,7 +22,7 @@ import {
   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'
 
@@ -396,6 +396,11 @@ export default function LibraryView() {
     queryFn: () => window.electronAPI.library.distinctTags()
   })
 
+  const { data: lweStatus } = useQuery({
+    queryKey: ['lwe-status'],
+    queryFn: () => window.electronAPI.lwe.status()
+  })
+
   async function handleScan() {
     setScanning(true)
     setScanResult(null)
@@ -1051,6 +1056,7 @@ export default function LibraryView() {
                 key={wallpaper.id}
                 wallpaper={wallpaper}
                 selected={selectedIds.has(wallpaper.id)}
+                lweInstalled={lweStatus?.installed ?? false}
                 onApplied={() =>
                   queryClient.invalidateQueries({ queryKey: ['library'] })
                 }

+ 10 - 3
src/renderer/src/components/library/WallpaperCard.tsx

@@ -6,6 +6,7 @@ import clsx from 'clsx'
 interface WallpaperCardProps {
   wallpaper: WallpaperMeta
   selected?: boolean
+  lweInstalled?: boolean
   onApplied?: () => void
   onSelect?: (e: React.MouseEvent) => void
   onContextMenu?: (e: React.MouseEvent) => void
@@ -14,6 +15,7 @@ interface WallpaperCardProps {
 export default function WallpaperCard({
   wallpaper,
   selected,
+  lweInstalled,
   onApplied,
   onSelect,
   onContextMenu
@@ -103,9 +105,14 @@ export default function WallpaperCard({
       <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}
+          title={!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
+              ? '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>

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

@@ -1,15 +1,45 @@
 import { useState, useEffect } from 'react'
-import { FolderOpen, Save, Upload } from 'lucide-react'
+import { FolderOpen, Save, Upload, Download, CheckCircle, XCircle, Loader2, Package } 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)
+
   useEffect(() => {
     window.electronAPI.config.get().then((cfg) => {
       setWorkshopPath(cfg.workshopPath ?? cfg.defaultWorkshopPath)
     })
+    window.electronAPI.lwe.status().then(setLweStatus)
+    window.electronAPI.lwe.detectDistro().then(setDistro)
+  }, [])
+
+  // 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 +70,40 @@ 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)
+    }
+  }
+
+  const isBusy = lweInstalling || depsInstalling
+
   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 +164,115 @@ 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-1">
+              <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">{lweStatus.path}</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>
       </div>
     </div>
   )

+ 11 - 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',
 
@@ -54,10 +54,19 @@ export const IpcChannels = {
   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_LAUNCH: 'lwe:launch',
+  LWE_STOP: 'lwe:stop',
+
   // 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]

+ 15 - 0
src/shared/types.ts

@@ -109,4 +109,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
 }