Parcourir la source

added an option to download direct release

UwU il y a 1 jour
Parent
commit
f45194bf47

+ 6 - 4
README.md

@@ -20,10 +20,12 @@ npm run dist     # package with electron-builder
 
 ## Notes
 
-- Settings can build and install linux-wallpaperengine from source (needs
-  sudo for the package manager and `make install`). A custom repository (git
-  URL or local path) and branch can be set there to build from a fork instead
-  of the official repo.
+- Settings downloads a prebuilt linux-wallpaperengine from the
+  [linux-wallpaperengine-kde releases](https://github.com/WallpaperEngineLover/linux-wallpaperengine-kde/releases)
+  on Ubuntu 24.04, Fedora 44 and Arch, into `~/.local/share/linux-wallpaperengine-kde`
+  (sudo only to install missing system libraries). Elsewhere it builds and
+  installs from source (needs sudo for the package manager and `make install`),
+  from a configurable repository (git URL or local path) and branch.
 - The optional desktop icons overlay
   (`src/main/services/desktop-icons-overlay.py`) draws KDE Folder View icons
   on a transparent layer-shell surface above the wallpaper. It needs python3,

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

@@ -1,6 +1,7 @@
 import { BrowserWindow, dialog, ipcMain } from 'electron'
 import { IpcChannels } from '@shared/ipc-channels'
 import { getLweStatus, detectDistro, installLweDeps, installLwe, uninstallLwe, launchLweAsync, isLweRunning, killAllLweProcesses, listLweObjects, hotswapLweSettings, listLweProperties, listLweAudioObjects, listLweEffects, type HotswapOptions } from '../services/lwe.service'
+import { getPrebuiltTarget, installLwePrebuilt } from '../services/lwe-prebuilt.service'
 import { stopDisplay } from '../services/display.service'
 import { stopPlaylist } from '../services/playlist-player.service'
 import type { ScreenTarget } from '@shared/types'
@@ -28,6 +29,14 @@ export function registerLweHandlers(win: BrowserWindow): void {
     return getLweStatus()
   })
 
+  ipcMain.handle(IpcChannels.LWE_PREBUILT_TARGET, () => getPrebuiltTarget())
+
+  ipcMain.handle(IpcChannels.LWE_INSTALL_PREBUILT, async () => {
+    await installLwePrebuilt(win)
+    invalidateEnvCache()
+    return getLweStatus()
+  })
+
   ipcMain.handle(IpcChannels.LWE_UNINSTALL, async () => {
     const result = await uninstallLwe()
     invalidateEnvCache()

+ 232 - 0
src/main/services/lwe-prebuilt.service.ts

@@ -0,0 +1,232 @@
+import { execFile } from 'child_process'
+import { promisify } from 'util'
+import * as fs from 'fs'
+import * as path from 'path'
+import { Readable } from 'stream'
+import { pipeline } from 'stream/promises'
+import type { BrowserWindow } from 'electron'
+import { IpcChannels } from '@shared/ipc-channels'
+import { LWE_RELEASE_REPO } from '@shared/constants'
+import type { LweInstallProgress, LwePrebuiltTarget } from '@shared/types'
+import { isCommandAvailable } from '../utils/platform'
+import { getLwePrebuiltDir } from '../utils/paths'
+import { buildCleanBuildEnv, readPrebuiltRelease, resetLweDetection } from './lwe.service'
+
+const execFileAsync = promisify(execFile)
+
+type PrebuiltDistro = 'ubuntu-24.04' | 'fedora-44' | 'arch'
+
+const DISTRO_LABELS: Record<PrebuiltDistro, string> = {
+  'ubuntu-24.04': 'Ubuntu 24.04',
+  'fedora-44': 'Fedora 44',
+  arch: 'Arch'
+}
+
+// Libraries the engine and CEF load from the system. Fedora installs missing ones by soname instead
+// (see installMissingLibraries), so Nobara's full ffmpeg is never swapped for ffmpeg-free.
+const RUNTIME_DEPS_UBUNTU = [
+  'libglew2.2', 'libsdl2-2.0-0', 'libx11-6', 'libxrandr2',
+  'libavcodec60', 'libavformat60', 'libavutil58', 'libswresample4', 'libswscale7',
+  'libdbus-1-3', 'libfreetype6', 'libglfw3', 'libharfbuzz0b', 'liblz4-1', 'libmpv2', 'libpulse0',
+  'libwayland-client0', 'libwayland-cursor0', 'libwayland-egl1', 'libegl1', 'libgl1',
+  'libnss3', 'libatk1.0-0t64', 'libatk-bridge2.0-0t64', 'libcups2t64', 'libxcomposite1', 'libxdamage1',
+  'libgbm1', 'libxkbcommon0', 'libasound2t64', 'libpango-1.0-0', 'libcairo2'
+]
+
+const RUNTIME_DEPS_ARCH = [
+  'glew', 'sdl2-compat', 'libx11', 'libxrandr', 'ffmpeg', 'dbus', 'freetype2', 'glfw', 'harfbuzz', 'lz4',
+  'mpv', 'libpulse', 'wayland', 'mesa', 'libglvnd', 'nss', 'at-spi2-core', 'libcups', 'libxcomposite',
+  'libxdamage', 'libxkbcommon', 'alsa-lib', 'pango', 'cairo'
+]
+
+function readOsRelease(): Record<string, string> {
+  const fields: Record<string, string> = {}
+  try {
+    for (const line of fs.readFileSync('/etc/os-release', 'utf8').split('\n')) {
+      const match = /^([A-Z_]+)=(.*)$/.exec(line.trim())
+      if (match) fields[match[1]] = match[2].replace(/^["']|["']$/g, '')
+    }
+  } catch { /* no os-release */ }
+  return fields
+}
+
+function detectPrebuiltDistro(): PrebuiltDistro | undefined {
+  const release = readOsRelease()
+  const ids = [release.ID, ...(release.ID_LIKE ?? '').split(/\s+/)].filter(Boolean)
+  if (ids.includes('arch')) return 'arch'
+  // Mint 22 and Pop!_OS 24.04 are built on noble and carry its libraries
+  if (release.UBUNTU_CODENAME === 'noble' || (release.ID === 'ubuntu' && release.VERSION_ID === '24.04')) {
+    return 'ubuntu-24.04'
+  }
+  if (ids.includes('fedora') && release.VERSION_ID === '44') return 'fedora-44'
+  return undefined
+}
+
+function isKdeSession(): boolean {
+  return (process.env.XDG_CURRENT_DESKTOP ?? '').split(':').some(d => d.toUpperCase() === 'KDE')
+}
+
+export function getPrebuiltTarget(): LwePrebuiltTarget {
+  if (process.arch !== 'x64') {
+    return { supported: false, reason: 'Prebuilt builds are x86_64 only, build from source instead.' }
+  }
+  const distro = detectPrebuiltDistro()
+  if (!distro) {
+    const name = readOsRelease().PRETTY_NAME ?? 'this distribution'
+    return {
+      supported: false,
+      reason: `There is no prebuilt build for ${name} (only Ubuntu 24.04, Fedora 44 and Arch), build from source instead.`
+    }
+  }
+  const variant = isKdeSession() ? 'kde' : 'generic'
+  return {
+    supported: true,
+    asset: `linux-wallpaperengine-${variant}-${distro}-x86_64.tar.gz`,
+    label: `${DISTRO_LABELS[distro]}, ${variant === 'kde' ? 'with' : 'without'} KDE integration`
+  }
+}
+
+interface GithubRelease {
+  tag_name: string
+  assets: { name: string; browser_download_url: string; size: number }[]
+}
+
+async function fetchLatestRelease(): Promise<GithubRelease> {
+  const res = await fetch(`https://api.github.com/repos/${LWE_RELEASE_REPO}/releases/latest`, {
+    headers: { Accept: 'application/vnd.github+json', 'User-Agent': 'we-manager' }
+  })
+  if (res.status === 404) throw new Error(`${LWE_RELEASE_REPO} has no releases yet.`)
+  if (!res.ok) throw new Error(`GitHub answered ${res.status} ${res.statusText} for the latest release.`)
+  return (await res.json()) as GithubRelease
+}
+
+async function download(url: string, dest: string, total: number, onProgress: (fraction: number) => void): Promise<void> {
+  const res = await fetch(url, { headers: { 'User-Agent': 'we-manager' } })
+  if (!res.ok || !res.body) throw new Error(`Download failed: ${res.status} ${res.statusText}`)
+
+  const size = Number(res.headers.get('content-length')) || total
+  let received = 0
+  let lastReport = 0
+  const body = Readable.fromWeb(res.body as import('stream/web').ReadableStream)
+  body.on('data', (chunk: Buffer) => {
+    received += chunk.length
+    const now = Date.now()
+    if (size > 0 && now - lastReport > 250) {
+      lastReport = now
+      onProgress(received / size)
+    }
+  })
+  await pipeline(body, fs.createWriteStream(dest))
+}
+
+/** Sonames the binary (with its bundled libs) can't resolve on this system */
+async function findMissingLibraries(binary: string): Promise<string[]> {
+  const { stdout } = await execFileAsync('ldd', [binary], {
+    env: buildCleanBuildEnv(),
+    encoding: 'utf8',
+    maxBuffer: 5 * 1024 * 1024
+  })
+  const missing = new Set<string>()
+  for (const line of stdout.split('\n')) {
+    const match = /^\s*(\S+) => not found/.exec(line)
+    if (match) missing.add(match[1])
+  }
+  return [...missing]
+}
+
+async function installMissingLibraries(distro: PrebuiltDistro, missing: string[]): Promise<void> {
+  const elevate = isCommandAvailable('pkexec') ? 'pkexec' : 'sudo'
+  let command: string[]
+  switch (distro) {
+    case 'fedora-44':
+      command = ['dnf', 'install', '-y', ...missing.map(soname => `${soname}()(64bit)`)]
+      break
+    case 'arch':
+      command = ['pacman', '-S', '--noconfirm', '--needed', ...RUNTIME_DEPS_ARCH]
+      break
+    case 'ubuntu-24.04':
+      command = ['apt-get', 'install', '-y', ...RUNTIME_DEPS_UBUNTU]
+      break
+  }
+  await execFileAsync(elevate, command, { timeout: 600_000, maxBuffer: 10 * 1024 * 1024 })
+}
+
+export async function installLwePrebuilt(win: BrowserWindow): Promise<void> {
+  const send = (progress: LweInstallProgress) => {
+    win.webContents.send(IpcChannels.EVENT_LWE_INSTALL_PROGRESS, progress)
+  }
+
+  const installDir = getLwePrebuiltDir()
+  const archive = `${installDir}.download.tar.gz`
+  const staging = `${installDir}.new`
+  const previous = `${installDir}.old`
+
+  try {
+    const target = getPrebuiltTarget()
+    const distro = detectPrebuiltDistro()
+    if (!target.supported || !target.asset || !distro) throw new Error(target.reason ?? 'No prebuilt build fits this system.')
+
+    send({ stage: 'downloading', message: 'Looking up the latest release...', percentage: 2 })
+    const release = await fetchLatestRelease()
+    const asset = release.assets.find(a => a.name === target.asset)
+    if (!asset) throw new Error(`Release ${release.tag_name} has no ${target.asset}.`)
+
+    const installed = readPrebuiltRelease()
+    const binary = path.join(installDir, 'linux-wallpaperengine')
+    const upToDate = installed?.tag === release.tag_name && installed.asset === asset.name && fs.existsSync(binary)
+
+    if (!upToDate) {
+      const sizeMb = Math.round(asset.size / 1024 / 1024)
+      send({ stage: 'downloading', message: `Downloading ${release.tag_name} (${sizeMb} MB)...`, percentage: 5 })
+      fs.mkdirSync(path.dirname(installDir), { recursive: true })
+      await download(asset.browser_download_url, archive, asset.size, fraction => {
+        send({
+          stage: 'downloading',
+          message: `Downloading ${release.tag_name} (${sizeMb} MB)...`,
+          percentage: 5 + Math.round(fraction * 70)
+        })
+      })
+
+      send({ stage: 'installing', message: 'Unpacking...', percentage: 78 })
+      fs.rmSync(staging, { recursive: true, force: true })
+      fs.mkdirSync(staging)
+      await execFileAsync('tar', ['-xzf', archive, '-C', staging, '--strip-components=1'], { timeout: 300_000 })
+      fs.writeFileSync(path.join(staging, '.release'), JSON.stringify({ tag: release.tag_name, asset: asset.name }))
+
+      // running engines keep their already mapped files, the new ones are used from the next launch
+      fs.rmSync(previous, { recursive: true, force: true })
+      if (fs.existsSync(installDir)) fs.renameSync(installDir, previous)
+      fs.renameSync(staging, installDir)
+      fs.rmSync(previous, { recursive: true, force: true })
+      fs.rmSync(archive, { force: true })
+    }
+
+    send({ stage: 'installing', message: 'Checking system libraries...', percentage: 85 })
+    let missing = await findMissingLibraries(binary)
+    if (missing.length > 0) {
+      send({
+        stage: 'installing',
+        message: `Installing missing system libraries (sudo required): ${missing.join(', ')}`,
+        percentage: 88
+      })
+      await installMissingLibraries(distro, missing)
+      missing = await findMissingLibraries(binary)
+      if (missing.length > 0) {
+        throw new Error(`These system libraries are still missing: ${missing.join(', ')}. Install them with your package manager, or build from source.`)
+      }
+    }
+
+    resetLweDetection()
+    send({
+      stage: 'done',
+      message: upToDate
+        ? `linux-wallpaperengine ${release.tag_name} is already up to date.`
+        : `linux-wallpaperengine ${release.tag_name} installed (${target.label}).`,
+      percentage: 100
+    })
+  } catch (err) {
+    fs.rmSync(archive, { force: true })
+    fs.rmSync(staging, { recursive: true, force: true })
+    send({ stage: 'error', message: `Installation failed: ${(err as Error).message}`, percentage: 0 })
+  }
+}

+ 52 - 26
src/main/services/lwe.service.ts

@@ -25,7 +25,7 @@ import {
   getWaylandDisplay,
   getXdgRuntimeDir
 } from '../utils/platform'
-import { getWorkshopPath, getLweManifestPath } from '../utils/paths'
+import { getWorkshopPath, getLweManifestPath, getLwePrebuiltDir } from '../utils/paths'
 import {
   getLweRepoUrl,
   getLweRepoBranch,
@@ -50,7 +50,7 @@ const execFileAsync = promisify(execFile)
  * that into the engine's build lets CMake's library search pick up we_manager's own bundled
  * libEGL.so etc. and cache that dead path in CMakeCache.txt once the mount is gone.
  */
-function buildCleanBuildEnv(): NodeJS.ProcessEnv {
+export function buildCleanBuildEnv(): NodeJS.ProcessEnv {
   const env = { ...process.env }
   for (const key of ['LD_LIBRARY_PATH', 'LD_PRELOAD', 'APPDIR', 'APPIMAGE', 'OWD', 'ARGV0']) {
     delete env[key]
@@ -66,6 +66,8 @@ const LWE_SEARCH_PATHS = [
   // local fork builds take precedence over system installs
   path.join(os.homedir(), 'projects', 'private', 'linux-wallpaperengine', 'build', 'output', 'linux-wallpaperengine'),
   path.join(os.homedir(), 'projects', 'linux-wallpaperengine', 'build', 'output', 'linux-wallpaperengine'),
+  // a downloaded release wins over a source install, installing from source removes it again
+  path.join(getLwePrebuiltDir(), 'linux-wallpaperengine'),
   '/usr/local/bin/linux-wallpaperengine',
   '/usr/local/linux-wallpaperengine',
   '/usr/bin/linux-wallpaperengine',
@@ -144,9 +146,36 @@ function findLweBinary(): string | undefined {
   return whichCommand(LWE_BINARY)
 }
 
+/** Tag of the downloaded release, written next to it by the prebuilt install */
+export function readPrebuiltRelease(): { tag: string; asset: string } | undefined {
+  try {
+    return JSON.parse(fs.readFileSync(path.join(getLwePrebuiltDir(), '.release'), 'utf8'))
+  } catch {
+    return undefined
+  }
+}
+
+function isPrebuiltPath(p: string): boolean {
+  return p.startsWith(getLwePrebuiltDir() + path.sep)
+}
+
 export function getLweStatus(): LweStatus {
   const found = findLweBinary()
-  return found ? { installed: true, path: found } : { installed: false }
+  if (!found) return { installed: false }
+  const version = isPrebuiltPath(found) ? readPrebuiltRelease()?.tag : undefined
+  return version ? { installed: true, path: found, version } : { installed: true, path: found }
+}
+
+/** Forget everything detected about the previous binary after it was installed, replaced or removed */
+export function resetLweDetection(): void {
+  invalidateCommandCache(LWE_BINARY)
+  invalidateObjectFlagsSupport()
+  invalidateAudioSensitivitySupport()
+  invalidateSoundVolumeSupport()
+  invalidateEffectFlagsSupport()
+  invalidateDisableAnimationsSupport()
+  invalidateExpandCanvasSupport()
+  invalidateHelpText()
 }
 
 export function detectDistro(): LinuxDistro {
@@ -161,32 +190,32 @@ export function detectDistro(): LinuxDistro {
 
 // Aligned with https://github.com/Almamu/linux-wallpaperengine (README + Wayland + FBOProvider gmpxx)
 const DEPS_DEBIAN = [
-  'build-essential', 'cmake', 'pkg-config',
+  'git', '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', 'libdbus-1-dev',
+  'libmpv-dev', 'mpv', 'libpulse-dev', 'libpulse0', 'libfftw3-dev', 'libfreetype-dev', 'libharfbuzz-dev', 'libdbus-1-dev',
   'libwayland-dev', 'wayland-protocols', 'libegl1-mesa-dev',
   'libgmp-dev', 'patchelf'
 ]
 
 // Fedora/Nobara: 'ffmpeg' conflicts with ffmpeg-free, so only ffmpeg-free-devel is listed
 const DEPS_FEDORA = [
-  'gcc', 'g++', 'cmake', 'pkg-config',
+  'git', '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', 'dbus-devel',
+  'mpv-devel', 'pulseaudio-libs-devel', 'fftw-devel', 'freetype-devel', 'harfbuzz-devel', 'dbus-devel',
   'wayland-devel', 'wayland-protocols-devel', 'mesa-libEGL-devel',
   'gmp-devel', 'patchelf'
 ]
 
 const DEPS_ARCH = [
-  'base-devel', 'cmake', 'pkg-config',
+  'git', 'base-devel', 'cmake', 'pkg-config',
   'glew', 'freeglut', 'sdl2', 'lz4', 'ffmpeg',
-  'glm', 'glfw', 'mpv', 'libpulse', 'fftw', 'dbus',
+  'glm', 'glfw', 'mpv', 'libpulse', 'fftw', 'freetype2', 'harfbuzz', 'dbus',
   'libxrandr', 'libxinerama', 'libxcursor', 'libxi', 'libxxf86vm',
   'wayland', 'wayland-protocols', 'mesa',
   'gmp', 'patchelf'
@@ -419,15 +448,9 @@ export async function installLwe(win: BrowserWindow): Promise<void> {
       }
     }
 
-    // Invalidate cache so next status check re-detects
-    invalidateCommandCache(LWE_BINARY)
-    invalidateObjectFlagsSupport()
-    invalidateAudioSensitivitySupport()
-    invalidateSoundVolumeSupport()
-    invalidateEffectFlagsSupport()
-    invalidateDisableAnimationsSupport()
-    invalidateExpandCanvasSupport()
-    invalidateHelpText()
+    // a downloaded release would otherwise keep shadowing what was just built
+    fs.rmSync(getLwePrebuiltDir(), { recursive: true, force: true })
+    resetLweDetection()
 
     const status = getLweStatus()
     if (status.installed) {
@@ -492,6 +515,16 @@ export async function uninstallLwe(): Promise<{ ok: boolean; message: string }>
 
   await stopLwe()
 
+  if (isPrebuiltPath(status.path)) {
+    try {
+      fs.rmSync(getLwePrebuiltDir(), { recursive: true, force: true })
+    } catch (err) {
+      return { ok: false, message: `Uninstall failed: ${(err as Error).message}` }
+    }
+    resetLweDetection()
+    return { ok: true, message: 'linux-wallpaperengine has been uninstalled.' }
+  }
+
   const elevate = isCommandAvailable('pkexec') ? 'pkexec' : 'sudo'
 
   const manifest = readLweManifest()
@@ -539,14 +572,7 @@ export async function uninstallLwe(): Promise<{ ok: boolean; message: string }>
 
     try { fs.rmSync(getLweManifestPath(), { force: true }) } catch { /* ignore */ }
 
-    invalidateCommandCache(LWE_BINARY)
-    invalidateObjectFlagsSupport()
-    invalidateAudioSensitivitySupport()
-    invalidateSoundVolumeSupport()
-    invalidateEffectFlagsSupport()
-    invalidateDisableAnimationsSupport()
-    invalidateExpandCanvasSupport()
-    invalidateHelpText()
+    resetLweDetection()
 
     return { ok: true, message: 'linux-wallpaperengine has been uninstalled.' }
   } catch (err) {

+ 6 - 0
src/main/utils/paths.ts

@@ -49,6 +49,12 @@ export function getPreviewCachePath(): string {
   return path.join(getDataPath(), 'preview-cache')
 }
 
+/** Where a downloaded prebuilt engine lives, outside the app's own data so the AppImage and .deb share it */
+export function getLwePrebuiltDir(): string {
+  const dataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share')
+  return path.join(dataHome, 'linux-wallpaperengine-kde')
+}
+
 export function getLweManifestPath(): string {
   return path.join(getDataPath(), 'lwe-install-manifest.json')
 }

+ 5 - 0
src/preload/index.ts

@@ -13,6 +13,7 @@ import type {
   DownloadProgressEvent,
   WallpaperEnvironment,
   LweStatus,
+  LwePrebuiltTarget,
   LweInstallProgress,
   LinuxDistro,
   LweSceneObject,
@@ -288,6 +289,10 @@ const api = {
       ipcRenderer.invoke(IpcChannels.LWE_INSTALL_DEPS),
     install: (): Promise<LweStatus> =>
       ipcRenderer.invoke(IpcChannels.LWE_INSTALL),
+    prebuiltTarget: (): Promise<LwePrebuiltTarget> =>
+      ipcRenderer.invoke(IpcChannels.LWE_PREBUILT_TARGET),
+    installPrebuilt: (): Promise<LweStatus> =>
+      ipcRenderer.invoke(IpcChannels.LWE_INSTALL_PREBUILT),
     uninstall: (): Promise<{ ok: boolean; message: string }> =>
       ipcRenderer.invoke(IpcChannels.LWE_UNINSTALL),
     launch: (wallpaperPath: string, options?: { screen?: ScreenTarget; fps?: number }): Promise<{ ok: boolean; running: boolean }> =>

+ 135 - 85
src/renderer/src/components/settings/SettingsView.tsx

@@ -1,7 +1,7 @@
 import { useState, useEffect } from 'react'
 import { useQuery, useQueryClient } from '@tanstack/react-query'
 import { FolderOpen, Save, Upload, Download, CheckCircle, XCircle, Loader2, Package, Trash2, Monitor, RotateCcw, Archive, LayoutGrid, Power, Skull, Film, Globe, Shield, Bug } from 'lucide-react'
-import type { LweStatus, LweInstallProgress, LinuxDistro, SteamIdentity } from '../../../../shared/types'
+import type { LweStatus, LwePrebuiltTarget, LweInstallProgress, LinuxDistro, SteamIdentity } from '../../../../shared/types'
 import PerformanceSettings from './PerformanceSettings'
 import DisplaySettings from './DisplaySettings'
 import VoteBorderSettings from './VoteBorderSettings'
@@ -44,6 +44,9 @@ export default function SettingsView() {
   const lweInstalling = lweProgress !== null && !['done', 'error', 'installing-deps'].includes(lweProgress.stage)
   const depsInstalling = lweProgress?.stage === 'installing-deps'
   const [distro, setDistro] = useState<LinuxDistro | null>(null)
+  const [prebuiltTarget, setPrebuiltTarget] = useState<LwePrebuiltTarget | null>(null)
+  const [showSourceBuild, setShowSourceBuild] = useState(false)
+  const sourceBuildVisible = showSourceBuild || prebuiltTarget?.supported === false
   const [uninstalling, setUninstalling] = useState(false)
   const [uninstallMsg, setUninstallMsg] = useState<string | null>(null)
   const [killingLwe, setKillingLwe] = useState(false)
@@ -100,6 +103,7 @@ export default function SettingsView() {
     })
     window.electronAPI.lwe.status().then(setLweStatus)
     window.electronAPI.lwe.detectDistro().then(setDistro)
+    window.electronAPI.lwe.prebuiltTarget().then(setPrebuiltTarget)
     window.electronAPI.desktopIcons.getEnabled().then(setDesktopIconsEnabled)
     window.electronAPI.playlist.getAll().then((all) => setPlaylists(all.map((p) => ({ id: p.id, title: p.title }))))
     window.electronAPI.lwe.listScreens().then(setScreens)
@@ -183,6 +187,19 @@ export default function SettingsView() {
     }
   }
 
+  async function handleInstallPrebuilt() {
+    setLweProgress({ stage: 'downloading', message: 'Starting download...', percentage: 0 })
+    try {
+      await window.electronAPI.lwe.installPrebuilt()
+    } catch (err) {
+      setLweProgress({
+        stage: 'error',
+        message: `Installation failed: ${(err as Error).message}`,
+        percentage: 0
+      })
+    }
+  }
+
   async function handleUninstallLwe() {
     if (!confirm('Are you sure you want to uninstall linux-wallpaperengine?')) return
     setUninstalling(true)
@@ -698,95 +715,128 @@ export default function SettingsView() {
             scenes directly on your desktop.
           </p>
 
-          <div className="mt-3 space-y-2">
-            <label className="block text-xs text-gray-400">Source repository</label>
-            <p className="text-xs text-gray-600">
-              Build from a custom fork instead of the official repo. Accepts a git URL or a
-              local path. Leave empty to use the official repository.
-            </p>
-            <input
-              type="text"
-              value={lweRepoUrl}
-              onChange={(e) => { setLweRepoUrl(e.target.value); setRepoSaved(false) }}
-              placeholder={defaultLweRepo}
-              className="w-full rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500"
-            />
-            <div className="flex gap-2 items-center">
-              <input
-                type="text"
-                value={lweRepoBranch}
-                onChange={(e) => { setLweRepoBranch(e.target.value); setRepoSaved(false) }}
-                placeholder="branch (default)"
-                className="w-44 rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500"
-              />
-              <button
-                onClick={handleSaveRepo}
-                className="flex items-center gap-2 rounded-lg bg-white/5 px-4 py-2 text-sm text-gray-300 hover:bg-white/10"
-              >
-                <Save size={14} />
-                {repoSaved ? 'Saved!' : 'Save'}
-              </button>
-            </div>
-            <label className="block text-xs text-gray-400">Extra cmake arguments</label>
-            <p className="text-xs text-gray-600">
-              Passed to cmake when configuring the build, e.g. -DDISABLE_KDE_FEATURES=ON
-            </p>
-            <input
-              type="text"
-              value={lweCmakeArgs}
-              onChange={(e) => { setLweCmakeArgs(e.target.value); setRepoSaved(false) }}
-              placeholder="-DDISABLE_KDE_FEATURES=ON"
-              className="w-full rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500 font-mono"
-            />
-          </div>
-
           {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>
+              {lweStatus.installed ? (
+                <>
+                  <div className="flex items-center gap-2 text-sm text-green-400">
+                    <CheckCircle size={16} />
+                    Installed{lweStatus.version ? ` (${lweStatus.version})` : ''}
+                  </div>
+                  {lweStatus.path && (
+                    <p className="text-xs text-gray-500">Path: {lweStatus.path}</p>
+                  )}
+                </>
+              ) : (
+                <div className="flex items-center gap-2 text-sm text-yellow-400">
+                  <XCircle size={16} />
+                  Not installed
+                </div>
               )}
+
+              {prebuiltTarget?.supported ? (
+                <p className="text-xs text-gray-500">Prebuilt build: {prebuiltTarget.label}</p>
+              ) : prebuiltTarget && (
+                <p className="text-xs text-yellow-500/80">{prebuiltTarget.reason}</p>
+              )}
+
               <div className="flex flex-wrap gap-2">
-                <button
-                  onClick={handleInstallLwe}
-                  disabled={isBusy}
-                  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"
-                >
-                  {lweInstalling ? (
-                    <Loader2 size={14} className="animate-spin" />
-                  ) : (
-                    <Download size={14} />
-                  )}
-                  {lweInstalling ? 'Building...' : 'Rebuild & install'}
-                </button>
-                <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>
+                {prebuiltTarget?.supported && (
+                  <button
+                    onClick={handleInstallPrebuilt}
+                    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
+                      ? 'Installing...'
+                      : !lweStatus.installed
+                        ? 'Download & install'
+                        : lweStatus.version
+                          ? 'Update'
+                          : 'Switch to prebuilt build'}
+                  </button>
+                )}
+                {lweStatus.installed && (
+                  <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>
+                )}
               </div>
+              {prebuiltTarget?.supported && !lweStatus.installed && (
+                <p className="text-xs text-gray-600">
+                  Downloads the latest release from GitHub. Missing system libraries are
+                  installed through your package manager, which asks for your password.
+                </p>
+              )}
               {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
+          )}
+
+          <button
+            onClick={() => setShowSourceBuild(!sourceBuildVisible)}
+            className="mt-4 text-xs text-gray-400 hover:text-gray-200"
+          >
+            {sourceBuildVisible ? '▾' : '▸'} Build from source
+          </button>
+
+          {sourceBuildVisible && (
+            <div className="mt-2 space-y-2 rounded-lg bg-white/[0.02] p-3">
+              <label className="block text-xs text-gray-400">Source repository</label>
+              <p className="text-xs text-gray-600">
+                A git URL or a local path. Leave empty to use the default repository.
+              </p>
+              <input
+                type="text"
+                value={lweRepoUrl}
+                onChange={(e) => { setLweRepoUrl(e.target.value); setRepoSaved(false) }}
+                placeholder={defaultLweRepo}
+                className="w-full rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500"
+              />
+              <div className="flex gap-2 items-center">
+                <input
+                  type="text"
+                  value={lweRepoBranch}
+                  onChange={(e) => { setLweRepoBranch(e.target.value); setRepoSaved(false) }}
+                  placeholder="branch (default)"
+                  className="w-44 rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500"
+                />
+                <button
+                  onClick={handleSaveRepo}
+                  className="flex items-center gap-2 rounded-lg bg-white/5 px-4 py-2 text-sm text-gray-300 hover:bg-white/10"
+                >
+                  <Save size={14} />
+                  {repoSaved ? 'Saved!' : 'Save'}
+                </button>
               </div>
+              <label className="block text-xs text-gray-400">Extra cmake arguments</label>
+              <p className="text-xs text-gray-600">
+                Passed to cmake when configuring the build, e.g. -DDISABLE_KDE_FEATURES=ON
+              </p>
+              <input
+                type="text"
+                value={lweCmakeArgs}
+                onChange={(e) => { setLweCmakeArgs(e.target.value); setRepoSaved(false) }}
+                placeholder="-DDISABLE_KDE_FEATURES=ON"
+                className="w-full rounded-lg bg-white/5 px-3 py-2 text-sm text-gray-200 outline-none focus:ring-1 focus:ring-indigo-500 font-mono"
+              />
 
               {distro && distro !== 'unknown' && (
                 <p className="text-xs text-gray-500">
@@ -794,7 +844,7 @@ export default function SettingsView() {
                 </p>
               )}
 
-              <div className="flex flex-wrap gap-2">
+              <div className="flex flex-wrap gap-2 pt-1">
                 <button
                   onClick={handleInstallDeps}
                   disabled={isBusy || !distro || distro === 'unknown'}
@@ -816,26 +866,26 @@ export default function SettingsView() {
                 <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"
+                  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"
                 >
                   {lweInstalling ? (
                     <Loader2 size={14} className="animate-spin" />
                   ) : (
                     <Download size={14} />
                   )}
-                  {lweInstalling ? 'Building...' : 'Build & install'}
+                  {lweInstalling ? 'Building...' : lweStatus?.installed ? 'Rebuild & install' : '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.
+                Install the build dependencies for your distro first, then build & install.
+                Both steps require sudo. Installing from source replaces a downloaded prebuilt build.
               </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).
+                  manually (cmake, OpenGL, SDL2, FFmpeg, MPV, GLFW, GLEW, FFTW, PulseAudio, D-Bus dev packages).
                 </p>
               )}
             </div>

+ 3 - 1
src/shared/constants.ts

@@ -5,7 +5,9 @@ export const WE_APP_ID = 431960
 // that needs to be closed on logout, unlike WE_APP_ID above.
 export const STANDALONE_APP_ID = 480
 
-export const DEFAULT_LWE_REPO = 'https://github.com/Almamu/linux-wallpaperengine.git'
+export const DEFAULT_LWE_REPO = 'https://github.com/WallpaperEngineLover/linux-wallpaperengine-kde.git'
+/** GitHub owner/repo whose releases carry the prebuilt engine archives */
+export const LWE_RELEASE_REPO = 'WallpaperEngineLover/linux-wallpaperengine-kde'
 
 export const RECOMMENDED_WEB_FPS = 60
 

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

@@ -132,6 +132,8 @@ export const IpcChannels = {
   LWE_DETECT_DISTRO: 'lwe:detect-distro',
   LWE_INSTALL_DEPS: 'lwe:install-deps',
   LWE_INSTALL: 'lwe:install',
+  LWE_PREBUILT_TARGET: 'lwe:prebuilt-target',
+  LWE_INSTALL_PREBUILT: 'lwe:install-prebuilt',
   LWE_UNINSTALL: 'lwe:uninstall',
   LWE_LAUNCH: 'lwe:launch',
   LWE_STOP: 'lwe:stop',

+ 10 - 1
src/shared/types.ts

@@ -366,8 +366,17 @@ export interface LweStatus {
 
 export type LinuxDistro = 'fedora' | 'arch' | 'debian' | 'unknown'
 
+/** Which prebuilt release archive fits this machine, or why none does */
+export interface LwePrebuiltTarget {
+  supported: boolean
+  /** Release asset name, e.g. linux-wallpaperengine-kde-fedora-44-x86_64.tar.gz */
+  asset?: string
+  label?: string
+  reason?: string
+}
+
 export interface LweInstallProgress {
-  stage: 'installing-deps' | 'cloning' | 'building' | 'installing' | 'done' | 'error'
+  stage: 'installing-deps' | 'downloading' | 'cloning' | 'building' | 'installing' | 'done' | 'error'
   message: string
   percentage: number
 }